Skip to content

02 — Rust Ownership Model

Technical Overview

Rust's ownership model is the central innovation that makes it a memory-safe systems language without garbage collection. The model consists of three interlocking concepts: ownership (every value has one owner, who is responsible for its deallocation), borrowing (temporary access without taking ownership), and lifetimes (compile-time tracking that references never outlive the data they point to). Together, these rules are enforced by the borrow checker — a component of the Rust compiler that statically verifies memory safety for every program. Understanding the ownership model is prerequisite to understanding why Rust can guarantee safety while maintaining C-like performance.

Prerequisites

  • Stack vs heap allocation semantics
  • RAII (Resource Acquisition Is Initialization) concept from C++
  • Pointer and reference semantics
  • Compiler static analysis basics
  • C++ unique_ptr and shared_ptr for comparison

Historical Context

The ownership model was not invented by Rust. Linear type theory and region-based memory management had been explored in academic programming language research since the 1980s (Girard's linear logic, 1987; Tofte and Talpin's region inference, 1997; the Cyclone language, 2002). Rust's contribution was making a practical, usable systems language that incorporated these ideas with an ergonomic syntax and an industrial-strength compiler. Graydon Hoare started Rust as a personal project in 2006; Mozilla Research adopted it in 2009; Rust 1.0 was released in May 2015.


Core Ownership Rules

Rust's ownership system is governed by three rules that are always in effect and statically verified:

Rule 1: Every value in Rust has exactly one owner — a variable that "owns" the value.

Rule 2: There can only be one owner at a time.

Rule 3: When the owner goes out of scope, the value is dropped (freed).

fn main() {
    let s1 = String::from("hello");  // s1 owns the heap-allocated String

    // At scope exit, s1 is dropped: the String's heap memory is freed
    // No need for free() or delete — the compiler inserts it automatically
}  // <-- s1 goes out of scope here, String is dropped

Ownership Transfer (Move Semantics)

When a value is assigned to a new variable or passed to a function, ownership moves to the new location. The original variable becomes invalid — it can no longer be used.

let s1 = String::from("hello");
let s2 = s1;  // ownership moves from s1 to s2

println!("{}", s1);  // ERROR: s1 was moved
// error[E0382]: borrow of moved value: `s1`
// note: `s1` moved due to this assignment

println!("{}", s2);  // OK: s2 is the current owner

Why this prevents double-free: If ownership has moved to s2, then when s1 goes out of scope, there is nothing to free (the value was moved). When s2 goes out of scope, it frees the value exactly once. The compiler statically enforces this — no runtime check needed.

Move semantics diagram:

Before:
  s1 ──owns──► "hello" (heap)

After: let s2 = s1;
  s1:  [INVALID — no longer owns anything]
  s2 ──owns──► "hello" (heap)

When s2 drops:
  "hello" freed exactly once

Contrast C++:
  std::string s1 = "hello";
  std::string *s2 = &s1;  // raw pointer: no ownership transfer
  delete s1;  // frees
  delete s2;  // DOUBLE FREE: undefined behavior

The Copy Trait

For simple, stack-allocated types (integers, booleans, chars, tuples of Copy types), Rust performs a copy rather than a move. The original remains valid because copying is cheap and well-defined:

let x = 5;       // i32, which implements Copy
let y = x;       // x is COPIED, not moved

println!("{}", x);  // OK: x is still valid (it was copied, not moved)
println!("{}", y);  // OK: y has an independent copy

Types that implement Copy: all integer types (i8..i64, u8..u64, isize, usize), f32, f64, bool, char, (), arrays of Copy types, tuples of Copy types.

Types that do NOT implement Copy: String (heap allocation), Vec<T>, Box<T>, any type with a Drop implementation. For these, assignment is always a move.


The Borrow Checker

The borrow checker is the component of the Rust compiler (rustc) that enforces the ownership rules at compile time. It operates on the HIR (High-level Intermediate Representation) of the program, tracking ownership and reference validity through every code path.

Borrowing Rules

Instead of taking ownership, code can borrow a reference to a value. There are two kinds of borrows:

Immutable borrow (&T): Multiple immutable borrows can exist simultaneously. The owner cannot mutate the value while it is borrowed immutably.

Mutable borrow (&mut T): Exactly one mutable borrow may exist at a time. No other borrows (mutable or immutable) may exist simultaneously.

let mut s = String::from("hello");

// Multiple immutable borrows: OK
let r1 = &s;
let r2 = &s;
println!("{} {}", r1, r2);  // r1 and r2 used here

// Mutable borrow: OK (after immutable borrows are done)
let r3 = &mut s;
r3.push_str(" world");

// Simultaneous immutable and mutable borrow: ERROR
let r4 = &s;
let r5 = &mut s;  // ERROR: cannot borrow `s` as mutable because it is also borrowed as immutable
println!("{} {}", r4, r5);
// error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable

Why this prevents data races: A data race requires: two concurrent accesses to the same memory, at least one is a write, without synchronization. The borrow checker's rule (many reads OR one write) eliminates the possibility of a concurrent mutable access from the same thread. Combined with the Send/Sync trait system for multi-threading (only Sync types can be shared across threads; sharing &mut T across threads requires a Mutex), data races are compile-time errors.

Dangling Reference Prevention

The borrow checker ensures that references never outlive the data they point to:

fn dangle() -> &String {           // tries to return a reference to a String
    let s = String::from("hello");  // s is created inside the function
    &s                              // returns a reference to s
}                                   // s goes out of scope, its memory is freed
                                    // ERROR: reference to freed memory

// error[E0106]: missing lifetime specifier
// ... this function's return type contains a borrowed value, but there is no value
//     for it to be borrowed from

The compiler detects that s has a shorter lifetime than the returned reference. The fix is to return the String by value (transfer ownership), not by reference.


Lifetimes

Lifetimes are Rust's mechanism for tracking how long references are valid. Every reference has a lifetime — the scope for which the reference is valid. Lifetimes are usually inferred by the compiler, but must sometimes be explicitly annotated.

Lifetime Annotations

Lifetime annotations use 'name syntax. They describe the relationship between the lifetimes of multiple references — they do not change how long a value lives, they only communicate constraints:

// This function returns a reference to the longer of two string slices
// The output reference must live at least as long as both inputs
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

// The 'a annotation says:
// "the returned reference will be valid for at least the overlap of 
//  the lifetimes of x and y"
// The compiler uses this to verify that the returned reference is not used
// after either x or y is dropped.

Without the 'a annotation, the compiler cannot determine how long the returned reference is valid (it could come from either x or y), and rejects the function.

Lifetime in Structs

If a struct holds references, its lifetime must be annotated:

struct Important<'a> {
    part: &'a str,  // this struct cannot outlive the string it references
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let i;
    {
        let first_sentence = novel.split('.').next().expect("Could not find a '.'");
        i = Important { part: first_sentence };  
        // first_sentence is a &str that borrows from novel
        // i.part is valid as long as novel is valid
    }
    println!("{}", i.part);  // OK: novel is still in scope
}

Lifetime Elision Rules

Most common lifetime patterns can be inferred by the compiler (called lifetime elision). The three elision rules:

Rule 1: Each reference parameter gets its own lifetime parameter.

fn foo(x: &str) -> &str { ... }
// compiler sees: fn foo<'a>(x: &'a str) -> &'a str

Rule 2: If there is exactly one input lifetime parameter, all output lifetimes get that parameter.

fn first_word(s: &str) -> &str { ... }
// compiler assigns: fn first_word<'a>(s: &'a str) -> &'a str
// output lifetime = input lifetime (valid rule 2 application)

Rule 3: If there is an &self or &mut self parameter, the output lifetime gets self's lifetime.

impl ImportantExcerpt<'_> {
    fn level(&self) -> i32 { ... }
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        // Rule 3: output lifetime = self's lifetime
        self.part
    }
}

When these three rules do not determine all output lifetimes, the compiler requires explicit annotations.


Non-Lexical Lifetimes (NLL) — Rust 2018

Prior to Rust 2018, the borrow checker used lexical lifetimes: a borrow lasted for the entire lexical scope (block) in which it was defined, even if the borrow was not actually used after a certain point in the block.

/* PRE-NLL (Rust 2015): would be rejected */
let mut v = vec![1, 2, 3];
let last = v.last();     // immutable borrow of v
println!("{:?}", last);  // last used here — borrow ends logically here

v.push(4);               // mutable borrow of v
                         // Pre-NLL: ERROR — v still "borrowed" by last
                         // even though last was used and done

NLL (Non-Lexical Lifetimes) tracks borrows by their actual last use point rather than the end of their lexical scope. The borrow checker became significantly more ergonomic in Rust 2018:

/* POST-NLL: accepted */
let mut v = vec![1, 2, 3];
let last = v.last();    // immutable borrow
println!("{:?}", last); // last used here — borrow ENDS here (NLL)

v.push(4);              // mutable borrow — OK, immutable borrow already ended

NLL was implemented using Polonius — a reimplementation of the borrow checker using Datalog-based analysis — which tracks liveness of borrows through the control flow graph rather than lexical scope boundaries.


Why These Rules Guarantee Memory Safety

The ownership model provides safety guarantees through five invariants:

1. No use-after-free: The owner is the only entity that can free the value. A value is freed when its owner is dropped. Any reference to the value has a lifetime that the borrow checker verifies is shorter than the owner's lifetime. Therefore, no reference can be used after the value is freed.

2. No double-free: Ownership is exclusive (one owner at a time). Move semantics ensure there is always exactly one owner. Dropping happens only once, when the one owner is dropped.

3. No dangling pointers: Lifetime analysis ensures references do not outlive the owned value. Any reference that would outlive its target is a compile error.

4. No data races: The borrow rule (many &T OR one &mut T) ensures that mutable access to a value is exclusive. Combined with Send/Sync trait bounds on cross-thread sharing, concurrent mutable access requires explicit synchronization (Mutex, RwLock, Arc).

5. No null pointer dereferences: Rust has no null pointer. Optional values are represented as Option<T>, which must be pattern-matched to extract the value. Accessing an Option::None as if it were Some is a compile error or a deliberate unwrap() that panics with a clear message.


Ownership Transfer Diagram

Stack        Heap
─────        ────

let s1 = String::from("hello");
  s1: ┌─────────┐    ┌─────────────────────┐
      │ ptr      │───►│ "hello"             │
      │ len: 5   │    └─────────────────────┘
      │ cap: 5   │
      └─────────┘

let s2 = s1;   // MOVE — not copy
  s1: [INVALID — moved]
  s2: ┌─────────┐    ┌─────────────────────┐
      │ ptr      │───►│ "hello"             │
      │ len: 5   │    └─────────────────────┘
      │ cap: 5   │
      └─────────┘
  s2 now owns the heap allocation

// s1 goes out of scope: nothing to free (moved)
// s2 goes out of scope: heap freed exactly once ✓

Borrow (no ownership transfer):
  let s = String::from("hello");
  let r = &s;  // r borrows s — does NOT own

  s: ┌─────────┐    ┌─────────────────────┐
     │ ptr      │───►│ "hello"             │
     │ len: 5   │    └─────────────────────┘
     │ cap: 5   │
     └─────────┘
       ▲
  r: ──┘  (r points to s, does not own the heap allocation)

  When r's borrow ends: nothing freed (r never owned)
  When s drops: heap freed once ✓

  Borrow checker verifies: r's lifetime ⊂ s's lifetime

Production Examples

Rust's Standard Library: Vec and Drop

Vec<T> in Rust's standard library demonstrates ownership in practice:

pub struct Vec<T, A: Allocator = Global> {
    buf: RawVec<T, A>,   // owns the heap allocation
    len: usize,
}

impl<T, A: Allocator> Drop for Vec<T, A> {
    fn drop(&mut self) {
        // This is called automatically when Vec goes out of scope
        // Drop each element
        unsafe { ptr::drop_in_place(ptr::slice_from_raw_parts_mut(self.as_mut_ptr(), self.len)) }
        // Deallocate the buffer
        // (handled by RawVec's Drop implementation)
    }
}

The Drop trait is Rust's RAII mechanism. When a Vec goes out of scope, drop() is called automatically, which frees all elements and the heap buffer. No free() or delete required.

Tokio: Async Rust and Arc>

In async Rust (Tokio), shared mutable state across tasks uses Arc<Mutex<T>>:

use std::sync::{Arc, Mutex};
use tokio::task;

async fn shared_counter() {
    let counter = Arc::new(Mutex::new(0));  // reference-counted, mutex-protected

    let counter_clone = Arc::clone(&counter);  // clone the Arc (not the data)
    let task1 = task::spawn(async move {
        let mut lock = counter_clone.lock().unwrap();
        *lock += 1;
    });

    let counter_clone2 = Arc::clone(&counter);
    let task2 = task::spawn(async move {
        let mut lock = counter_clone2.lock().unwrap();
        *lock += 1;
    });

    task1.await.unwrap();
    task2.await.unwrap();

    println!("Counter: {}", *counter.lock().unwrap());  // always 2, no data race
}
// Arc provides: reference-counted ownership (freed when last Arc drops)
// Mutex provides: exclusive mutable access (data race prevention)
// Together: safe shared mutable state

Debugging Notes

# Borrow checker errors often reference specific error codes
rustc --explain E0382  # use of moved value
rustc --explain E0502  # mutable and immutable borrow conflict
rustc --explain E0106  # missing lifetime annotation

# Visualize borrow relationships
cargo install cargo-expand  # expand macros to see generated code
cargo expand               # see full generated Rust, including lifetime annotations

# Check for common ownership mistakes
cargo clippy  # linter with memory and ownership specific warnings

# Understand lifetime errors with detailed output
RUST_BACKTRACE=1 cargo build 2>&1 | head -50

Security Implications

The ownership model provides security guarantees that no amount of code review can provide in C/C++: - No use-after-free → no attacker-controlled memory reuse - No data races → no Dirty COW-class kernel races in safe Rust - No null dereference → no kernel NULL-deref privilege escalation - No double-free → no heap corruption from double-free

Programs written in safe Rust cannot have CVEs from these classes — they are compile errors.

Performance Implications

  • Move semantics: same overhead as pointer assignment (no copy of heap data)
  • Borrowing: zero overhead — borrows compile to raw pointers
  • Lifetime analysis: compile-time only — zero runtime overhead
  • Drop: RAII destructor — same overhead as C++ destructor, predictable timing (no GC pause)
  • Arc: reference counting — 2 atomic increments/decrements per clone/drop (~10ns each)

Failure Modes

The ownership model can make some patterns difficult to express: - Self-referential structs: A struct that holds a reference to itself. Requires Pin<T> (see async Rust). - Cyclic data structures: A doubly-linked list requires either Rc<RefCell<T>> (with cycle detection overhead) or unsafe raw pointers. - Iterator invalidation patterns: Iterating over a collection while modifying it is a compile error in Rust (correct!). Must restructure the algorithm.

Modern Usage

  • The Rust compiler (rustc) itself is the reference implementation of the ownership model
  • rustc uses the Polonius borrow checker (NLL) as of Rust 2018
  • The next-generation borrow checker ("Polonius 2.0") is in development for cases where NLL is still overly conservative
  • Async Rust (Tokio, async-std) extends the ownership model to asynchronous tasks with Pin<T> for safe self-reference in futures

Future Directions

  • Polonius 2.0: More precise borrow checking, eliminating remaining false positives in NLL
  • Borrow checker API: Proposals to expose the borrow checker as a library for use in IDE plugins and external tools
  • Formal verification of the borrow model: RustBelt (Dreyer et al., 2018) provides a formal proof that Rust's type system is sound — the ownership rules provably prevent the undefined behaviors claimed

Exercises

  1. Implement a stack data structure in Rust using ownership semantics. The push function should take ownership of the value, and pop should return ownership. Verify that you cannot use a popped value's original binding.

  2. Write a function first_word(s: &str) -> &str that returns a slice of the first word. Add lifetime annotations explicitly (don't rely on elision). Verify the function rejects calls where the returned slice would outlive the input.

  3. Implement a simple linked list in Rust without unsafe. Start with Box<T> for heap allocation. Observe how ownership naturally manages memory. Then try to implement a doubly-linked list — observe the difficulty and why unsafe or Rc<RefCell<T>> is required.

  4. Write a multi-threaded counter using Arc<Mutex<i32>>. Spawn 10 threads each incrementing the counter 1000 times. Verify the result is always 10,000. Then try to share Rc<RefCell<i32>> across threads and observe the compile error.

  5. Read the RustBelt paper (Jung et al., 2018, available on the author's website). What does "semantic soundness" mean for Rust's type system? How does RustBelt handle unsafe code in the formal model?

References

  • Matsakis, Nicholas D.; Klock, Felix S. "The Rust Language." ACM SIGADA, 2014.
  • Jung, Ralf et al. "RustBelt: Securing the Foundations of the Rust Programming Language." POPL, 2018.
  • Matsakis, Nicholas. "Non-Lexical Lifetimes." blog.rust-lang.org, 2016.
  • Klabnik, Steve; Nichols, Carol. "The Rust Programming Language." No Starch Press, 2019 (also free at doc.rust-lang.org/book).
  • Tofte, Mads; Talpin, Jean-Pierre. "Region-Based Memory Management." Information and Computation, 1997.
  • Rust Reference: doc.rust-lang.org/reference (lifetimes, ownership, borrowing — authoritative specification)