← Rust: Zero to Depth
Full-stack DevRust: Zero to Depth

Ownership and Borrowing: The Three Rules That Are the Language

Part 2 of the Rust: Zero to Depth series: move semantics and why assignment isn't a copy, the readers-XOR-writer rule, non-lexical lifetimes, what the 2024 edition and Polonius change, and the patterns that end the fight with the borrow checker.

RustOwnershipBorrowingMemory SafetyBorrow Checker

Part 1 ended with a crime scene: let t = s; followed by an error — borrow of moved value. Every other language you’ve used would have run that code. C++ would have silently copied a pointer and planted a double-free. Python would have aliased it and let you find out in production. Rust stopped you at compile time and wrote you a paragraph about why. This part is about that paragraph — the three rules behind it, which are not a feature of Rust so much as the whole point of it.

Learn these properly and something surprising happens: lifetimes (Part 3), smart pointers (Part 8), and fearless concurrency (Part 9) stop being separate topics and become the same idea wearing different clothes.

Rule one: every value has exactly one owner

A String is three things on the stack — a pointer, a length, a capacity — plus the actual bytes on the heap. When you write let t = s;, Rust does the cheap thing (copies the three stack words) and then does the thing no other mainstream language does: it invalidates s. Ownership moved. There is still exactly one pointer that will ever free those bytes, so cleanup stays deterministic and there is precisely one Drop in the future, not zero and not two.

This is the inversion to internalize: in Rust, assignment is a move by default, not a copy. The exceptions:

  • Copy types — integers, floats, booleans, chars, and tuples/arrays of them. Small enough that copying is the cheap correct behavior, so let t = s; just works and both stay valid.
  • .clone() — when you genuinely want two independent Strings, you say so out loud and pay for the heap copy explicitly. The cost is visible in the source, which is the entire philosophy: expensive things should look expensive.
  • Borrowing — the common case, and rule two’s job.

And the mirror image of ownership: when the owner goes out of scope, the value is dropped — deterministically, at a point the compiler knows. This is RAII, and it’s how Rust gets garbage collection’s safety with C’s predictability: no GC thread, no pauses, no free() to forget, and files/sockets/locks clean themselves up the same way.

Rule two: borrow freely — many readers XOR one writer

If every use required a move, you’d pass ownership into a function to read a string and never get it back. References (&T to read, &mut T to write) let you lend a value without giving it away — and the lending law is the most important sentence in this series:

At any moment, you may have any number of shared references or exactly one mutable reference — never both.

Sit with why this one rule buys two different prizes. In single-threaded code, it kills iterator invalidation and use-after-free: you can’t mutate the vector while a reference into it exists, because the compiler watches the overlap. In multi-threaded code (Part 9), the same rule is what makes data races a compile error — a data race is exactly “two threads, one writes, no synchronization,” which is precisely the overlap the rule forbids. The borrow checker doesn’t know what a thread is. It doesn’t need to.

Function signatures are where this becomes a design tool rather than a restriction. fn render(name: &str) borrows — the caller keeps their string. fn consume(name: String) takes ownership — the caller gives it up, visibly, at the call site. You read an API’s contract straight off its types, and the compiler enforces both halves of the deal.

Rule three: borrows end at their last use (NLL)

The borrow checker used to be dumber than it is. Before non-lexical lifetimes (2018), a borrow lived to the end of its block, and perfectly sensible code was rejected. Today the analysis is usage-based: a borrow lives from creation to its last use, so this compiles:

let mut s = String::from("hi");
let r = &s;          // shared borrow born
println!("{r}");     // ...and dead here (last use)
let m = &mut s;      // writer is fine — no overlap
m.push('!');

The direction of travel keeps improving. The 2024 edition removed a whole class of confusing errors around impl Trait return types (they now capture input lifetimes by default, with a + use<...> syntax for saying exactly what escapes — Part 7 has the details). And Polonius, the next-generation borrow checker, is on the horizon — a more precise analysis that accepts patterns today’s checker still falsely rejects. Neither changes the rules; both shrink the set of times the computer says no to a program a human correctly knows is fine.

How to stop fighting the borrow checker

The fight from Part 1 is a phase, and these four patterns are how you graduate from it:

  1. Borrow early, clone late. Default to &T in parameters. Clone only when ownership truly must exist twice — during learning, .clone() is a perfectly acceptable pressure valve, and removing training-wheel clones later is a lovely afternoon of refactoring.
  2. Take &str, not &String. The idiomatic read-only string parameter is &str — it accepts String, &String, string literals, and slices alike. Same family: &[T] over &Vec<T>, &Path over &PathBuf.
  3. Own at the boundaries, borrow inside. A struct owns its fields; functions borrow them. Ownership sits at the edges of your system — the database row, the config, the connection — and flows inward by reference.
  4. Let the compiler finish its sentence. Rust’s errors name the moved value, the conflicting borrows, and usually the fix. The day you start reading them all the way through is the day the fight ends — they are tutoring, not accusing (Part 1’s promise, kept).

Practice, then Part 3

  1. Write three functions: one that takes String, one that takes &String, one that takes &str. Call each twice with the same variable and observe which calls still compile. Then delete the &String version forever — it’s a museum piece.
  2. Trigger the readers-writer error on purpose: hold let r = &v[0]; and try v.push(2);. Read the full error. Then fix it by ending r’s use before the push — feel NLL allow the moment r dies.
  3. Find a place you cloned something “to make it compile” (or write one now), and remove the clone by switching a parameter to a reference. Notice the compiler teaching you which ownership was unnecessary.

Part 3 takes on the question this part carefully avoided: what happens when a reference must outlive the function that created it — lifetimes, what the annotations actually mean (spoiler: they describe, they don’t command), and why 'static is almost never what you think.

guest@swangnice:~$