DEV Community

Cover image for Rust Borrow Checker Errors: Read Them Before You Clone
Qasim Parray
Qasim Parray

Posted on Originally published at abrarqasim.com

Rust Borrow Checker Errors: Read Them Before You Clone

Short version for the impatient: most borrow checker errors are the compiler asking one question, "who is still holding this?", and you can answer it faster than you think once you stop reading the error from the top.

I came to Rust from PHP and JavaScript, where nobody asks who owns anything. You pass an array around, mutate it in three places, and find out on Friday which place was wrong. So the first few weeks of Rust felt like arguing with a very polite auditor. The error messages are good, genuinely better than most compilers manage. My problem was that I read them as complaints instead of as questions, and my fix for every complaint was the same: add .clone() until it went away.

That works, and it's also how you end up with a Rust program that is slower than the PHP it replaced. Here are the four errors I see most, what each one is really asking, and the restructure that beats cloning.

The rule the compiler is enforcing

The Rust Book chapter on references and borrowing states it in two lines. At any given time you can have either one mutable reference or any number of immutable references to a value. And references must always be valid.

That's the whole borrow checker. Everything else is the compiler working out, for each reference in your function, the stretch of code between where it's created and where it's last used. If a mutable and an immutable stretch overlap on the same value, you get an error. The word "last used" matters. Since non-lexical lifetimes landed, a borrow ends at its final use and not at the closing brace, which is why some code that looks guilty compiles fine.

Hold on to that idea of a stretch of code. Every error below is two stretches overlapping.

Moved value: you handed it away

The most common one for newcomers is E0382, use of a moved value. The ownership chapter says each value has one owner, and passing a Vec or a String into a function by value transfers that ownership. The caller can't use it afterwards.

fn shout_all(v: Vec<String>) -> Vec<String> {
    v.into_iter().map(|s| s.to_uppercase()).collect()
}

fn main() {
    let names = vec![String::from("ada"), String::from("linus")];
    let loud = shout_all(names);
    println!("{} names, first is {}", names.len(), loud[0]);
    // error[E0382]: borrow of moved value: `names`
}
Enter fullscreen mode Exit fullscreen mode

My old reflex was shout_all(names.clone()). It compiles. It also copies every string in the vector just so a function can read them. The question the compiler is really asking is whether shout_all needs to own the data, and here it plainly doesn't. It only reads.

fn shout_all(v: &[String]) -> Vec<String> {
    v.iter().map(|s| s.to_uppercase()).collect()
}

fn main() {
    let names = vec![String::from("ada"), String::from("linus")];
    let loud = shout_all(&names);
    println!("{} names, first is {}", names.len(), loud[0]);
}
Enter fullscreen mode Exit fullscreen mode

Taking &[String] instead of Vec<String> also makes the function accept arrays and slices, so it's the better signature regardless. If a function only reads, borrow. If it keeps the value or changes it for good, take ownership. Cloning is the third option, and it should be a decision rather than a reflex.

Mutating while someone is reading

Next up is E0502: you cannot borrow something as mutable because it's already borrowed as immutable. The classic version is holding a reference into a vector and then pushing to it.

fn main() {
    let mut scores = vec![10, 20, 30];
    let top = &scores[0];
    scores.push(40);
    println!("top score: {top}");
    // error[E0502]: cannot borrow `scores` as mutable
    // because it is also borrowed as immutable
}
Enter fullscreen mode Exit fullscreen mode

Coming from other languages this looks harmless, and in Rust the danger is concrete. push may reallocate the vector's buffer, and then top points at memory that has been freed. In C++ that's a bug you find in production. Here it's a compile error, which I now consider a fair trade even on the days it irritates me.

The two stretches in this example are easy to see: top is alive from line 3 to line 5, and push sits in the middle. There are two honest fixes, and the right one depends on what you wanted. If you only needed the value, copy it out, since integers are Copy:

let top = scores[0];
scores.push(40);
println!("top score: {top}");
Enter fullscreen mode Exit fullscreen mode

If you needed the reference, finish using it before you mutate, which means moving the println! above the push. What you should not do is reach for RefCell or unsafe to silence the check. RefCell moves the same conflict from compile time to a runtime panic, which is a worse place to find it.

Two mutable borrows through a method

E0499 is the one that taught me the most, because the fix isn't obvious until you know one detail about how the checker sees structs. Take a struct with two separate collections and a helper method for each.

struct Inventory {
    items: Vec<String>,
    log: Vec<String>,
}

impl Inventory {
    fn items_mut(&mut self) -> &mut Vec<String> {
        &mut self.items
    }
    fn note(&mut self, msg: &str) {
        self.log.push(msg.to_string());
    }
}

fn main() {
    let mut inv = Inventory { items: vec![], log: vec![] };
    let items = inv.items_mut();
    inv.note("adding a lamp");
    items.push("lamp".to_string());
    // error[E0499]: cannot borrow `inv` as mutable more than once
}
Enter fullscreen mode Exit fullscreen mode

Look at what the signature of items_mut promises: a mutable borrow of self, the whole struct, for as long as the returned reference lives. The checker doesn't peek inside the method body to see that only items is touched. So note collides with it, even though log and items have nothing to do with each other.

Inside a single function, though, the checker does track fields separately. That gives you the fix:

fn main() {
    let mut inv = Inventory { items: vec![], log: vec![] };
    let items = &mut inv.items;
    inv.log.push("adding a lamp".to_string());
    items.push("lamp".to_string());
}
Enter fullscreen mode Exit fullscreen mode

Same behaviour, no clone, no RefCell. The cost is that you're reaching into fields from outside the struct, which some people dislike on design grounds. If that bothers you, the other fix is to split the struct into two smaller ones that each own one collection, and I think that's usually where the design wanted to go anyway. If you want another example of ownership details shaping an API, I wrote up Vec::into_parts and the Box leak habit I had to drop. In my experience an awkward API is often pointing at an awkward ownership story.

Missing lifetime specifier

The last one is E0106, and it has the scariest reputation. It shows up when a function returns a reference and the compiler can't tell which input the reference comes from.

fn longest(a: &str, b: &str) -> &str {
    if a.len() > b.len() { a } else { b }
}
// error[E0106]: missing lifetime specifier
Enter fullscreen mode Exit fullscreen mode

The function could return either argument, so the caller needs to know how long the result stays valid. The fix is a small annotation that says both inputs and the output share one lifetime:

fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() > b.len() { a } else { b }
}
Enter fullscreen mode Exit fullscreen mode

The 'a doesn't change how long anything lives. It's a label that tells the checker "the result is valid for as long as both of these are". The lifetime syntax chapter walks through exactly this example, and I'd read it twice. I did not understand it the first time, and I'm still not sure I'd explain it well to a colleague without a whiteboard.

One more thing I'll say, since people ask: if you find yourself writing lifetime annotations on a struct in your second week, stop and return an owned String instead. Lifetimes in structs are a real tool. They are also a lot of ceremony for code that wasn't hot to begin with.

How I read the errors now

Here's the routine I follow when the compiler complains, and it took me embarrassingly long to settle on it. I skip the top line and go to the labelled spans in the middle of the message. Rust marks where the first borrow starts, where the conflicting use happens, and where the first borrow is still needed. Those three marks are your two overlapping stretches. Then I ask which one I can shorten.

Usually I can shorten the first by finishing with it earlier, or copy a small value out instead of holding a reference, or change a function signature from owned to borrowed. Only after that do I consider a clone, and when I do, I leave a comment saying why. I also wrote about who checks the lifetime in Rust versus C++, and the short reason to put up with all this is that the check exists so you aren't doing it by eye at 2am.

I'm still learning the harder territory here, namely async code holding references across .await points, and trait objects with borrowed data. Those errors look different and I don't trust my instincts on them yet.

Try this week

Open one Rust project of yours, or a small throwaway, and run grep -rn "\.clone()" src/. For each hit, ask whether the function receiving it only reads. If it does, change the parameter to a borrow and delete the clone. Do five of them and run cargo build after each, so the compiler tells you which ones were hiding a real ownership question. If you want help moving a PHP or Node service toward Rust without a rewrite, my portfolio shows the kind of migration work I take on.


Originally published at abrarqasim.com. I write there about React, PHP, Rust, Go and the AI tooling around them.

Top comments (0)