String moves it while assigning an i32 copies it, and what the Copy trait has to do with it;&T) and mutable (&mut T) references;Learn Rust Series):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.
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.
Here are the three ownership rules from episode 1, and this time we are going to actually understand them:
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.
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.
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.
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.
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.
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.
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:
&T), or&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.
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.
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. ;-)
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.
Three exercises, gentle to chewier. Full solutions at the top of the next episode, as always.
Write a function first_word(s: &String) -> usize that returns the byte index where the first space appears (or the string's length if there is no space). Iterate with s.chars() or work with s.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 in main after the call.
Write a function append_line(log: &mut String, line: &str) that pushes line onto log followed by a newline (push_str is your friend, and "\n" is a newline). In main, create a let mut log = String::new();, call append_line two 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.
free().String is a stack struct pointing at a heap buffer, which is why assigning it moves rather than copies -- two owners would mean a double-free.Copy and duplicate on assignment; heap-owning types move. The compiler tells you which is which.& lets a function use a value without taking it.&mut borrows for mutation, and the shared-xor-mutable rule (many readers XOR one writer) makes data races impossible at compile time.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.