Learn Rust Series (#3) - Ownership & Borrowing
Learn Rust Series (#3) - Ownership & Borrowing

What will I learn
- You will learn the three ownership rules in depth, and what a value actually looks like on the stack and the heap;
- why assigning a
Stringmoves it while assigning ani32copies it, and what theCopytrait has to do with it; - how borrowing lends access to a value without transferring ownership, with both shared (
&T) and mutable (&mut T) references; - the shared-xor-mutable rule, and how it makes data races impossible before your program even runs;
- why the borrow checker sometimes rejects code that looks perfectly fine, and the small refactors that make it happy again.
Requirements
- A working Rust toolchain (via rustup) from episode 1;
- A terminal and an editor (VS Code with rust-analyzer is a fine choice);
- Episodes 1 and 2 read, so cargo, the scalar types, and the move-borrow-clone triangle are already familiar.
Difficulty
- Beginner
Curriculum (of the Learn Rust Series):
- Learn Rust Series (#1) - Introduction to Rust
- Learn Rust Series (#2) - Variables, Types, Functions
- Learn Rust Series (#3) - Ownership & Borrowing (this post)
Learn Rust Series (#3) - Ownership & Borrowing
Welcome back. In episode 1 I promised you that Rust makes whole categories of bugs impossible at compile time, and I waved a hand at the mechanism: ownership and borrowing. In episode 2 we wrote real code -- variables, types, functions -- and touched the move-borrow-clone triangle in passing. Today we stop waving hands. This is the episode where the borrow checker does its real work up close, and where the thing that makes Rust Rust finally clicks into place.
I will be honest with you: this is the chapter people struggle with. Not because it is complicated -- the rules fit on a napkin -- but because it asks you to think about something most languages hide from you completely: who is responsible for this piece of memory, and when does it get cleaned up. Once that becomes second nature, the rest of Rust feels almost easy. So we are going to go slow and build the model properly.
Nota bene: if you come from Python, Go, or JavaScript, you have never had to think about this, because a garbage collector thought about it for you. That is exactly why it feels strange at first. Keep those languages in mind as we go -- the contrast is where the insight lives.
Solutions to Episode 2's exercises
As always, let us clear the exercises from last time before we start, because they lead nicely into today.
Exercise 1 asked for a c_to_f function converting Celsius to Fahrenheit, being careful about float literals:
fn c_to_f(celsius: f64) -> f64 {
celsius * 9.0 / 5.0 + 32.0
}
fn main() {
println!("{}", c_to_f(0.0)); // 32
println!("{}", c_to_f(100.0)); // 212
println!("{}", c_to_f(37.0)); // 98.6
}
The trap was mixing types. If you had written celsius * 9 / 5 with integer literals, the compiler would have complained, because celsius is an f64 and 9 is an integer -- and Rust never mixes numeric types silently, as we saw last episode. Writing 9.0 and 5.0 keeps everything in f64 land, and the whole expression is the return value because there is no semicolon on that line.
Exercise 2 was the shadowing one: start from let input = "42"; and rebind it to a number:
fn main() {
let input = "42"; // input is a &str
let input: i32 = input.parse().unwrap(); // input is now an i32
println!("{}", input * 2); // 84
}
The same name went from text to integer through shadowing -- a brand new binding of a new type, not mutation. The .parse() call needs to know what to parse into, which is why we annotate let input: i32. The .unwrap() says "I am sure this succeeds, and if it does not, crash". We will replace that reckless .unwrap() with proper error handling in a later episode, but for now it keeps the example short.
Exercise 3 asked for min_max returning both extremes as a tuple:
fn min_max(a: i32, b: i32, c: i32) -> (i32, i32) {
let smallest = a.min(b).min(c);
let largest = a.max(b).max(c);
(smallest, largest)
}
fn main() {
let (lo, hi) = min_max(7, 2, 5);
println!("min = {lo}, max = {hi}"); // min = 2, max = 7
}
Returning a tuple and destructuring it on the other side (let (lo, hi) = ...) is the pattern I told you to sit with, because you will use it constantly. Right -- on to ownership.
The three rules, again, but slower
Here are the three ownership rules from episode 1, and this time we are going to actually understand them:
- Every value has an owner -- a single variable responsible for it.
- There can be only one owner at a time.
- When the owner goes out of scope, the value is dropped and its memory is freed.
Rule 3 is the quiet engine of the whole thing. Watch it happen:
fn main() {
let s = String::from("outer");
{
let inner = String::from("temporary");
println!("{inner}");
} // inner goes out of scope here -- its heap memory is freed right now
println!("{s}");
} // s goes out of scope here -- freed
When inner reaches the closing brace of its block, Rust automatically frees the heap memory it owned. Not "eventually, when a garbage collector wakes up". Not "when you remember to call free()". Exactly there, deterministically, at the closing brace. This is called RAII if you come from C++, and Rust builds its entire safety story on it. No free() to forget, no GC pause to schedule -- the scope is the lifetime.
Stack, heap, and why some values move
To understand why moving exists, you need a quick picture of where values live. There are two regions of memory a program uses. The stack is fast, ordered, and holds values whose size is known at compile time -- an i32, a bool, a char, a fixed array. The heap is for data whose size can grow or is not known until runtime -- the classic example being a String, which can hold anything from one character to a novel.
A String is actually two pieces. On the stack sits a little bookkeeping struct: a pointer to the character data, a length, and a capacity. The actual text lives on the heap, where the pointer points. When you write let s2 = s1, Rust copies that little stack struct -- pointer, length, capacity -- but it does not copy the heap data. And now you have a problem: two stack structs pointing at the same heap buffer.
If Rust allowed that, then at the end of scope both s1 and s2 would try to free that one buffer. That is a double-free, one of the classic memory bugs from episode 1. Rust's solution is the move: after let s2 = s1, it declares s1 invalid, so only s2 owns the buffer, and only s2 frees it.
fn main() {
let s1 = String::from("hello");
let s2 = s1; // the String is MOVED: s1 is now invalid
println!("{s2}"); // fine
// println!("{s1}"); // would NOT compile: s1 was moved out of
}
Uncomment that last line and the compiler stops you cold, exactly as it did in episode 1. This is not Rust being difficult. It is Rust refusing to let two owners fight over one allocation.
Copy types: the cheap ones just duplicate
So why did let y = x work perfectly fine for an i32 in episode 2, with both x and y still usable afterward? Because small, fixed-size values that live entirely on the stack implement a special marker called the Copy trait. When a type is Copy, assignment duplicates the bytes instead of moving, because copying a handful of stack bytes is trivially cheap and there is no heap buffer to fight over.
fn main() {
let x = 5;
let y = x; // i32 is Copy: x is duplicated, both stay valid
println!("{x} and {y}"); // 5 and 5
}
The Copy types are the simple ones: all the integer and float types, bool, char, and tuples made entirely of Copy types. Anything that owns a heap allocation -- String, Vec, and most of the interesting types -- is deliberately not Copy, because copying them silently would be expensive and surprising. The rule of thumb: cheap stack-only values copy, anything owning heap memory moves. You rarely have to memorise the list, because the compiler tells you the moment you get it wrong.
Ownership and functions
Passing a value to a function follows the exact same rules as assigning it to a variable. Pass a String and you move it in; the function now owns it, and it is dropped when the function ends:
fn consume(s: String) {
println!("consumed: {s}");
} // s dropped here -- its heap memory is freed
fn main() {
let text = String::from("hello");
consume(text);
// println!("{text}"); // would NOT compile: text was moved into consume
}
Pass a Copy value like an i32 and it is copied in, so the original stays usable. This is consistent and mechanical, which is the point -- once you know the move rule, functions hold no surprises.
Of course, moving a value into every function you call would be maddening. You would spend your life handing values back out again just to keep using them. In the bad old days before we knew better, you might do exactly that -- return the value so the caller gets ownership back:
fn shout(s: String) -> String {
let loud = s.to_uppercase();
loud // hand ownership back to the caller
}
fn main() {
let word = String::from("hello");
let word = shout(word); // move in, move out, rebind
println!("{word}"); // HELLO
}
That works, but it is clunky. Having said that, there is a much better tool for "let me use your value without taking it from you", and it is the other half of today's episode.
Borrowing: access without ownership
A reference lets a function use a value without owning it. You create one with &, and the jargon for using a reference is borrowing -- you borrow the value, look at it, and give it back untouched. Nothing is moved and nothing is freed when the reference ends.
fn length(s: &String) -> usize {
s.len() // we only read through the reference; we never own s
}
fn main() {
let s = String::from("borrow me");
let n = length(&s); // lend s to the function
println!("{s} is {n} bytes"); // s is still ours -- never moved
}
The &s creates a shared reference to s. The length function receives &String, reads what it needs, and when it returns, the borrow simply ends -- because the function never owned s, there is nothing to free. On the last line s is still perfectly valid. Compare this to the shout example above: no move-in-move-out dance, no rebinding. Borrowing is almost always what you want.
If you come from Python or Go, &s looks like taking the address of something, and under the hood that is roughly what happens -- a reference is a pointer. The difference is that Rust tracks how long that pointer is allowed to live, and that tracking is what makes references safe rather than the loaded footgun they are in C.
Mutable references: borrowing to change
A plain &T reference is read-only -- you can look but not touch. When you need to modify the borrowed value, you take a mutable reference with &mut:
fn push_excited(s: &mut String) {
s.push_str("!!!");
}
fn main() {
let mut msg = String::from("wow");
push_excited(&mut msg);
println!("{msg}"); // wow!!!
}
Three things had to line up here. The variable is declared let mut msg, because you cannot mutably borrow something that is not itself mutable. You pass &mut msg, explicitly, so the mutation is visible at the call site -- you can see that msg might change just by reading main. And the function signature says &mut String, advertising in its contract that it intends to modify what you lend it. In Python a function can mutate a list you pass it with zero warning at the call site; in Rust the &mut is right there in your face. That visibility is not bureaucracy, it is documentation the compiler enforces.
The one rule that matters: shared xor mutable
Now we reach the heart of the borrow checker, and it is a single rule. At any given time, for any given value, you may have either:
- any number of shared references (
&T), or - exactly one mutable reference (
&mut T),
but never both at the same time. Many readers, or one writer. Never a writer while there are readers, never two writers. This is sometimes called the "shared xor mutable" rule (that is exclusive-or -- one or the other, not both).
Many readers is fine, because if nobody can write, nobody can be surprised by a change:
fn main() {
let data = String::from("hello");
let r1 = &data;
let r2 = &data;
let r3 = &data;
println!("{r1} {r2} {r3}"); // three readers, no problem at all
}
But the moment you mix a writer in with readers, the compiler refuses:
fn main() {
let mut data = String::from("hello");
let r1 = &data; // a shared (read) borrow
let r2 = &mut data; // ERROR: cannot borrow `data` as mutable while it is borrowed as immutable
println!("{r1} and {r2}");
}
Why so strict? Because this rule is exactly what makes a data race impossible. A data race needs two things happening together: at least one writer, and at least one other party touching the same data at the same time. Rust's rule forbids that combination by construction. And here is the beautiful part -- the compiler proves it for you at compile time, single-threaded or multi-threaded, so you cannot write a data race in safe Rust even if you set out to. In Go you can absolutely share a variable between goroutines and corrupt it; you find out with the race detector at runtime, if you are lucky and the race happens to trigger during testing. Rust moves that entire class of bug to compile time.
The borrow checker prevents dangling references
Here is another bug the borrow checker kills before it is born. In C you can return a pointer to a local variable, the function returns, its stack frame is destroyed, and now you are holding a pointer to garbage -- a dangling pointer. Rust simply will not let you:
fn dangle() -> &String { // ERROR: missing lifetime specifier -- returns a reference to data owned by the function
let s = String::from("gone soon");
&s
} // s is dropped here, so the reference would dangle
The function tries to return a reference to s, but s is owned by the function and gets dropped the instant the function returns. The reference would point at freed memory. The compiler catches this and refuses to build. The fix is to return the String itself -- move ownership out to the caller -- rather than a reference to something about to die. Rust's references are guaranteed to always point at valid data, for their entire life. That guarantee is worth an enormous amount when you have felt the pain of the alternative.
Why it rejects code that looks fine -- and how to fix it
Let me show you the moment every beginner hits, because it feels deeply unfair the first time. You will write something like this and the compiler will slap it down:
fn main() {
let mut data = String::from("hello");
let r1 = &data; // read borrow starts
let r2 = &mut data; // ERROR: mutable borrow while r1 is still alive
println!("{r1}, then {r2}");
}
"But I am not even using them at the same time!" you protest. And you have a point. Modern Rust agrees with you, actually -- the trick is where the read borrow ends. A borrow lasts until its last use, not until the end of the block (this is called non-lexical lifetimes, if you want the fancy name). So if you finish with r1 before you create the mutable borrow, the code compiles fine:
fn main() {
let mut data = String::from("hello");
let r1 = &data;
println!("{r1}"); // last use of r1 -- its borrow ends right here
let r2 = &mut data; // now perfectly legal: no shared borrow is alive
r2.push_str(" world");
println!("{r2}"); // hello world
}
Nothing changed except the order. In the broken version the shared borrow r1 was still alive when the mutable borrow was requested; in the working version r1 is completely finished before data is borrowed mutably. This is the single most common "the borrow checker hates me" moment, and the fix is almost always the same: stop overlapping a writer with readers. Restructure so each borrow does its job and ends before the next one that conflicts with it begins. After a few weeks this becomes automatic, and code that overlaps a &mut with a & starts looking wrong to you before you even hit compile. ;-)
A quick word on where this is heading
You may have noticed I have been quietly careful to keep references short-lived, and that the dangling example failed with something about a "lifetime specifier". That is not an accident. Rust tracks exactly how long every reference is valid, and usually it figures this out silently. Occassionally -- when references get tangled up in structs or returned from functions in non-obvious ways -- you have to help it with annotations. That is a whole topic of its own, and it earns its own episode much later in the series. For now, the mental model to carry forward is simple: a reference may never outlive the thing it points at, and the compiler enforces that with an iron grip.
The same ownership machinery also powers the collections we will meet soon (a growable Vec owns its elements, and lending out a slice of it is just borrowing), the way structs own their fields, and how errors are passed around safely. Everything downstream leans on today. So if it has not fully clicked yet, that is normal -- the exercises below are designed to make it concrete, and it settles with practice more than with reading.
Try it yourself
Three exercises, gentle to chewier. Full solutions at the top of the next episode, as always.
Write a function
first_word(s: &String) -> usizethat returns the byte index where the first space appears (or the string's length if there is no space). Iterate withs.chars()or work withs.len()-- the point is that you take a&String, never own it, and the caller can still use the string afterward. Prove that by printing the original string inmainafter the call.Write a function
append_line(log: &mut String, line: &str)that pusheslineontologfollowed by a newline (push_stris your friend, and"\n"is a newline). Inmain, create alet mut log = String::new();, callappend_linetwo or three times with different lines, then print the whole log. Notice how the one mutable borrow does its work and ends each time you call the function.Take this deliberately broken snippet, understand why it fails, then fix it by reordering (do not add
.clone()-- that is cheating your way out of learning):
fn main() {
let mut name = String::from("scipio");
let borrowed = &name;
name.push_str("_nl"); // ERROR: mutation while `borrowed` is still alive
println!("{borrowed}");
}
Exercise 3 is the important one. Sit with the compiler error, read it properly, and fix it by making the read borrow end before the mutation happens. That instinct -- "end the borrow, then mutate" -- is ownership becoming intuition.
So what did we actually cover?
- The three ownership rules, and rule 3 (drop at end of scope) is the deterministic engine that frees memory with no GC and no manual
free(). - A
Stringis a stack struct pointing at a heap buffer, which is why assigning it moves rather than copies -- two owners would mean a double-free. - Cheap stack-only types are
Copyand duplicate on assignment; heap-owning types move. The compiler tells you which is which. - Passing to a function follows the same move/copy rules; borrowing with
&lets a function use a value without taking it. &mutborrows for mutation, and the shared-xor-mutable rule (many readers XOR one writer) makes data races impossible at compile time.- The borrow checker also kills dangling references, and when it rejects code that "looks fine", the fix is almost always to stop overlapping a writer with readers -- reorder so each borrow ends before a conflicting one begins.
This was the big one. If you internalise move-versus-borrow and the shared-xor-mutable rule, you have the load-bearing beam of the entire language, and everything ahead is built on top of it. Next time we put this to work on the tools that let a program actually decide things -- branching and matching on the shape of your data -- and you will see ownership quietly riding along in every example.