Rust: Ownership & Borrowing

Keywords

rust, ownership, borrow checker, borrowing, lifetimes, move semantics, references, interior mutability, memory safety, aliasing

Introduction

Here is a function any C++ programmer has written, in one form or another, and a fraction of them have shipped as a production incident. It builds a string on the stack and hands back a pointer into it:

const std::string& greeting() {
    std::string s = "hello";
    return s;                 // returns a reference to a local
}                             // s is destroyed here; the reference now dangles

The program builds and runs, and on a good day the freed memory still holds the right bytes, so it works — in development, in the demo, through the first week in production. Then the allocator reuses that slot, the bytes change underneath the live reference, and the caller reads garbage or segfaults at three in the morning. The same shape recurs everywhere: a pointer into a vector held across a push_back that reallocates; a delete on one thread while another still holds the pointer; two threads writing the same field with no lock. C++ forbids none of it. It trusts you not to do it — and “don’t do it” is a policy enforced by code review and discipline, which is to say, not enforced.

Now the same function in Rust:

fn greeting() -> &str {
    let s = String::from("hello");
    &s                        // error[E0515]: cannot return reference to local `s`
}

This does not compile. Not a warning — a hard error, every time, on every machine, before a single instruction runs. The Rust compiler has proven that the reference would outlive the data it points to, and it refuses to build a program containing a dangling pointer. There is no garbage collector doing this at runtime and no cost paid when the program runs; the check happens once, at compile time, and then it’s gone. The borrow checker is a static proof that the program is memory-safe, and that proof is the idea the entire language is built around.

The Core Insight

Memory safety without a garbage collector sounds like it should require a trick. It doesn’t; it requires three rules the compiler enforces about who is allowed to touch what, and when.

  1. Every value has exactly one owner. When the owner goes out of scope, the value is dropped — its destructor runs, its memory is freed — automatically and exactly once. This is RAII, the C++ pattern, except the compiler enforces it rather than trusting you to wire it up. One owner means there is never a second owner to free the same value (no double-free) and never zero owners that forget to free it (no leak by construction).
  2. You move ownership, or you borrow it — and the rules differ. Moving hands the single deed to a new owner and invalidates the old binding; the moved-from variable is dead and the compiler knows it. Borrowing lends temporary access without giving up ownership.
  3. Borrows obey shared XOR mutable. At any moment a value may have any number of shared & references (read-only) or exactly one &mut reference (read-write), but never both at once. Aliasing or mutation alone is safe; aliasing plus mutation is the root of nearly every memory bug, and Rust forbids the combination.

That third rule is the keystone. A data race is two threads accessing the same memory with at least one writing and no synchronization — aliasing plus mutation. Iterator invalidation is a reference aliasing a collection while the collection mutates and reallocates underneath it — aliasing plus mutation. By making shared-and-mutable a type error, Rust eliminates both by construction, not by catching them in testing. The supporting cast is lifetimes: the compiler tracks, for every reference, the region of code over which it stays valid, and rejects any reference that could outlive its data. That’s what killed greeting() above.

A mental model

Ownership is a single deed to a house. You can be only one person who holds it; you can give it away, after which it isn’t yours anymore and you can’t act as though it is; and the moment no one holds the deed, the house is demolished. There is no photocopying the deed — that would create two owners, and the whole system depends on there being one.

Borrowing is lending the house, not transferring it. You can let any number of people in to look — many readers wandering the rooms at once is fine, because none of them changes anything. Or you can give one person the keys to renovate — but then no one else may be inside, not even to look, because the floor they’re standing on might be torn up while they read. Many readers, or one writer, never both. That is shared-XOR-mutable in one sentence.

The borrow checker is not a linter nagging about style; it is a proof engine. A linter flags patterns that are often wrong; the borrow checker proves a class of bugs absent. When it rejects your code it is not guessing — it has found a way the program could violate a rule, and it won’t let that program exist. Lifetimes are simply the engine’s bookkeeping: a label on each reference saying this may not outlive what it points to, propagated through every call until it checks out or contradicts.

When the borrow checker “fights” you (and what it’s telling you)

Every Rust programmer goes through a phase of treating the borrow checker as an adversary. The reframe that ends that phase: when it rejects your code, it has almost always found a real bug. The code that fights it usually aliases and mutates, holds a reference too long, or shares across threads without synchronization — exactly the things that are undefined behavior in C++ but compile silently there. The fight is the compiler showing you a defect you’d otherwise have shipped, and the fix is rarely to fight back; it’s to restructure so the ownership is honest, which usually yields the better design.

That said, some legitimate designs genuinely need shared mutation — a cache updated through a shared handle, a graph with cycles, an observer that mutates its subject. Rust doesn’t ban these; it provides escape valves with their own rules. Interior mutability (Cell, RefCell) lets you mutate through a shared reference by moving the borrow check from compile time to run time. Reference counting (Rc, Arc) lets a value have several owners when one won’t model the problem. These aren’t loopholes — each trades a guarantee you got free at compile time for a cost or risk you now manage explicitly, and knowing which guarantee you traded is the whole skill. Figure 26.1 lays out the rules and the contrast they buy.

What you’ll learn

  • Why every value has exactly one owner, how a move transfers that ownership, and how RAII-by-the-compiler makes double-free and leaks impossible by construction
  • The shared-XOR-mutable rule — what & and &mut mean, and why simultaneous aliasing and mutation is the single root cause the borrow checker is built to eliminate
  • How lifetimes let the compiler prove no reference outlives its data, when elision hides them, and when you must annotate them yourself
  • How slices borrow a window into a collection without copying, and why that’s safe
  • When and why to reach for interior mutability (Cell/RefCell), and the runtime cost — including the panic — you accept in exchange
  • When a single owner can’t model your problem, and how Rc/Arc give shared ownership, with Arc<Mutex<T>> previewing how the same rules make concurrency safe

Prerequisites

  • Rust: Fundamentals — variables, functions, structs, enums, match, and the basic distinction between stack and heap values (the Rust: Fundamentals material)
  • A working mental model of stack vs. heap, pointers, and what “free this memory” means — the systems-programming concepts ownership is built to automate
  • Comfort reading compiler errors; Rust’s are unusually good, and learning to read them rather than swat them is most of becoming productive

Ownership and move semantics

Start with the rule everything else rests on: every value has exactly one owner, and when that owner goes out of scope the value is dropped. For a heap-allocated type like String, “dropped” means its buffer is freed. You never call free; the compiler inserts the drop at the closing brace of the owner’s scope, and because there is only ever one owner, it inserts it exactly once. This is RAII — resource acquisition is initialization — but where C++ relies on you to write the destructor and not double-free it, Rust’s compiler keeps the books.

The subtle part is assignment. When you assign one binding to another, a non-Copy value is moved, not copied: ownership transfers to the new binding and the old one becomes invalid. This isn’t a runtime operation that duplicates the heap buffer; it’s a compile-time bookkeeping change about who the owner is. The cost is zero, and the old binding is statically dead — touch it and the program won’t build.

let s1 = String::from("hello");
let s2 = s1;              // ownership moves from s1 to s2
// println!("{s1}");     // error[E0382]: borrow of moved value `s1`
println!("{s2}");        // fine — s2 is the owner now

This is the same let s2 = s1; a Python or Java programmer reads as “two names for one object” and a C++ programmer reads as “a copy.” In Rust it’s neither: it’s a transfer, and s1 is gone afterward. Passing a value to a function is the same event — the argument is moved in, so after consume(s2) the binding s2 is dead too. If you need an independent duplicate you ask for one explicitly with .clone(), which deep-copies and leaves both bindings valid; the explicitness is the point, because a deep copy of a large structure is a cost you should see in the source, not have happen invisibly. Small Copy types — integers, bool, char, Copy structs — are the exception: they’re duplicated bitwise because copying them is trivial and they own no heap resource, so let y = x; leaves both valid.

The deeper payoff is that move semantics make resource handling correct by default. A file handle, a lock guard, a database connection — wrap it in a type whose Drop closes it, and ownership guarantees the close happens exactly once, at the exact scope exit, even on an early return or a panic unwinding the stack. There is no finally, no manually paired open/close to get wrong, no “did every path release this?” The single owner is the answer.

Borrowing and the shared-XOR-mutable rule

A language where the only way to use a value is to give it away would be miserable — every function that just wants to read a string would consume it. Borrowing is the fix: a reference lends access without taking ownership. A shared reference &T lets you read; a mutable reference &mut T lets you read and write; and in both cases the owner keeps the value, getting it back when the borrow ends. This is the everyday way you pass data to functions, and it’s free — a reference is just a pointer, and no ownership changes hands.

The constraint that makes borrowing safe is the centerpiece of the entire language: shared XOR mutable. At any given point, a value may have any number of shared & references, or exactly one &mut reference, but never both simultaneously. You can have a dozen readers; you can have one writer; you cannot have a writer while anyone is reading. Figure 26.1 shows this fork — many & on one side, a single &mut on the other, and “never both at once” where they meet.

Why this exact rule? Because aliasing plus mutation is the common root of an entire family of bugs, and forbidding the combination kills the whole family at once. If two references can see the same data and at least one can change it, a write through one can pull the rug out from under a read through the other. That’s a data race when the two are on different threads, iterator invalidation when one walks a collection while another reallocates it, a dangling pointer when one frees what the other still points at. Allow aliasing or mutation alone and all of these are impossible; allow both at once and all become possible. Rust draws the line precisely there. The mundane-looking restriction “you can’t take a &mut while a & is live” is, underneath, a guarantee that no read will ever observe a value being torn up by a concurrent write.

In practice the borrow checker is smarter than “borrowed somewhere in this scope.” Modern Rust uses non-lexical lifetimes: a borrow lasts only until its last actual use, not the end of the block. So a few shared reads followed by a mutation is fine — the reads are over before the write begins, and the checker sees the borrows don’t overlap in time even though they share a scope. The rule is about simultaneous access, and the compiler tracks “simultaneous” precisely.

Lifetimes

Borrows are safe only if every reference is guaranteed to point at data that’s still alive. The compiler proves this by tracking lifetimes — for each reference, the span of code over which it remains valid — and rejecting any program where a reference could outlive its referent. That’s the proof that killed the dangling greeting() in the introduction: the returned &str would outlive the local String it borrowed.

Most of the time you never write a lifetime, because the compiler infers them through lifetime elision — a small set of rules covering the common cases. A function taking one reference and returning one is assumed to return a borrow of that input; a method taking &self is assumed to return a borrow tied to self. These defaults are right so often that lifetimes feel invisible. You annotate only when the relationship is genuinely ambiguous — the classic case being a function taking two references and returning one, where the compiler can’t guess which input the output borrows from. Consider a longest that returns whichever of two strings is longer:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

The 'a creates nothing at runtime; it’s a constraint you’re stating: the returned reference lives as long as the shorter of the two inputs’ lifetimes, so the caller can’t hold the result past the point where either input is dropped. Without the annotation the compiler errors (E0106, “missing lifetime specifier”), because elision can’t decide whether the output borrows x or y — and getting that wrong is exactly the dangling-reference bug. The annotation is you supplying the one fact the compiler can’t infer, after which it proves the rest. Structs that hold references need the same treatment: struct Excerpt<'a> { text: &'a str } declares that an Excerpt may not outlive the string slice it borrows, enforced at every use site.

Slices

A slice is a borrowed window into a contiguous sequence — a &str into a String, a &[T] into a Vec<T> or array — represented as a pointer plus a length, owning nothing. Slices are where borrowing earns its keep ergonomically: a function taking &str can be called with a string literal, a whole String, or a sub-range of either, all without copying, because each is just a pointer and a length into memory someone else owns.

fn first_word(s: &str) -> &str {
    match s.find(' ') {
        Some(i) => &s[..i],   // borrow the bytes before the first space
        None => s,            // no space — the whole thing is one word
    }
}

What’s quietly important is that the returned slice is still a borrow of the input — elision ties its lifetime to s — so the borrow checker keeps watching. If the caller mutates or drops the original String while still holding the returned word, that’s a & and a &mut (or a move) overlapping, and the program won’t compile. The slice looks like a cheap value you can pass around freely, but the lifetime system never stops tracking what it points into. That’s the difference between a Rust slice and a C++ string_view, which carries the same pointer-plus-length but offers no protection against the underlying string being freed out from under it.

Interior mutability

The shared-XOR-mutable rule occasionally rejects a design that’s actually correct — most often when you want to mutate something through a shared reference. A method that takes &self but needs to update an internal counter or cache can’t take a &mut to that field, because &self is shared. The escape valve is interior mutability: types that let you mutate through a & by moving the borrow check from compile time to runtime. Cell<T> does this for Copy values by swapping the whole value in and out; RefCell<T> does it for anything, handing out runtime-checked borrows via borrow() and borrow_mut().

use std::cell::RefCell;

struct Logger { entries: RefCell<Vec<String>> }

impl Logger {
    fn record(&self, msg: &str) {                 // note: &self, not &mut self
        self.entries.borrow_mut().push(msg.into()); // mutate through a shared ref
    }
}

The trade is explicit and it has teeth. RefCell still enforces shared-XOR-mutable — it has to, or it would reintroduce the bugs the rule prevents — but it enforces it by counting borrows at runtime and panicking on a violation, rather than refusing to compile. Call borrow_mut() while another borrow is live and the program crashes with already borrowed: BorrowMutError. You haven’t escaped the rule; you’ve moved its enforcement from a compile error you fix once to a runtime panic that can reach production. That’s the cost of the flexibility, and it’s why interior mutability is a deliberate choice for the cases that need it, not a default reach.

War story: the same write that segfaults in C++, and panics in Rust

A team porting a single-threaded graph algorithm from C++ kept a node’s neighbor list and a “currently visiting” cursor in the same structure, with a traversal that, on certain inputs, mutated the list while still iterating it — the classic iterator-invalidation bug. In C++ it had shipped: the std::vector reallocated mid-loop, the iterator dangled into freed memory, and on most inputs the bytes survived long enough that the loop finished with the right answer. It segfaulted maybe one run in ten thousand, in production, and had eluded diagnosis for months. The Rust port modeled the shared cursor with RefCell to get past the borrow checker — and on the first adversarial input it panicked immediately and deterministically with already borrowed: BorrowMutError, pointing at the exact line holding an immutable borrow open across a borrow_mut(). Same bug. The difference: C++ turned it into undefined behavior that corrupted memory silently and intermittently, while Rust — even with the check pushed to runtime by RefCell — turned it into a loud, reproducible, single-line crash. Had the team used a plain &mut, the compiler would have caught it and the program would never have built. Pushing the check to runtime is sometimes necessary, but every step you move it later is a step toward the C++ failure mode — keep the check as early as the design allows.

Rc and Arc: shared ownership

Sometimes a single owner genuinely doesn’t model the problem. A graph node may be reachable from several parents; a configuration may need to outlive any one task reading it; a value’s lifetime may depend on runtime conditions the compiler can’t pin to one scope. For these, reference counting provides shared ownership: Rc<T> (single-threaded) and Arc<T> (atomic, thread-safe) wrap a value in a heap allocation with a count of how many owners exist. Cloning an Rc or Arc doesn’t copy the value — it bumps the count and hands back another handle to the same data. When the last handle drops and the count hits zero, the value is dropped. The single-owner rule isn’t broken; it’s generalized to “the group of owners collectively owns the value, freed when the group empties.”

Arc is the bridge to concurrency. By itself it gives shared read access across threads — every thread holds a handle to the same immutable data. To share mutable state you compose it with a lock: Arc<Mutex<T>>. The Arc provides the shared ownership so every thread can reach the value; the Mutex provides shared-XOR-mutable at runtime — exactly one thread holds the lock and may write at a time, everyone else waits. It’s the same rule as &mut, enforced dynamically by a lock instead of statically by the borrow checker, and it’s the foundation of Rust’s “fearless concurrency”: the type system won’t let you share a value across threads unless it’s wrapped in something that upholds the rule, so the data race you’d write in another language simply doesn’t typecheck here.

Build it → Ownership and borrowing under real load. The Rust services in Project 03: High-Performance Cache lean on Arc<Mutex<T>> and interior mutability to share state across worker threads safely; Project 06: Async Runtime shows lifetimes and ownership governing task and waker handoff in an executor; and Project 11: Distributed KV (Raft) uses shared ownership and locks to coordinate replicated state across nodes.


Practical exercise

Difficulty: Level I · Level II · Level III

  1. Level I — Read the rejections. Take a small program with five borrow-checker errors: a use-after-move, a returned reference to a local, a &mut taken while a & is live, a value mutated while a slice borrows it, and a vector pushed to while an element reference is held. Fix each so the program compiles, and for each fix write one sentence naming the real bug the rule prevented (“this would have been a use-after-free because…”). The goal is to stop seeing the errors as obstacles and start reading them as bug reports.
  2. Level II — Annotate a lifetime, and justify it. Write a function that returns a reference borrowed from one of two reference parameters (the longer of two slices, or a lookup that returns a borrow of whichever argument matched). Observe that it won’t compile under elision, add the lifetime annotation that makes it build, and explain in a short paragraph why elision couldn’t infer it — what ambiguity the 'a resolves — and what the annotation promises the caller about how long the result stays valid.
  3. Level III — Design for shared mutation, and price the trade. Design a data structure that genuinely needs shared mutability — a graph with shared nodes, an observer that mutates its subjects, or a memoizing cache reached through shared handles. Decide between Rc<RefCell<T>> (single-threaded) and Arc<Mutex<T>> (multi-threaded), justifying the choice from the access pattern. Then write the paragraph that matters most: which compile-time guarantee did you trade away, and what runtime cost or failure mode did you accept in return — a RefCell borrow panic, a Mutex’s contention and deadlock risk — and how you’d defend that trade in review.

Summary

Rust delivers memory safety without a garbage collector by making it a property the compiler proves rather than one the programmer promises. Every value has exactly one owner and is dropped, exactly once, when that owner leaves scope — RAII enforced by the compiler, which eliminates double-free and leaks. Ownership moves rather than copies, invalidating the old binding so use-after-move is a compile error. Borrowing lends access under the one rule everything hinges on — shared XOR mutable: many readers or one writer, never both — and because simultaneous aliasing and mutation is the root of data races and use-after-free, forbidding the combination kills that whole family of bugs by construction. Lifetimes prove no reference outlives its data; slices are borrows the lifetime system never stops tracking. When a design truly needs shared mutation, interior mutability (Cell/RefCell) and shared ownership (Rc/Arc, and Arc<Mutex<T>> across threads) are the escape valves — each trading a compile-time guarantee for an explicit runtime cost you must understand and defend.

Key takeaways

  • One owner, dropped on scope exit, enforced by the compiler — that single rule makes double-free and leaks impossible without any runtime machinery.
  • A move transfers the deed and kills the old binding; copies are explicit (.clone()) so their cost is visible in the source, and only trivial Copy types duplicate freely.
  • Shared XOR mutable is the keystone: aliasing-plus-mutation is the root of data races and use-after-free, and forbidding the combination eliminates both by construction.
  • Lifetimes prove no reference outlives its data; you only annotate when elision can’t infer the relationship, and the annotation supplies the one fact the compiler lacks.
  • When the borrow checker fights you it has usually found a real bug; the escape valves (RefCell, Rc/Arc, Mutex) move the check to runtime, and every step later is a step back toward the failure mode Rust exists to prevent.

Connections to other chapters

  • Rust: Fundamentals (prerequisite): the syntax, types, and stack-vs-heap model this chapter assumes. Ownership is the concept that turns those building blocks into a memory-safe systems language, so this is where Fundamentals pays off.
  • Memory and Resource Management (sibling): C++ has the same ownership conceptsunique_ptr is a move-only single owner, shared_ptr is reference counting, RAII ties cleanup to scope — but enforces none of them; a raw pointer or stray copy slips straight past. Rust takes the identical smart-pointer model and makes the compiler enforce it, turning C++’s use-after-free and double-free from runtime hazards into compile errors. That chapter compares how all six languages reclaim resources; this chapter carries the Rust-specific borrow-checker depth, and reading the two side by side is the clearest way to see what “enforced” buys you.
  • Concurrency and Parallelism Models (extension): shared-XOR-mutable plus the Send/Sync marker traits is the entire basis of “fearless concurrency.” The same rule that stops a data-race on one thread, lifted to the type level with Arc<Mutex<T>>, stops it across threads — and the compiler refuses to share anything that doesn’t uphold the rule. That chapter sets Rust’s approach beside how the other languages model concurrency.
  • Python / Java / Go (contrast): these reach memory safety the other way, with a tracing garbage collector that finds unreachable objects at runtime. The contrast is static ownership (zero runtime cost, a learning curve, no GC pauses) versus runtime tracing (no ownership to reason about, at the price of a collector and its pauses) — two different answers to the same safety problem, each with a cost the other avoids.

Further reading

Essential

  • The Rust Programming Language (Klabnik & Nichols), the ownership chapters — “Understanding Ownership” and “Validating References with Lifetimes.” The canonical, example-first introduction to everything in this chapter.
  • Programming Rust (Blandy, Orendorff & Tindall) — the ownership, references, and lifetimes chapters go deeper on the why, with the systems-programmer’s framing.

Deep dives

  • RustBelt: Securing the Foundations of the Rust Programming Language (Jung et al., POPL 2018) — the formal proof that Rust’s type system, including its unsafe escape hatches used correctly, actually guarantees the safety it claims.
  • Stacked Borrows (Jung et al., POPL 2020) — an operational model of Rust’s aliasing discipline that pins down precisely what shared-XOR-mutable means at the level of memory operations, and what unsafe code must respect.

Historical context

  • Cyclone: A Safe Dialect of C (Jim et al., 2002) and the region-based and linear/affine type-systems lineage behind it — the research on regions and ownership types that Rust’s lifetimes and move semantics descend from, showing the idea predates the language by decades.