Rust ownership is the one concept that separates “I’ve written Rust” from “I understand
Rust.” You can fake your way through syntax, but the borrow checker will find every
gap in your mental model — usually at the worst possible moment, like a cargo build at
2 a.m. before a deploy.
I stopped fighting the compiler and started reading its error messages as a curriculum.
Every error[E0xxxx] is a lesson in Rust ownership, written by someone who has seen
thousands of beginners make the same mistake. This post is that curriculum: the three
rules, the errors they produce, and the fixes that compile.
If you want the practical side of Rust after this, see Reading a codebase and Rust for CLI tools.
The three rules of Rust ownership
Everything falls out of three rules:
- Each value has exactly one owner. A
String, aVec, a file handle — one variable owns it at any given moment. - Moving transfers ownership. Assigning or passing a value to a function moves it. The old binding is dead.
- A value is dropped when its owner goes out of scope. No garbage collector, no
manual
free. The destructor runs deterministically.
That’s it. Borrowing, lifetimes, and the borrow checker are all consequences of these three rules. The compiler isn’t being difficult — it’s enforcing the only system that makes rule 3 safe without a GC.
Move semantics: the E0382 use-after-move
Here’s the simplest program that breaks rule 2:
fn main() {
let s1 = String::from("hello");
let s2 = s1; // move: s1 is no longer valid
println!("{s1}"); // ERROR: use of moved value
}
Compile it and rustc tells you exactly what happened:
error[E0382]: borrow of moved value: `s1`
--> 01_use_after_move.rs:4:16
|
2 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 | let s2 = s1; // move: s1 is no longer valid
| -- value moved here
4 | println!("{s1}"); // ERROR: use of moved value
| ^^ value borrowed here after move
|
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-back for more info)
help: consider cloning the value if the performance cost is acceptable
|
3 | let s2 = s1.clone(); // move: s1 is no longer valid
| ++++++++error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0382`.
The compiler names the error code (E0382), points to the exact line, explains why
the move happened (String does not implement Copy), and even suggests the fix:
clone(). This is not a compiler being pedantic. This is a compiler teaching you
move semantics in twenty lines.
The fix, when you genuinely need both values:
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone(); // deep copy: both s1 and s2 are valid
println!("{s1} {s2}");
}
Output:
hello hello
clone() is the escape hatch, but it has a cost: a full heap allocation and memcpy. Use
it when you need two independent values, not as a reflex.
Borrowing and the E0502 conflict
Borrowing lets you use a value without taking ownership. You get a reference (&T for
shared, &mut T for exclusive). The rule: you can have either any number of shared
borrows or one mutable borrow, never both at once.
fn main() {
let mut data = vec![1, 2, 3];
let first = &data[0]; // immutable borrow starts
data.push(4); // ERROR: mutable borrow while immutable is live
println!("{first}");
}
The compiler output:
error[E0502]: cannot borrow `data` as mutable because it is also borrowed as immutable
--> 02_borrow_conflict.rs:4:5
|
3 | let first = &data[0]; // immutable borrow starts
| ---- immutable borrow occurs here
4 | data.push(4); // ERROR: mutable borrow while immutable is live
| ^^^^^^^^^^^^ mutable borrow occurs here
5 | println!("{first}");
| ----- immutable borrow later used here
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0502`.
E0502 is the borrow checker’s most famous hit. The immutable borrow first is still
live on line 5, so the mutable borrow on line 4 is illegal. The compiler even tells you
where the immutable borrow is later used — that’s the borrow checker tracking liveness
across lines.
The fix is usually to shorten the borrow’s lifetime. Copy the value out, or scope the borrow:
fn main() {
let mut data = vec![1, 2, 3];
let first = data[0]; // copy the value, not a borrow
data.push(4);
println!("{first}");
}
Output:
1
Lifetimes: E0597 and E0106
A reference must never outlive the data it points to. That’s what lifetimes enforce. Two errors cover almost every beginner mistake.
Dangling reference (E0597)
fn main() {
let r;
{
let x = 5;
r = &x; // ERROR: x does not live long enough
}
println!("{r}");
}
error[E0597]: `x` does not live long enough
--> 03_dangling_ref.rs:5:13
|
4 | let x = 5;
| - binding `x` declared here
5 | r = &x; // ERROR: x does not live long enough
| ^^ borrowed value does not live long enough
6 | }
| - `x` dropped here while still borrowed
7 | println!("{r}");
| - borrow later used here
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0597`.
The compiler shows you the exact scope where x is dropped and where the borrow is used.
The fix is to make the data outlive the reference:
fn main() {
let x = 5;
let r = &x; // x lives long enough now
println!("{r}");
}
Output:
5
Returning a reference to a local (E0106)
fn make_ref() -> &String {
let s = String::from("local");
&s // ERROR: cannot return reference to local variable
}
fn main() {
let r = make_ref();
println!("{r}");
}
error[E0106]: missing lifetime specifier
--> 04_return_local_ref.rs:1:18
|
1 | fn make_ref() -> &String {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime, but this is uncommon unless you're returning a borrowed value from a `const` or a `static`
|
1 | fn make_ref() -> &'static String {
| +++++++
help: instead, you are more likely to want to return an owned value
|
1 - fn make_ref() -> &String {
1 + fn make_ref() -> String {
|
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0106`.
E0106 fires when the compiler can’t tell how long a reference should live. The help
text is unusually good here: it suggests 'static (probably wrong) and then suggests
returning an owned value (probably right). The fix:
fn make_string() -> String {
String::from("local") // return ownership, not a reference
}
fn main() {
let r = make_string();
println!("{r}");
}
Output:
local
Multiple references, one return value
The harder version of E0106 is when a function takes two references and returns one:
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() { x } else { y }
// ERROR: missing lifetime specifier
}
fn main() {
let s1 = String::from("long");
let result;
{
let s2 = String::from("hi");
result = longest(&s1, &s2);
}
println!("{result}");
}
error[E0106]: missing lifetime specifier
--> 05_lifetime_annotation.rs:1:33
|
1 | fn longest(x: &str, y: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
|
1 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
| ++++ ++ ++ ++
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0106`.
The compiler literally writes the fix for you: fn longest<'a>(x: &'a str, y: &'a str) -> &'a str.
The 'a is a lifetime annotation — it says “the returned reference is valid for as
long as both inputs are valid.” The fix:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
fn main() {
let s1 = String::from("long");
let result;
{
let s2 = String::from("hi");
result = longest(&s1, &s2);
println!("{result}");
}
}
Output:
long
Why the borrow checker is a feature
Every error above is the compiler preventing a bug that would be a security vulnerability in C or C++:
- E0382 prevents use-after-free. The moved-from value is gone; using it would be a dangling pointer.
- E0502 prevents data races. A mutable borrow while an immutable borrow is live is a data race waiting to happen.
- E0597 prevents dangling references. The data is dropped; the reference would point to freed memory.
- E0106 prevents returning pointers to stack memory that no longer exists.
The borrow checker is not an obstacle. It is a static analysis tool that proves your program has no data races and no use-after-free — at compile time, with zero runtime cost. Languages with a GC get safety by pausing your program. Languages with manual memory management get speed by trusting you. Rust gets both by making the compiler check the one thing humans are bad at: tracking who owns what, and for how long.
The escape hatches and their costs
Sometimes the borrow checker is wrong — not because the rules are bad, but because the compiler can’t prove your design is safe. That’s when you reach for the escape hatches.
clone()
The simplest. Pay for a deep copy, get two independent values. Cost: heap allocation + memcpy. Use it when the copy is cheap or rare.
Rc<RefCell<T>> for shared ownership
When multiple parts of your code need to read and write the same data, Rc (reference
counting) gives shared ownership and RefCell gives interior mutability:
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let shared = Rc::new(RefCell::new(vec![1, 2, 3]));
let a = Rc::clone(&shared);
let b = Rc::clone(&shared);
a.borrow_mut().push(4);
b.borrow_mut().push(5);
println!("{:?}", shared.borrow());
}
Output:
[1, 2, 3, 4, 5]
Cost: Rc is a runtime reference count (atomic-free, but not thread-safe). RefCell
moves the borrow check to runtime — borrow_mut() will panic if another borrow is live.
You trade a compile-time error for a runtime panic. Use it for single-threaded shared
mutation, like a GUI tree or a game state.
Arc<Mutex<T>> for threads
When you need shared mutation across threads, Arc (atomic reference counting) replaces
Rc and Mutex replaces RefCell:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..4 {
let c = Arc::clone(&counter);
handles.push(thread::spawn(move || {
let mut num = c.lock().unwrap();
*num += 1;
}));
}
for h in handles {
h.join().unwrap();
}
println!("count = {}", *counter.lock().unwrap());
}
Output:
count = 4
Cost: Arc uses atomic operations (more expensive than Rc). Mutex serializes
access — only one thread can hold the lock at a time. You trade parallelism for safety.
Use it when threads genuinely need to mutate shared state; otherwise, prefer message
passing or immutable data.
A mental checklist: who owns this?
When the borrow checker yells at me, I run through this checklist:
- Who owns this value? One variable. If I’m passing it somewhere, am I moving it or borrowing it?
- Do I need it after this line? If yes, I can’t move it. Borrow it, or clone it.
- How long does this reference need to live? If it outlives the current scope, the data must too — or I need to return an owned value instead.
- Do I need shared mutation? If yes,
Rc<RefCell<T>>for one thread,Arc<Mutex<T>>for many. If no, plain references are cheaper and safer. - Am I fighting the compiler? If I’ve added three
clone()calls and aRefCell, my design is probably wrong. The borrow checker is usually right.
The borrow checker is not a gatekeeper. It’s a pair programmer who has read the Rust ownership rules a million times and will catch every violation before it becomes a production incident. Learn to read its errors — they’re the best documentation in the language.
The compiler is the curriculum
Every error in this post was produced by rustc 1.93.1 on a real program. No
embellishment, no paraphrasing. The error codes, the help text, the suggested fixes —
all verbatim.
That’s the secret: you don’t need a book to learn Rust ownership. You need a
compiler and the patience to read what it’s telling you. The errors are the lessons.
The fixes are the understanding. And the next time you see error[E0382], you’ll know
exactly what the compiler is trying to teach you.