The borrow checker is a reader, not a guard
For about a month, Rust felt like arguing with a compiler that had decided my program was wrong
before reading it. Every &mut was a negotiation. I sprinkled .clone() until things built, which
worked, and taught me nothing.
The thing that fixed it was a change in what I thought the borrow checker was for.
It is not checking memory safety
That is what it achieves, but it is not what it is doing. What it is actually enforcing is one rule about aliasing and mutation:
At any point, you may have one mutable reference to a value, or any number of immutable ones. Never both.
That rule exists because aliased mutable state is the thing that makes programs impossible to reason about — not just unsafe. Data races are one consequence. Iterator invalidation is another. So is the entire category of bug where a function you called modified something you were holding.
Once I read the rule as being about reasoning rather than about memory, the errors started making sense, because the compiler was refusing programs I would not have been able to reason about either.
Swift has the same problem and hides it
This is the connection that made it click for me, coming from Swift.
var items = [1, 2, 3]
for item in items {
items.append(item) // reading and mutating the same array
}
Swift handles that by making the for loop iterate over a copy — value semantics, copy-on-write, and
a heap allocation you did not ask for. It is safe, and the cost is hidden.
let mut items = vec![1, 2, 3];
for item in &items {
items.push(*item); // error: cannot borrow `items` as mutable
} // because it is also borrowed as immutable
Rust refuses instead. Same hazard, different answer: Swift copies silently, Rust makes you decide. Neither is wrong, and knowing that Swift was solving the same problem all along made Rust’s version feel less arbitrary.
Three rules that removed most of my errors
Borrows end at last use, not at the end of the scope. This is non-lexical lifetimes, and I spent a week not knowing it:
let mut data = vec![1, 2, 3];
let first = &data[0];
println!("{first}"); // last use of `first` — the borrow ends here
data.push(4); // fine
Half the errors I was fighting were fixed by moving a line, not by restructuring anything.
Split borrows work on fields. The checker tracks individual fields, not just whole structs:
struct Editor {
buffer: String,
cursor: usize,
}
fn edit(editor: &mut Editor) {
let text = &editor.buffer; // borrows one field
let position = &mut editor.cursor; // borrows another — fine
}
It does not do this through a method call, because a method takes &mut self and that borrows
everything. The fix is a free function taking the fields, or split_at_mut-style helpers.
Returning a reference means promising it outlives the function. Almost every lifetime annotation I could not write was a signature that promised something the body could not deliver, and the honest fix was to return an owned value.
Clone is not defeat
I want to argue against a piece of Rust culture here, because it cost me time.
.clone() is a normal thing to write. Cloning a String on a code path that runs once per request
is an allocation of a few dozen bytes, in a program that is about to do file or network I/O. It is
free in every sense that matters.
The version of the rule I use now: clone freely at first, then profile. The places where cloning genuinely matters are loops running millions of times, and those are exactly the places a profiler finds instantly. Writing awkward lifetime-annotated code to avoid an allocation nobody measured is premature optimisation with extra syntax.
Where the model still breaks down
Two cases still require thought rather than intuition, and it is worth saying so.
Graphs and back-references. A tree where children point at parents cannot be expressed with plain
references — the ownership is genuinely cyclic. Rc<RefCell<T>> with Weak for the back-edges is
the standard answer, and it moves the check from compile time to run time. RefCell panics on
violation rather than refusing to compile.
Self-referential structs. A struct holding both a buffer and a slice into that buffer cannot be
moved safely, and this is what Pin exists for. It is genuinely hard, and if you are hitting it in
application code the answer is nearly always to restructure — store an index instead of a reference.
Neither of those came up in ordinary work. What came up was a hundred small errors, and the model above dissolved most of them.
What I would tell myself
Read the error message all the way through, including the note at the bottom — the Rust compiler explains itself better than any compiler I have used, and I spent the first month not reading past the first line.
And stop treating a borrow error as an obstacle between you and a working program. Twice now it has refused something that turned out to be a real bug in how I had structured the data, and I would not have found either of them in Swift until much later.