Skip to content
Published on

Rust Ownership, Borrowing, and Lifetimes

Authors

Introduction — Safe Memory Without a GC

Most languages get memory safety one of two ways. Either they hand everything to the programmer, like C and C++ (and you get use-after-free, double frees, and data races), or they hand it to a garbage collector, like Java, Go, and Python (and you get runtime overhead and unpredictable pauses).

Rust takes a third path. It tracks the lifetime of memory and resources at compile time, so that use-after-free and data races simply don't compile — with no GC. The trick behind this is three sets of rules: ownership, borrowing, and lifetimes. Understand these three and most of the rest of Rust follows naturally. Conversely, the time you spend wrestling with them is exactly that infamous "fight with the borrow checker."

The goal of this post is to end that fight quickly. Instead of memorizing the rules, once you understand why they exist, you start to see why the checker is complaining.

The Three Rules of Ownership

The official Rust book sums up ownership in three sentences.

  1. Every value in Rust has an owner.
  2. There can be only one owner at a time.
  3. When the owner goes out of scope, the value is dropped.

The third rule is what replaces the GC. At the closing } of a scope, Rust automatically calls drop on the values that scope owns. Heap memory gets freed, a file gets closed, a lock gets released. This is called RAII (Resource Acquisition Is Initialization), a concept from C++, but Rust enforces it at the language level.

fn main() {
    let s = String::from("hello"); // s owns the heap string
    println!("{s}");
} // s goes out of scope here → drop(s) is called → heap memory freed

A String holds its data on the heap and keeps three values on the stack: a pointer, a length, and a capacity. When the scope ends, the three stack values disappear, and just before that drop returns the heap buffer. No developer calls free, no GC cleans up later. The moment of deallocation is statically fixed in the code.

Move Semantics — Not a Copy, a Move

This is where the "only one owner" rule gets interesting. What happens when you assign a value to another variable?

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;        // ownership of s1 "moves" into s2
    println!("{s2}");   // OK
    // println!("{s1}"); // compile error! s1 is no longer valid
}

If you come from another language, you'd expect s1 and s2 to be two names for the same string. But that would break rule 2 — there would be two owners. And if both were valid, drop would be called twice at the end of scope, double-freeing the same heap buffer. That's a notorious bug in C++.

Rust's solution is to define assignment as a move. After let s2 = s1;, s1 is in a "moved-out" state and can't be used anymore. Ownership lives in exactly one variable (s2), and drop runs only once. The move itself is a shallow operation that copies only the three stack values (pointer, length, capacity); it never touches the heap data. Fast and safe.

Function calls work the same way. Passing a value to a function moves ownership into it.

fn consume(s: String) {
    println!("{s}");
} // s is dropped here

fn main() {
    let s = String::from("hello");
    consume(s);         // ownership moves into consume
    // println!("{s}"); // error! s has already been moved
}

E0382 — Reading the Use-After-Move Error

Uncomment the line above and Rust says this. Rust's error messages are among the friendliest in the world, so just reading them solves most problems.

error[E0382]: borrow of moved value: `s`
  --> src/main.rs:9:22
   |
7  |     let s = String::from("hello");
   |         - move occurs because `s` has type `String`,
   |           which does not implement the `Copy` trait
8  |     consume(s);
   |             - value moved here
9  |     // println!("{s}");
   |                    ^ value borrowed here after move

Let's take this apart. error[E0382] is the error code; run rustc --explain E0382 for a detailed writeup. The body pinpoints three spots: (1) s was moved because String doesn't implement Copy, (2) the value was moved at consume(s), and (3) the value was used again after the move. Cause, move site, and reuse site, all at a glance.

The fix depends on the situation. If you still need the value, you can (a) make consume borrow it instead of taking ownership (next section), (b) pass a deep copy with s.clone(), or (c) have consume return the value. Usually the right answer is (a).

Borrowing — Access Without Transferring Ownership

If every function took ownership of its arguments, programming would be misery — you'd have to hand over a string and get it back just to measure its length. That's what borrowing is for: taking a temporary reference to a value without owning it. The syntax is the ampersand (&).

fn length(s: &String) -> usize { // &String: "borrows" the string
    s.len()
} // s is only a reference, so it is not dropped here (the original lives on)

fn main() {
    let s = String::from("hello");
    let n = length(&s); // &s: lends s out
    println!("{s} has length {n}"); // s is still valid!
}

A reference does not own the value, so when the reference goes out of scope the original is not dropped. When length returns, main's s is untouched. That's the heart of borrowing: leave ownership where it is and lend out access for a while.

There are two kinds of borrow.

  • Immutable reference (&T): read-only. You can look at the value but not change it.
  • Mutable reference (&mut T): read and write. You can modify the borrowed value.
fn push_world(s: &mut String) { // borrow mutably
    s.push_str(" world");       // modify the original
}

fn main() {
    let mut s = String::from("hello"); // must be `mut` to lend mutably
    push_world(&mut s);                // pass a mutable reference
    println!("{s}"); // "hello world"
}

The Borrow Checker — The "Shared XOR Mutable" Rule

Now for the heart of Rust. The borrow checker enforces exactly one rule about references. Understand it and 90% of checker errors are explained.

At any given moment, for a given value you may have either one mutable reference (&mut) or any number of immutable references (&) — but never both at once.

This is often called "shared XOR mutable" or "aliasing XOR mutation." As the exclusive-or name suggests, sharing (many &) and mutation (&mut) are mutually exclusive. While many are reading, no one may write; while someone is writing, no one may read (including themselves).

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

    let r1 = &s; // immutable reference 1
    let r2 = &s; // immutable reference 2 — OK, many immutable refs are allowed
    println!("{r1} and {r2}");

    let r3 = &mut s; // mutable reference — OK once the immutable refs are no longer used
    r3.push_str(" world");
    println!("{r3}");
}

Try to make a mutable reference while an immutable one is still alive and it's an error.

fn main() {
    let mut s = String::from("hello");
    let r1 = &s;        // immutable borrow starts
    let r2 = &mut s;    // error! can't borrow mutably during an immutable borrow
    println!("{r1} {r2}");
}

Why does this rule exist? To prevent data races and iterator invalidation at compile time. If one thread reads a vector while another (or even the same one) pushes an element onto it, a reallocation can happen and the reference you were reading now points into the void. In C++ this shows up as a runtime crash or silent corruption; in Rust it simply doesn't compile. The guarantee "if there's one mutable reference, no one can change the value except through that reference" makes the very definition of a data race ("concurrent access without synchronization where at least one is a write") impossible at compile time.

E0502 — Immutable and Mutable Borrow Conflict

Here is that conflict in real collection code. It's one of the most common errors you'll hit.

fn main() {
    let mut v = vec![1, 2, 3];
    let first = &v[0];  // immutable borrow (points at an element)
    v.push(4);          // mutable borrow (modifies the whole vector)
    println!("{first}");
}
error[E0502]: cannot borrow `v` as mutable because it is also
              borrowed as immutable
 --> src/main.rs:4:5
  |
3 |     let first = &v[0];
  |                  - immutable borrow occurs here
4 |     v.push(4);
  |     ^^^^^^^^^ mutable borrow occurs here
5 |     println!("{first}");
  |               ------- immutable borrow later used here

This error looks annoying, but it's actually stopping a real bug. If the vector is at capacity, v.push(4) allocates a larger buffer on the heap, copies the existing elements over, and frees the old buffer. Now the address first pointed at is freed memory. In C++'s std::vector this is the famous iterator-invalidation bug, and it typically reads a wrong value silently or segfaults. Rust knows first's immutable borrow lives until the println!, so it rejects the push in between. The fix is to push after you're done with first, or to index into the vector each time.

By the way, thanks to NLL (Non-Lexical Lifetimes), introduced in Rust 2018, a reference's lifetime ends at its last use, not at the closing } of the scope. So if you delete println!("{first}") above, it compiles — first is no longer used, so its borrow is considered already over.

Lifetimes — References Must Not Outlive Their Referent

The borrow checker's second job is to prevent dangling references. A reference must not outlive the value it points to — otherwise it would point into already-freed memory.

fn main() {
    let r;
    {
        let x = 5;
        r = &x;     // borrow x
    }               // x is dropped here
    // println!("{r}"); // error! r points at a dead x
}

Rust knows r is trying to outlive x and rejects it (error E0597, "x does not live long enough"). So far the compiler figures this out on its own. But across a function boundary there are cases the compiler can't infer alone. That's when we make the relationship explicit with a lifetime annotation.

// Return the longer of two string slices
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

That 'a (a lifetime parameter, read "tick a") is a lifetime annotation. What it says is: "the returned reference is valid for at least as long as the shorter-lived of x and y." The annotation does not change a reference's actual lifetime. It only tells the compiler the relationship between several references' lifetimes. The compiler uses that relationship to check, at the call site, that the return value isn't used longer than its sources.

Why is this needed? From the compiler's point of view, it can't tell whether longest's return value came from x or from y without looking at the body (and it checks using only the signature). Since the two arguments may have different lifetimes, it needs a way to express how long the returned reference stays valid. 'a is that expression.

Lifetime Elision — Most of the Time You Don't Write Them

If you've read this far and worry you'll have to put 'a on every reference — good news, you won't. The compiler has lifetime elision rules that infer annotations automatically for common patterns. So writing explicit lifetimes comes up less often than you'd think.

// Written explicitly:
fn first_word<'a>(s: &'a str) -> &'a str { /* ... */ }

// Thanks to elision, this is identical:
fn first_word(s: &str) -> &str {
    match s.find(' ') {
        Some(i) => &s[..i],
        None => s,
    }
}

The elision rules are roughly: (1) each input reference gets its own lifetime; (2) if there is exactly one input reference, its lifetime is assigned to all output references; (3) if there's a &self or &mut self in a method, self's lifetime is assigned to all outputs. When these rules leave no ambiguity, you don't write anything. You only annotate when the rules don't decide it (like longest, which has two input references).

The 'static lifetime is worth knowing too. A &'static str is a reference that lives for the entire program, the classic example being a string literal ("hello"). Literals are baked into the binary, so they're always valid.

Copy vs Clone — Types That Don't Move

Earlier, assigning a String was a move. But integers are different.

fn main() {
    let x = 5;
    let y = x;          // a copy, not a move
    println!("{x} {y}"); // both valid! x was not moved out
}

A type like i32 implements the Copy trait. For a Copy type, assignment does a bitwise copy instead of a move, and the original stays valid. Why is an integer fine but a String isn't? An integer is a fixed-size value that lives entirely on the stack, so copying its bits shares no heap resource and there's no double-free risk. A String, on the other hand, holds a heap pointer, so a bit copy would leave two owners pointing at the same heap. That's why it can't be Copy.

Types that are Copy: all integers and floats (i32, u64, f64), bool, char, and tuples/arrays made only of Copy types ((i32, i32), [u8; 4]). The rule is "a pure stack value that owns no heap resource and doesn't implement Drop."

Clone is an explicit deep copy. Where Copy is automatic, implicit, and cheap, Clone is manual, explicit, and usually expensive (it often allocates on the heap).

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone(); // duplicates the heap data too (a new allocation)
    println!("{s1} {s2}"); // both valid — s1 was not moved
}

.clone() is an easy escape hatch from move errors, but overusing it is a performance smell. Code that clones a heap string on every call can usually be rewritten with a borrow (&str). If you soothed the checker with .clone() as a beginner, refactoring those spots to references once you're comfortable is a good next step.

To summarize, assignment behaves in three ways. If the type is Copy, it's an automatic copy (original valid); if you explicitly call Clone, it's a deep copy (original valid); if it's neither, it's a move (original invalidated).

Learning It With Your Hands

Ownership, borrowing, and lifetimes are a topic where knowing-from-reading and knowing-from-doing are especially far apart. Why the checker rejects something really sinks in only after being rejected several times. In the Rust Learning Lab you can compile move, borrow, and lifetime scenarios yourself and practice reading the error messages — the rules in this post will stick far faster.

One practical tip to close on. When you find yourself fighting the checker, read the error code before you start changing code at random. Whether it's E0382 (use after move), E0502 (borrow conflict), or E0597 (doesn't live long enough), the prescription is completely different. Rust's errors almost always tell you what you broke, where, and why. Reading that confession is a hundred times faster than guessing.

Wrapping Up

Ownership, borrowing, and lifetimes feel like obstacles at first, but they're really the compiler catching, on your behalf, bugs that would otherwise have blown up at runtime (or in production at 3 a.m.). Use-after-free, double free, data races, iterator invalidation — all of them are statically excluded by three rules: one owner, shared XOR mutable, and references can't outlive their referent.

The fight with the borrow checker has an end. The moment you understand the reasons behind the rules, the checker stops being an adversary and becomes a pair-programming partner. From that point on, Rust isn't "a fussy language" but "a language that points out my mistakes at compile time."

References