Type Systems

Ownership & Borrow Checking

Memory safety proved by the compiler — no garbage collector, no free()

Ownership and borrow checking are Rust's compile-time rules that guarantee memory safety without a garbage collector: every value has exactly one owner, the value is freed when that owner goes out of scope, and references are checked so you never have a dangling pointer or a data race.

  • Runtime costZero (compile-time only)
  • Owners per valueExactly 1
  • Borrow ruleShared XOR mutable
  • Frees memoryAt end of owner's scope
  • EliminatesUse-after-free, data races

Interactive visualization

Press play, or step through manually. The visualization is yours to drive — try it before reading on.

Open visualization fullscreen ↗

Watch the 60-second explainer

A condensed visual walkthrough — narrated, captioned, under a minute.

How ownership keeps memory safe

Every other systems language makes you choose between two bad options. Either you call malloc/free (or new/delete) by hand and accept that one missed free leaks and one extra free corrupts the heap — or you bolt on a garbage collector that scans the heap at runtime and occasionally freezes your program for milliseconds. Rust took a third path: it makes the compiler prove, before the program ever runs, that every byte is freed exactly once and that no reference outlives the thing it points to. That proof system is ownership, and the part of the compiler that enforces it is the borrow checker.

Three rules do almost all the work:

  1. Each value has exactly one owner. The owner is the variable that holds it.
  2. There can be only one owner at a time. Assigning or passing a heap-owning value moves ownership; the old variable becomes unusable.
  3. When the owner goes out of scope, the value is dropped. Rust inserts the call to free the memory at the closing brace, automatically.

Because the owner is a variable and scope is lexical, the compiler always knows the exact line where each value dies. There is nothing to scan and nothing to schedule — the deallocation is just a function call (drop) emitted at the end of the owning scope. This is sometimes called scope-bound resource management, and it generalises beyond memory: file handles, sockets, and locks are all freed by the same drop-at-scope-end mechanism (Rust calls the pattern RAII, inherited from C++).

Moves, copies, and the borrow checker

The moment that surprises every newcomer is this:

let s1 = String::from("hello");
let s2 = s1;            // ownership MOVES from s1 to s2
println!("{}", s1);    // error[E0382]: borrow of moved value: `s1`

A String owns a heap buffer. If both s1 and s2 were allowed to point at it, the closing brace would run drop twice and free the same buffer twice — the classic double-free. Rust forbids the second use of s1 outright. Plain Copy types (integers, f64, bool, fixed arrays of them) duplicate their bits instead of moving, because copying a stack value can't cause a double-free.

Constantly moving values around would be miserable, so Rust lets you borrow instead — take a reference without taking ownership. There are two kinds, and the borrow checker enforces a single invariant over them, often phrased as "shared XOR mutable" or "aliasing XOR mutation":

  • Any number of shared references &T may coexist. They are read-only, so no aliasing hazard.
  • Or exactly one exclusive reference &mut T, with no other reference of any kind live at the same time.

You never get both at once. That single rule is what eliminates iterator invalidation (you can't mutate a vector while a reference into it is live), use-after-free (a reference can't outlive its owner), and data races (two threads can't share a &mut). The check is a static dataflow analysis: the compiler computes, for each reference, the set of program points where it is still used, and rejects any program where a mutable borrow's region overlaps another borrow's region.

Lifetimes: proving a reference never dangles

A lifetime is the region of code over which a reference must stay valid. The borrow checker's deepest job is to prove that a reference's lifetime never exceeds the lifetime of the value it points at. Consider the canonical bug it catches:

fn dangle() -> &String {     // error[E0106]: missing lifetime specifier
    let s = String::from("oops");
    &s                        // s is dropped here; the reference would dangle
}

In C this compiles and returns a pointer into a freed stack frame — undefined behaviour. Rust refuses, because the lifetime of &s would have to outlive s, which is impossible. When a function relates input and output references, you annotate which lifetime flows where:

// "the returned reference lives as long as the SHORTER of x and y"
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

The 'a is not a runtime value; it's a constraint the checker solves. Most of the time you never write one — the lifetime elision rules infer the obvious cases. Since 2018, Non-Lexical Lifetimes (NLL) shrink each borrow's region to its last actual use rather than the end of the lexical block, which is why a great deal of older "obviously fine" code now compiles without contortions.

When the model helps and when it fights you

  • Tree- and list-shaped data with clear ownership — a parent owns its children — maps perfectly onto the one-owner rule.
  • Concurrency. "Shared XOR mutable" is exactly the discipline that makes data races impossible, so fearless threading is the single biggest payoff.
  • Resource handles. Files, sockets, GPU buffers, and mutex guards all close themselves at scope end with zero boilerplate.

It fights you when the data is genuinely shared or cyclic. A doubly linked list, a graph with back-edges, or an observer that points back at its subject all violate single-ownership. The escape hatches are deliberate: Rc<T> / Arc<T> move the ownership decision to runtime via reference counting, RefCell<T> moves the borrow check to runtime (panicking instead of refusing to compile), and unsafe lets you make the proof yourself. Reaching for these is normal, not a failure — they trade a little runtime cost for expressiveness exactly where the static rules are too strict.

Borrow checker vs other memory-management strategies

Borrow checker (Rust)Tracing GC (Java, Go)Ref counting (Swift ARC, C++ shared_ptr)Manual (C)
When memory is freedEnd of owner's scope, statically knownLater, when collector runsWhen count hits 0When you call free
Runtime overheadNoneGC pauses, write barriersAtomic inc/dec on every shareNone
Use-after-freeCompile errorImpossible (kept alive)Impossible (kept alive)Undefined behaviour
Double-freeCompile errorImpossibleImpossibleHeap corruption
LeaksPossible (e.g. Rc cycles), but boundedCollected unless rootedReference cycles leakEasy to leak
Data racesCompile errorPossiblePossiblePossible
Pause-time / latencyDeterministicStop-the-world or concurrent pausesDeterministic but count trafficDeterministic
Cyclic dataHard; needs Rc + WeakHandled automaticallyLeaks without WeakManual
Learning curveSteep (fight the checker)GentleModerateDeceptively gentle, then footguns

The headline trade is where the cost lands. A tracing GC moves the cost to runtime and hides it from the programmer; the borrow checker moves it to compile time and the programmer's head. You pay in the form of compiler errors and a learning curve, and in return you get C-level performance with memory safety the compiler has already proven.

What the numbers actually say

  • Zero runtime overhead. A &T is one pointer; drop is a direct deallocation call. Benchmarks consistently put idiomatic Rust within a few percent of C, and well ahead of GC'd languages on tail latency because there are no collection pauses.
  • ~70% of serious security bugs are memory-safety bugs. Microsoft (2019) and the Chromium team independently reported that roughly 70% of their high-severity CVEs were memory-safety issues — exactly the class ownership eliminates by construction.
  • Android measured the payoff. Google reported that as new Android code shifted toward memory-safe languages, memory-safety vulnerabilities fell from ~76% of the total in 2019 to ~24% by 2024, with zero memory-safety CVEs in their Rust code over that span.
  • Rc/Arc costs are opt-in. A non-atomic Rc clone is a single integer increment; an Arc clone is an atomic increment (tens of cycles under contention). You only pay it where you explicitly ask for shared ownership.

Modeling the borrow checker in JavaScript

JavaScript is garbage-collected, so it has no borrow checker — but we can model the static analysis to show what the compiler actually computes. This tiny checker tracks, per variable, whether it has been moved and which borrows are currently live, then rejects illegal states.

class BorrowChecker {
  constructor() {
    this.vars = new Map();   // name -> { moved, shared: count, exclusive: bool }
  }
  declare(name)  { this.vars.set(name, { moved: false, shared: 0, exclusive: false }); }

  use(name) {                                  // any access of the owner
    const v = this.vars.get(name);
    if (v.moved) throw Error(`E0382: use of moved value \`${name}\``);
  }

  move(from, to) {                             // let to = from;
    this.use(from);
    const v = this.vars.get(from);
    if (v.shared || v.exclusive)
      throw Error(`E0505: cannot move \`${from}\` while it is borrowed`);
    v.moved = true;
    this.declare(to);
  }

  borrowShared(name) {                         // &name
    const v = this.vars.get(name); this.use(name);
    if (v.exclusive)
      throw Error(`E0502: \`${name}\` is already borrowed mutably`);
    v.shared++;
    return () => { v.shared--; };              // returns the "drop the borrow" fn
  }

  borrowMut(name) {                            // &mut name
    const v = this.vars.get(name); this.use(name);
    if (v.shared)
      throw Error(`E0502: cannot borrow \`${name}\` as mutable: also borrowed as immutable`);
    if (v.exclusive)
      throw Error(`E0499: cannot borrow \`${name}\` as mutable more than once at a time`);
    v.exclusive = true;
    return () => { v.exclusive = false; };
  }
}

// --- demo ---
const bc = new BorrowChecker();
bc.declare("v");
const r1 = bc.borrowShared("v");   // ok: &v
const r2 = bc.borrowShared("v");   // ok: another &v (shared aliasing is fine)
// bc.borrowMut("v");              // would throw E0502 here: shared borrows live
r1(); r2();                        // both shared borrows end (their scope closes)
const m = bc.borrowMut("v");       // ok now: exclusive access, nothing else live
m();

Two details mirror the real compiler. First, a borrow is represented by the closure that ends it — calling it is the moment the lexical scope closes, and only then can a conflicting borrow proceed (that's Non-Lexical Lifetimes in miniature). Second, move is blocked while any borrow is live, which is what stops you from invalidating an outstanding reference.

Reference-counting fallback in Python pseudocode

When ownership is genuinely shared, Rust falls back to Rc/Arc, which is just reference counting — the same scheme CPython uses. The catch is identical too: cycles leak unless one edge is weak. Here's the model.

class Rc:
    """Strong reference-counted box, like Rust's Rc<T>."""
    def __init__(self, value):
        self.value = value
        self.strong = 1          # live strong owners
        self.weak = 0            # live weak (non-owning) refs

    def clone(self):             # Rc::clone — share ownership
        self.strong += 1
        return self

    def drop(self):              # owner goes out of scope
        self.strong -= 1
        if self.strong == 0:
            self.value = None    # the payload is freed here
            return True          # "memory reclaimed"
        return False

    def downgrade(self):         # Rc::downgrade -> Weak
        self.weak += 1
        return Weak(self)

class Weak:
    """Non-owning ref; must upgrade() before use, may return None."""
    def __init__(self, rc): self.rc = rc
    def upgrade(self):
        return self.rc if self.rc.strong > 0 else None   # dangling -> None

# parent -> child is strong; child -> parent is weak, so the cycle can be freed.
parent = Rc({"name": "root", "children": []})
child  = Rc({"name": "leaf", "parent": parent.downgrade()})
parent.value["children"].append(child)

print(child.value["parent"].upgrade() is parent)  # True while parent lives
parent.drop()                                      # strong count -> 0, root freed
print(child.value["parent"].upgrade())             # None: the weak ref dangled safely

The lesson the borrow checker teaches even outside Rust: a back-pointer in a cyclic structure should almost always be weak, or you build a leak you can't see.

Variants and escape hatches worth knowing

Rc<T> and Arc<T>. Shared ownership via reference counting. Rc is single-threaded with non-atomic counts; Arc is thread-safe with atomic counts and a measurable cost under contention. Use the cheaper Rc unless the value crosses threads.

RefCell<T> and Cell<T>. Interior mutability: they let you mutate through a shared reference by moving the borrow check from compile time to runtime. RefCell panics if you violate "shared XOR mutable" at runtime; Cell sidesteps it by only allowing whole-value get/set. The combo Rc<RefCell<T>> is the idiomatic "shared mutable node."

Weak<T>. A non-owning reference that doesn't keep the value alive and must be upgrade()d before use. The standard fix for the parent/child cycle that would otherwise leak.

unsafe and raw pointers. unsafe turns off a small set of checks (dereferencing raw pointers, calling FFI) so you can implement what the checker can't verify — then you, not the compiler, owe the proof. Most programs use it only inside a few well-audited library primitives.

Polonius. A next-generation, more precise borrow-checker engine (location-sensitive rather than region-based) that accepts some programs today's NLL checker rejects. It is being integrated incrementally into rustc.

Common errors and how to read them

  • E0382 — use of moved value. You used a variable after its value moved. Fix by borrowing (&x), cloning (x.clone()), or restructuring so the owner stays valid.
  • E0499 / E0502 — conflicting borrows. A mutable borrow overlaps another borrow. Thanks to NLL, the usual fix is to shorten the earlier borrow's scope so it ends before the next one starts — often just moving a line.
  • E0106 / E0515 — returning a dangling reference. You returned a reference to a local. Return the owned value instead, or thread a lifetime through the signature so the reference clearly borrows from an input.
  • RefCell double-borrow panic. Moving the check to runtime means a logic error becomes a panic, not a compile error. Keep borrow() / borrow_mut() guards short-lived.
  • Rc reference cycles. Two Rcs pointing at each other never reach count 0 and leak. Make one edge a Weak.
  • Fighting the checker instead of restructuring. The borrow checker is usually right that your design has shared mutable state. Splitting a struct, indexing into a slice instead of holding two references, or passing indices rather than pointers frequently dissolves the error.

Frequently asked questions

What are the three rules of Rust ownership?

Each value has exactly one owner; there can be only one owner at a time; and when the owner goes out of scope, the value is dropped (its memory freed). These three rules let the compiler decide at compile time exactly where every value is destroyed, with no garbage collector and no manual free.

What is the difference between borrowing and moving in Rust?

Moving transfers ownership: after let b = a, the variable a is invalidated and only b can use the value. Borrowing creates a reference (&a or &mut a) that lets you read or mutate the value without taking ownership; the borrow must end before the owner is used again or dropped.

What does "cannot borrow as mutable because it is also borrowed as immutable" mean?

Rust's aliasing rule allows either any number of shared (&T) references OR exactly one exclusive (&mut T) reference at a time, never both. The error means a live shared borrow overlaps a requested mutable borrow. Shorten the shared borrow's scope so it ends before the mutable one begins.

Does the borrow checker add runtime overhead?

No. Ownership and borrow checking are entirely a compile-time analysis. References compile to ordinary pointers and drops compile to direct deallocation calls, so a borrow-checked program runs at the same speed as the equivalent hand-written C — there is no GC pause and no reference-count traffic unless you opt into Rc or Arc.

What are lifetimes and why does Rust need them?

A lifetime is the compile-time region during which a reference is valid. Lifetimes let the borrow checker prove a reference never outlives the value it points to, so you can't return a pointer to freed stack memory. Most lifetimes are inferred; you write annotations like 'a only when the compiler can't relate input and output references on its own.

How does the borrow checker prevent data races?

A data race needs two threads accessing the same memory with at least one writing and no synchronization. Rust's rule of "shared XOR mutable" makes that combination unrepresentable: you cannot hand a &mut to two threads, and shared &T is read-only. Combined with the Send and Sync marker traits, this turns data-race freedom into a compile error rather than a runtime crash.