Rust Ownership: A Clear Mental Model with 30+ Examples

Requires
Rust 1.70+
Difficulty
Beginner
Published
Updated
Author
ownership borrowing move semantics borrow checker lifetimes memory safety Copy types Clone dangling references stack vs heap

The problem ownership solves

Every program that allocates memory on the heap must eventually free it. In languages with manual memory management (C, C++), the programmer is responsible. Two categories of bugs are endemic to this model:

  • Use-after-free: Memory is freed while a pointer still references it. The pointer becomes a dangling pointer — it points to memory that may now hold something else. Dereferencing it is undefined behavior.
  • Double-free: Memory is freed twice. The allocator's internal state is corrupted. The program may crash or, worse, become exploitable.

Languages with garbage collection (Java, Python, Go) solve this by tracking references at runtime and freeing memory automatically. The trade-off is runtime overhead — GC pauses, additional memory for the GC bookkeeping, and non-deterministic timing of memory reclamation.

Rust takes a third path: the ownership system. A set of rules, enforced entirely at compile time, that proves memory is always freed exactly once and all references are always valid — without any runtime overhead whatsoever. The proof happens before the program runs. If the code compiles, it is memory-safe.

The cost is learning a new mental model. This article builds that model from the ground up.

Stack and heap — a quick recap

Rust's ownership rules are designed around two memory regions:

The stack stores values whose size is known at compile time. Allocation and deallocation are almost free — it is just a pointer increment or decrement. When a function returns, all of its stack-allocated variables disappear automatically. Stack variables are fast, but they cannot grow dynamically.

The heap stores values whose size is not known at compile time or may change at runtime. Allocation requires a system call. The allocator finds an appropriately-sized free block, reserves it, and returns a pointer. Deallocation must be explicitly requested — and this is exactly where the bugs live.

In Rust, types like i32, f64, bool, and tuples of fixed-size types live entirely on the stack. Types like String, Vec<T>, and Box<T> keep their metadata on the stack (a pointer, a length, and a capacity) but their actual content lives on the heap. Ownership governs when heap memory is freed.

The three ownership rules

Rust's entire ownership system flows from three rules:

  1. Every value in Rust has a single owner — a variable that owns the value.
  2. There can only be one owner at a time.
  3. When the owner goes out of scope, the value is dropped (memory is freed).

That is the whole model. Every other concept in ownership — moves, borrows, lifetimes — is a consequence of these three rules.

Rust
fn main() {
    // s1 is the owner of the String "hello"
    let s1 = String::from("hello");

    // s1 still owns "hello" — we can use it
    println!("{s1}");

} // s1 goes out of scope here — Rust automatically calls drop(s1)
  // The heap memory for "hello" is freed

The drop call at the end of scope is not something you write — the compiler inserts it automatically. This is deterministic, predictable memory reclamation. The memory is always freed at the closing brace of the scope that owns it, not whenever a GC decides to run.

Scope and the drop function

A value's owner is always the innermost scope in which it was created. When that scope ends, the owner is dropped. Nested scopes have their own drop points:

Rust
fn main() {
    let outer = String::from("outer");  // outer's scope begins

    {
        let inner = String::from("inner");  // inner's scope begins
        println!("inner: {inner}");
    }  // inner drops here — heap memory freed immediately

    println!("outer: {outer}");  // outer is still valid here

}  // outer drops here

You can also call drop() explicitly to free a resource before the end of scope — this is useful for releasing lock guards, closing file handles, or freeing large allocations early:

Rust
fn main() {
    let large_buffer = Vec::<u8>::with_capacity(1_000_000);

    // ... do work with large_buffer ...

    drop(large_buffer);  // explicitly free 1 MB now rather than at end of function

    // large_buffer is gone — using it here is a compile error
    // println!("{}", large_buffer.len()); // error: use of moved value

    // ... continue with other work that benefits from the freed memory ...
}
You cannot call drop() on a value more than once — the compiler prevents it. Rule 3 guarantees every value is freed exactly once.

Move semantics

Rule 2 says there can only be one owner at a time. When you assign a heap-allocated value to another variable, ownership moves — the original variable is no longer valid:

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

    // s1 is now invalid — the compiler will reject this:
    println!("{s1}");  // error[E0382]: borrow of moved value: `s1`
}

This is not a copy operation — the heap data is not duplicated. Only the ownership metadata (pointer, length, capacity) moves from s1's stack slot to s2's stack slot. The heap data stays where it is. The old stack slot (s1) is marked as invalid by the compiler, preventing a double-free when both would otherwise be dropped.

Moves happen in every context where a value is transferred: assignment, passing to a function, returning from a function:

Rust
fn take_ownership(s: String) {
    println!("Got: {s}");
}  // s drops here — heap memory freed

fn give_ownership() -> String {
    let s = String::from("mine");
    s  // move out of the function — caller becomes owner
}

fn main() {
    let s1 = String::from("hello");
    take_ownership(s1);  // s1 moves into the function
    // s1 is invalid here — you can't use it after this line

    let s2 = give_ownership();  // s2 becomes owner of the returned String
    println!("{s2}");
}
Rust — taking and returning to keep ownership
// This works but is awkward — you have to return s to "give it back"
fn calculate_length(s: String) -> (String, usize) {
    let len = s.len();
    (s, len)  // return s alongside the result to transfer ownership back
}

fn main() {
    let s1 = String::from("hello");
    let (s2, len) = calculate_length(s1);
    println!("'{s2}' has length {len}");
}
// This pattern is why references (borrowing) exist — see next section

Copy types: the exception

Types that live entirely on the stack and have a fixed size implement the Copy trait. For these types, assignment creates an independent copy — both the original and the new variable are valid. There is no ownership transfer:

Rust
fn main() {
    let x = 42;      // i32 implements Copy
    let y = x;        // y gets a copy — x is still valid
    println!("{x} {y}");  // 42 42 — both are usable

    let a = true;
    let b = a;       // bool implements Copy — a is still valid

    let p = (1.0_f64, 2.0_f64);
    let q = p;        // (f64, f64) implements Copy — p is still valid
}

Types that implement Copy include all integer types (i8 through i128, u8 through u128), usize, isize, floating-point types (f32, f64), bool, char, shared references (&T), and tuples or arrays where every element is Copy.

Types that do not implement Copy are anything that manages heap memory: String, Vec<T>, Box<T>, HashMap, and any custom struct or enum that contains non-Copy fields.

Rust — deriving Copy for custom types
// A struct can derive Copy if ALL its fields are Copy
#[derive(Debug, Clone, Copy)]
struct Point {
    x: f64,
    y: f64,
}

fn main() {
    let p1 = Point { x: 1.0, y: 2.0 };
    let p2 = p1;  // copied — p1 is still valid
    println!("{p1:?} {p2:?}");
}

Clone: explicit deep copy

When you genuinely need two independent copies of a heap-allocated value, call .clone(). This explicitly creates a full deep copy — the heap data is duplicated — and makes it clear in the code that an allocation is happening:

Rust
fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();  // deep copy — s2 has its own heap allocation

    println!("s1 = {s1}, s2 = {s2}");  // both are valid

    let v1 = vec![1, 2, 3];
    let v2 = v1.clone();  // duplicates the Vec's heap-allocated content
    println!("{v1:?} {v2:?}");
}
Clone is intentionally explicit. If you are cloning frequently in hot code paths, it is a signal to reconsider whether you should be borrowing instead. Cloning is fine when you genuinely need two independent copies; it is wasteful when a reference would serve the same purpose.

Borrowing with shared references

Passing ownership into a function and then returning it to get it back is awkward. The better solution is borrowing — passing a reference to a value without transferring ownership. The function uses the value, but ownership stays with the original variable:

Rust
// &String means "a reference to a String" — no ownership transfer
fn calculate_length(s: &String) -> usize {
    s.len()
}  // s (the reference) goes out of scope, but the String it points to is not dropped
   // because this function never owned it

fn main() {
    let s1 = String::from("hello");

    // & creates a reference — s1 still owns the String
    let len = calculate_length(&s1);

    println!("'{s1}' has length {len}");  // s1 is still valid
}

A shared reference (&T) is read-only by default. The borrower can read the value but cannot modify it. Multiple shared references can coexist simultaneously — sharing read access is always safe:

Rust
fn main() {
    let s = String::from("hello");

    // Multiple shared references at once — fine, all are read-only
    let r1 = &s;
    let r2 = &s;
    let r3 = &s;

    println!("{r1} {r2} {r3}");  // all valid simultaneously

    // Cannot modify through a shared reference
    // r1.push_str(" world");  // error: cannot borrow `*r1` as mutable
}

Dereferencing

The dereference operator * accesses the value a reference points to. Rust often handles dereferencing automatically through deref coercion, but it is important to know the explicit syntax:

Rust
fn main() {
    let x = 5;
    let r = &x;

    println!("r = {}", *r);    // explicit deref: *r = 5
    println!("r = {r}");      // deref coercion in format strings — same result

    assert_eq!(5, *r);         // true
    assert_eq!(x, *r);         // true
}

Mutable references

A mutable reference (&mut T) grants temporary exclusive write access to a value. The variable itself must be declared mut, and you use &mut to create the reference:

Rust
fn append_world(s: &mut String) {
    s.push_str(" world");  // modifying through a mutable reference is allowed
}

fn main() {
    let mut s = String::from("hello");  // s must be mut to allow &mut

    append_world(&mut s);   // pass a mutable reference
    println!("{s}");         // hello world
}

The critical restriction: while a mutable reference exists, no other references of any kind can exist to the same value. One mutable reference means exclusive access:

Rust
fn main() {
    let mut s = String::from("hello");

    let r1 = &mut s;
    let r2 = &mut s;  // error: cannot borrow `s` as mutable more than once

    println!("{r1} {r2}");
}
Rust — fix: sequential borrows, not overlapping
fn main() {
    let mut s = String::from("hello");

    {
        let r1 = &mut s;
        r1.push_str(" world");
    }  // r1 drops here — mutable borrow ends

    let r2 = &mut s;  // now r2 can borrow
    r2.push_str("!");
    println!("{s}");  // hello world!
}

This exclusivity constraint is exactly why Rust programs cannot have data races: a data race requires two concurrent accesses to the same memory where at least one is a write. Rust makes it impossible to have two simultaneous mutable references, so data races cannot exist — they are a compile error, not a runtime crash.

The borrow checker rules

The borrow checker is the part of the Rust compiler that enforces ownership and borrowing rules. Its job is to prove that all references are valid for as long as they are used. The complete ruleset:

  1. References must not outlive the value they point to (no dangling references).
  2. At any given moment, you may have either any number of shared references (&T) or exactly one mutable reference (&mut T) — but not both simultaneously.
Rust — dangling reference caught at compile time
fn dangle() -> &String {        // error: missing lifetime specifier
    let s = String::from("hello");
    &s  // returning a reference to s — but s will be dropped when dangle() returns!
}   // s drops here — the reference would dangle

// The compiler rejects this. The fix: return the String itself (move ownership)
fn no_dangle() -> String {
    let s = String::from("hello");
    s  // ownership moves to the caller — no dangling reference
}
Rust — mixing shared and mutable borrows
fn main() {
    let mut s = String::from("hello");

    let r1 = &s;          // shared borrow
    let r2 = &s;          // another shared borrow — fine
    let r3 = &mut s;     // error: cannot borrow as mutable while shared borrows exist

    println!("{r1} {r2} {r3}");
}
Rust — Non-Lexical Lifetimes (NLL): borrows end at last use
fn main() {
    let mut s = String::from("hello");

    let r1 = &s;
    let r2 = &s;
    println!("{r1} and {r2}");
    // r1 and r2 are last used above — their borrows end here (NLL)

    let r3 = &mut s;  // OK — r1 and r2 are no longer active
    r3.push_str(" world");
    println!("{r3}");
}

The Non-Lexical Lifetimes (NLL) feature (stable since Rust 2018) means a borrow ends at its last use, not at the end of the enclosing block. This makes the borrow checker significantly more ergonomic — you rarely need to add extra scopes to manage borrow boundaries.

Lifetimes: annotating reference validity

Lifetimes are compile-time annotations that describe the relationship between the validity of references. The compiler infers them in most situations through lifetime elision rules, but when the compiler cannot determine them, you annotate them explicitly with 'a syntax.

The most common case that requires explicit lifetimes is a function that takes multiple references and returns a reference — the compiler needs to know which input reference the output is tied to:

Rust
// Without lifetime annotation — compiler cannot determine which input
// the returned reference is valid for:
// fn longest(x: &str, y: &str) -> &str { ... }  // error: missing lifetime

// With lifetime annotation — 'a means "the output 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 }
}

fn main() {
    let s1 = String::from("long string");
    let result;
    {
        let s2 = String::from("xy");
        result = longest(s1.as_str(), s2.as_str());
        println!("Longest: {result}");  // OK — result used before s2 drops
    }
    // println!("{result}");  // error if here — s2 already dropped
}
Rust — lifetime in a struct
// A struct holding a reference must annotate the lifetime
// because the struct cannot outlive the data it references
struct Excerpt<'a> {
    text: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();

    // Excerpt borrows from novel — its lifetime is tied to novel's
    let excerpt = Excerpt { text: first_sentence };
    println!("{}", excerpt.text);
}  // novel and excerpt both drop here — safe
Lifetime annotations do not change how long references are valid — they are descriptions of existing relationships, not commands. The compiler uses them to verify that the code already respects those relationships. Most of the time, the compiler infers lifetimes automatically and you never write them explicitly.

Slice references

A slice is a reference to a contiguous sequence of elements in a collection. Slices are always references — they do not own the data, they borrow it:

Rust
fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();
    for (i, &byte) in bytes.iter().enumerate() {
        if byte == b' ' {
            return &s[0..i];  // return a slice of the original string
        }
    }
    &s[..]  // entire string if no space found
}

fn main() {
    let s = String::from("hello world");
    let word = first_word(&s);  // word borrows from s
    println!("{word}");          // hello

    // The borrow checker protects the slice:
    // s.clear();  // error: cannot mutate s while word borrows from it
    // println!("{word}");  // word would be a dangling reference
}

// Vec slices work the same way
fn sum_first_half(data: &[i32]) -> i32 {
    let half = data.len() / 2;
    data[..half].iter().sum()
}

Using &str (a string slice) instead of &String as function parameters is idiomatic Rust — it is more flexible because it accepts both &String values (via deref coercion) and string literals (&'static str).

Ownership patterns in practice

With the full model in hand, here are the patterns you will use most frequently when writing real Rust code:

Pattern 1 — read-only access: pass &T

Rust
fn print_stats(data: &[f64]) {
    let sum: f64 = data.iter().sum();
    println!("count: {}, sum: {sum:.2}", data.len());
}

fn main() {
    let prices = vec![12.5, 8.0, 34.75];
    print_stats(&prices);  // borrow, not move
    print_stats(&prices);  // can use prices again after the borrow ends
}

Pattern 2 — mutation without ownership: pass &mut T

Rust
fn normalise(data: &mut Vec<f64>) {
    let max = data.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
    for v in data.iter_mut() {
        *v /= max;
    }
}

fn main() {
    let mut scores = vec![40.0, 80.0, 100.0];
    normalise(&mut scores);
    println!("{scores:?}");  // [0.4, 0.8, 1.0]
}

Pattern 3 — transfer ownership: pass T

Rust
fn process_and_discard(data: Vec<i32>) -> i32 {
    data.iter().sum()
}  // data drops here — intentional, the function consumes it

fn main() {
    let nums = vec![1, 2, 3, 4, 5];
    let total = process_and_discard(nums);  // nums moved — no longer usable here
    println!("total: {total}");
}

The choice between these three patterns comes down to what the function needs to do:

NeedParameter typeCaller retains ownership?
Read the value&T or &[T]Yes
Modify the value&mut TYes
Consume the valueTNo — value moved in
Store a copy cheaplyT where T: CopyYes (copy happens implicitly)
Store a full duplicateT with .clone() at call siteYes — caller keeps original

When in doubt, start with &T. If you need to modify the value, change to &mut T. Only take ownership (T) when the function genuinely needs to own the data — for example, when it will store the value in a struct or move it into a thread.

Frequently Asked Questions

Why does Rust use ownership instead of garbage collection?
Garbage collectors add runtime overhead — pauses, memory overhead for the GC itself, and non-deterministic behavior. Rust's ownership system enforces memory safety at compile time with zero runtime cost. The compiler proves that memory is always freed exactly once and references always point to valid memory, without needing a GC running alongside your program.
What is the difference between a move and a copy in Rust?
When you assign a value to another variable, types that implement the Copy trait are duplicated — both variables are valid and independent. Types that do not implement Copy (like String, Vec, Box) are moved — ownership transfers to the new variable and the original is no longer valid. Copy is an implicit bitwise copy, appropriate for small stack values. Clone is an explicit deep copy, used when you need a full duplicate of a heap-allocated type.
Can I have both mutable and immutable references at the same time?
No. Rust enforces one of two access patterns at any moment: either one mutable reference (exclusive write access) or any number of immutable references (shared read access), but never both simultaneously. This rule is what prevents data races at compile time. If you need both, restructure the code so the immutable borrows end before the mutable borrow begins.
What is a lifetime in Rust?
A lifetime is a compile-time annotation that describes how long a reference is valid relative to other references. Rust infers lifetimes in most cases (lifetime elision), but when the compiler cannot determine them from context — typically in function signatures that return references, or in structs that hold references — you annotate them explicitly with the syntax &'a T. Lifetimes are a zero-cost abstraction: they disappear entirely after compilation.