The whiteboard rule
You write a function that calculates a total while updating a list. In Python, this works. In JavaScript, this works. In Rust, the compiler rejects the code with a wall of red text. You stare at the error, delete a line, add a line, and suddenly it compiles, but you have no idea why.
This happens to everyone. The borrow checker isn't trying to be mean. It enforces rules that prevent entire classes of bugs. Fighting it usually means you are asking Rust to do something unsafe without telling it how to keep you safe. The goal isn't to trick the compiler. The goal is to restructure your data so the compiler can prove your code is correct.
Think of a whiteboard in a meeting room. You can have ten people looking at the whiteboard at once. That is immutable borrowing. Everyone sees the same thing, no one can erase it. If one person wants to write on the whiteboard, they need exclusive access. Everyone else has to step back. That is mutable borrowing. Rust checks at compile time that no one is looking while you are writing. This prevents someone from reading half-written text or getting confused by a change they didn't expect.
Exclusive access prevents confusion. Share freely, mutate alone.
Non-lexical lifetimes save you
Rust tracks how long a reference lives. A common mistake is assuming a borrow lasts until the end of the code block. Modern Rust uses Non-Lexical Lifetimes, or NLL. The compiler tracks the last use of a reference, not the closing brace.
This changes how you write code. You can create a reference, use it, and then mutate the original data in the same block, as long as the reference is not used after the mutation.
fn main() {
let mut numbers = vec![1, 2, 3];
// Create a shared reference to the first element.
let first = &numbers[0];
// Use the reference. This is the last use of `first`.
println!("First: {first}");
// NLL sees `first` is no longer used.
// The immutable borrow ends here.
// Rust allows the mutable borrow for push.
numbers.push(4);
println!("Numbers: {:?}", numbers);
}
If you tried to use first after push, the compiler would reject the code. The borrow extends to the last use. This feature removes many artificial restrictions. You don't need to wrap code in extra blocks just to end a borrow.
Write code by usage, not by scope. The compiler tracks the last read.
Split borrows let you work inside structs
When you take a mutable reference to a struct, you get exclusive access to the whole struct. Rust is smart enough to split that access when you touch individual fields. You can read one field and write another field at the same time.
This is called split borrows. It allows you to write natural code inside methods without fighting the checker.
struct User {
name: String,
score: i32,
}
impl User {
/// Updates the score based on the name length.
fn update_score(&mut self) {
// `&mut self` gives exclusive access to the struct.
// We read `name`. This creates a temporary immutable borrow of `self.name`.
let name_len = self.name.len();
// We write `score`. This creates a mutable borrow of `self.score`.
// Rust sees `name` and `score` are different fields.
// The borrows don't overlap. This is allowed.
self.score += name_len;
}
}
fn main() {
let mut user = User {
name: String::from("Alice"),
score: 0,
};
// This works because `update_score` handles split borrows internally.
user.update_score();
println!("Score: {}", user.score);
}
If you tried to store the reference to name in a variable and then mutate score, you might hit a conflict. The split borrow works best when the reads are temporary. Storing a reference to a field extends the borrow of that field.
Trust the compiler to split borrows. You can read one field and write another inside &mut self.
Pitfalls and compiler errors
The borrow checker catches errors that would cause crashes or data races in other languages. When the compiler rejects your code, the error message points to the conflict. Reading the error code helps you diagnose the issue quickly.
E0502 appears when you try to borrow something as mutable while it is already borrowed as immutable. This is the most common error. It means you have a shared reference alive and you are trying to get exclusive access. The fix is usually to move the use of the immutable borrow before the mutation, or to clone the data if you need both.
E0507 appears when you try to move a value out of a borrowed context. You cannot take ownership of data that you only have a reference to. If you need the data, you must clone it.
E0382 appears when you use a value after it has been moved. Rust moves values by default. If a function takes ownership, the original variable is no longer valid. Use a reference or clone the value before passing it.
fn main() {
let mut data = vec![1, 2, 3];
// Create an immutable borrow.
let reference = &data;
// This fails with E0502.
// `reference` is still alive.
// Rust cannot give exclusive access for push.
// data.push(4);
// Fix: use the reference before mutating.
println!("Len: {}", reference.len());
// `reference` is no longer used.
// The borrow ends. Push is safe.
data.push(4);
}
A common convention is to use shadowing to end borrows early. Reassigning a variable with the same name creates a new binding and drops the old one. This signals to the compiler that the old value is done.
fn main() {
let mut x = 5;
let r = &x;
println!("{r}");
// Shadow `x`. The old `x` is no longer accessible.
// Any borrows of the old `x` must end here.
// This pattern helps the compiler see that `r` is done.
let x = x + 1;
// `r` cannot be used here.
// println!("{r}"); // Error: `r` borrows `x` which is shadowed.
println!("{x}");
}
Read the error code. E0502 tells you exactly where the conflict lives.
Interior mutability as an escape hatch
Sometimes you need to mutate data through a shared reference. Maybe you have a cache that updates itself, or a state machine that changes internally while appearing immutable to the outside. Rust provides interior mutability types for this.
RefCell<T> allows you to borrow data mutably at runtime. It checks the borrow rules when the program runs. If you violate the rules, the program panics. This trades compile-time safety for runtime flexibility.
Use RefCell<T> when the borrow checker is too restrictive and you can guarantee safety yourself. The community convention is to keep the unsafe surface small. RefCell is safe, but it shifts the burden of correctness to you.
use std::cell::RefCell;
struct Cache {
/// The cached value. Wrapped in RefCell for interior mutability.
data: RefCell<Vec<i32>>,
}
impl Cache {
/// Adds a value to the cache.
fn add(&self, value: i32) {
// Borrow the data mutably through a shared reference.
// This returns a guard that tracks the borrow.
let mut guard = self.data.borrow_mut();
guard.push(value);
// `guard` is dropped here. The mutable borrow ends.
}
/// Returns a snapshot of the cache.
fn snapshot(&self) -> Vec<i32> {
// Borrow immutably.
let guard = self.data.borrow();
// Clone the data to return an owned value.
// This avoids returning a reference tied to the borrow.
guard.clone()
}
}
fn main() {
let cache = Cache {
data: RefCell::new(vec![1, 2, 3]),
};
// `cache` is not mutable, but we can modify its contents.
cache.add(4);
println!("{:?}", cache.snapshot());
}
A critical convention with RefCell is to drop the guard explicitly if the scope is too wide. Holding a RefMut or Ref guard for too long can block other borrows and cause runtime panics. Use drop(guard) to release the borrow early.
When combining RefCell with shared ownership, use Rc<T>. The convention is to write Rc::clone(&data) instead of data.clone(). Both compile, but the explicit form signals that you are cloning the reference count, not the underlying data. This prevents confusion for readers who expect a deep clone.
Runtime checks cost performance. Prefer compile-time safety when you can.
Decision matrix
Choosing the right tool depends on your access pattern. Use the simplest option that satisfies your needs.
Use &T when you only need to read data and multiple parts of your code might access it simultaneously. Use &mut T when you need to modify data and can guarantee no one else is looking at it at the same time. Use clone() when you need an independent copy of the data and the cost of copying is acceptable. Use RefCell<T> when you need to mutate data through a shared reference and can accept runtime checks for borrow violations. Use Rc<T> when you need multiple owners of the same data and single-threaded access is sufficient.
Match the tool to the access pattern. Don't overcomplicate simple reads.