Ownership and Borrowing

Rust's ownership rules, move semantics, borrowing, the borrow checker, and an intro to lifetimes.

Why ownership exists

Every programming language needs a strategy for managing memory. Languages like Java, Go and Python use a garbage collector that periodically scans for unused memory and frees it — simple to use, but it costs runtime CPU cycles and introduces unpredictable pauses. Languages like C and C++ hand memory management entirely to the programmer via malloc/free — fast, but a well-documented source of bugs: forget to free memory and you leak it; free it twice, or use it after freeing, and you get undefined behavior, crashes, and security vulnerabilities.

Rust takes a third path: ownership. A set of rules, checked entirely at compile time by a component called the borrow checker, that guarantees memory is always freed exactly once, and that a piece of memory is never accessed after it's freed — with zero runtime overhead, because all the checking happens before the program ever runs.

The three ownership rules

  1. Each value in Rust has a single owner — the variable that holds it.
  2. There can only be one owner at a time.
  3. When the owner goes out of scope, the value is dropped (its memory is freed) automatically.
Rust
fn main() {
    {
        let s = String::from("hello"); // s owns this String's heap memory
        println!("{s}");
    } // s goes out of scope here — Rust automatically calls drop(), freeing the memory
    // s is no longer accessible past this point
}

No garbage collector, no manual free() — the compiler inserted the cleanup code for you, at a point it could prove was correct, at compile time.

Move semantics

Here's where it gets interesting. Assigning a heap-allocated value (like a String) to another variable doesn't copy it — it moves ownership, and the original variable becomes invalid:

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

    println!("{s1}"); // compile error!
}
Plaintext
error[E0382]: borrow of moved value: `s1`
  --> src/main.rs:5:20
   |
2  |     let s1 = String::from("hello");
   |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3  |     let s2 = s1; // ownership of the String moves from s1 to s2
   |              -- value moved here
4  |
5  |     println!("{s1}"); // compile error!
   |                ^^ value borrowed here after move

This is not a copy-on-write optimization detail — s1 is genuinely, permanently invalidated the moment ownership transfers to s2. This prevents a class of bug called double free: if both s1 and s2 were valid owners of the same heap memory, Rust would try to free that memory twice when they both went out of scope, corrupting the heap. By allowing only one owner, the bug is impossible by construction.

The same happens when passing a value into a function:

Rust
fn takes_ownership(s: String) {
    println!("{s}");
} // s goes out of scope here — the String is dropped

fn main() {
    let s = String::from("hello");
    takes_ownership(s); // ownership moves into the function
    println!("{s}");    // compile error — s was moved, no longer valid here
}

Clone — opting in to a deep copy

When you genuinely need two independent, valid copies of heap data, call .clone() explicitly. This makes the (potentially expensive) copy visible in the code, rather than hiding it:

Rust
fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone(); // an explicit, independent deep copy

    println!("{s1} and {s2}"); // both valid — no move happened
}

Copy types — why integers don't move

Simple stack-only types (integers, floats, bool, char, and tuples containing only such types) implement the Copy trait, so assigning them duplicates the value instead of moving it:

Rust
fn main() {
    let x = 5;
    let y = x; // x is copied, not moved — both are independently valid

    println!("{x} and {y}"); // fine — no error
}

This works because these types have a known, fixed size entirely on the stack — duplicating them is cheap and has no ownership implications, unlike heap-allocated data such as String or Vec<T>.

Borrowing — using a value without taking ownership

Constantly moving ownership in and out of functions would be exhausting. Instead, Rust lets you borrow a reference to a value with &, without taking ownership of it:

Rust
fn calculate_length(s: &String) -> usize { // borrows s, doesn't own it
    s.len()
} // s goes out of scope here, but since it doesn't own the String, nothing is dropped

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1); // pass a reference — s1 is still valid afterward

    println!("The length of '{s1}' is {len}."); // s1 still usable here
}

Mutable references

To modify borrowed data, use &mut:

Rust
fn add_exclamation(s: &mut String) {
    s.push_str("!");
}

fn main() {
    let mut s = String::from("hello");
    add_exclamation(&mut s);
    println!("{s}"); // hello!
}

The borrow checker's rules

At any given point, for a particular value, Rust enforces exactly one of the following:

  • Any number of immutable references (&T), or
  • Exactly one mutable reference (&mut T)

— never both at the same time. This rule alone prevents data races at compile time: a data race requires two or more pointers accessing the same data at the same time where at least one is writing, and this rule makes that situation impossible to even compile.

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

    let r1 = &s; // fine — immutable borrow
    let r2 = &s; // fine — another immutable borrow, allowed at the same time
    println!("{r1} and {r2}");

    let r3 = &mut s; // fine — r1 and r2's last use was above, so their borrow already ended
    r3.push_str(" world");
    println!("{r3}");
}
Rust
fn main() {
    let mut s = String::from("hello");

    let r1 = &s;      // immutable borrow starts
    let r2 = &mut s;  // compile error!

    println!("{r1}"); // r1 is still in use here, so r2's mutable borrow overlaps with it
}
Plaintext
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable

Rust also guarantees references can never dangle — point at memory that's already been freed. Trying to return a reference to a local variable fails to compile, whereas the equivalent in C would silently produce a pointer to freed stack memory:

Rust
fn dangle() -> &String {      // compile error: missing lifetime specifier
    let s = String::from("hello");
    &s // s is dropped at the end of this function — this reference would dangle
} // returning &s here is rejected by the compiler, not left as a runtime bug

The fix is to return the owned String itself, transferring ownership out, instead of a reference to a value that's about to be destroyed.

Lifetimes, briefly

Every reference in Rust has a lifetime — the scope for which it's valid. Most of the time this is inferred silently, but when the compiler can't work out the relationship between input and output reference lifetimes on its own, you annotate it explicitly with a generic-looking 'a syntax:

Rust
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 s2 = String::from("short");
    println!("{}", longest(&s1, &s2)); // long string
}

'a here doesn't change how long anything actually lives — it's a promise to the compiler: "the reference this function returns will be valid for at least as long as the shorter-lived of x and y." The compiler then verifies every call site actually satisfies that promise, catching potential dangling references before the program runs. You'll see this syntax often in library code; day-to-day application code frequently avoids needing explicit lifetime annotations at all, thanks to a set of inference shortcuts called "lifetime elision."

Common mistakes

  • Trying to use a variable after it's been moved, then reaching for .clone() everywhere as a reflex fix instead of restructuring the code to borrow, which is usually both cheaper and cleaner.
  • Attempting to hold a mutable and an immutable reference to the same value at the same time — read the compiler error carefully; it names both borrow sites.
  • Trying to return a reference to a value created inside the function — the value doesn't outlive the function call, so there's nothing valid left to reference. Return the owned value instead.
  • Treating lifetime annotations as something that "makes a value live longer" — they only describe relationships that already exist; they don't extend anything's lifetime.

Interview questions

Q: What problem does Rust's ownership system solve that garbage collection and manual memory management don't? It guarantees memory safety (no use-after-free, no double-free, no data races) entirely at compile time, with zero runtime cost — no GC pause, and no reliance on programmer discipline like manual malloc/free.

Q: What happens when you assign a String to another variable in Rust — does it copy? No — it moves. Ownership of the heap data transfers to the new variable, and the original variable becomes invalid and can no longer be used. This prevents two variables from both believing they own (and later freeing) the same memory.

Q: What is the borrow checker's core rule? At any point, a value can have either any number of immutable references, or exactly one mutable reference — never both simultaneously. This is checked entirely at compile time and rules out data races by construction.