Rust Ownership and Borrowing Explained

Last updated: April 13, 2025

1. Introduction: Memory Safety Without a GC

One of Rust's most defining and powerful features is its approach to memory management. Unlike languages like C/C++ which require manual memory management (malloc/free), or languages like Java/Python/Go which rely on a garbage collector (GC) to clean up unused memory, Rust employs a system based on Ownership, Borrowing, and Lifetimes.

This system enforces memory safety rules at compile time. This means common memory errors like dangling pointers, double frees, and data races are caught by the compiler before your code even runs, all without the runtime overhead of a garbage collector. Understanding these concepts is fundamental to writing effective and idiomatic Rust code.

2. Stack vs. Heap

To understand ownership, it helps to know briefly where data is stored:

  • Stack: Fast memory allocation/deallocation (LIFO - Last In, First Out). Used for data with a known, fixed size at compile time (like integers, booleans, function call frames). Allocation is very fast.
  • Heap: Used for data whose size might be unknown at compile time or might change (like strings that grow, vectors). Allocation involves finding space and returning a pointer, which is slower than stack allocation. Accessing data on the heap requires following a pointer.

Rust's ownership system is primarily concerned with managing heap data, ensuring it's cleaned up correctly when no longer needed.

3. Ownership Rules

Ownership is governed by three core rules enforced by the Rust compiler:

3.1 Rule 1: Each Value Has an Owner

Every value in Rust has a variable that's called its *owner*.

let s = String::from("hello"); // `s` is the owner of the String value "hello"

3.2 Rule 2: Only One Owner at a Time

There can only be one owner for a particular value at any given time.

3.3 Rule 3: Owner Goes Out of Scope, Value is Dropped

When the owner variable goes out of scope (e.g., at the end of a function or block), the value it owns is automatically dropped. Dropping involves deallocating the memory associated with the value (e.g., freeing heap memory for a String).

{
    let s = String::from("scope example"); // s is valid from this point forward
    // do stuff with s
} // this scope is now over, and s is no longer valid. Rust calls `drop` for s here.

3.4 Move Semantics

When you assign a heap-allocated value (like a String or Vec) from one variable to another, or pass it to a function, ownership is moved. The original variable becomes invalid to prevent double-free errors.

let s1 = String::from("hello");
let s2 = s1; // Ownership of the String data is MOVED from s1 to s2

// println!("s1 is: {}", s1); // Error! `s1` is no longer valid because ownership moved to `s2`.
println!("s2 is: {}", s2); // OK

This is different from a "shallow copy" in other languages. Rust invalidates the original owner.

3.5 The `Copy` Trait

Simple types stored entirely on the stack (like integers, booleans, floats, chars, tuples containing only Copy types) implement the Copy trait. When assigned, their values are simply copied, and the original variable remains valid. There's no ownership transfer or "move".

let x = 5; // i32 implements Copy
let y = x; // Value is copied

println!("x = {}, y = {}", x, y); // Both x and y are valid and equal 5

3.6 Ownership and Functions

Passing a value to a function follows the same move/copy rules:

fn takes_ownership(some_string: String) { // some_string comes into scope
    println!("{}", some_string);
} // Here, some_string goes out of scope and `drop` is called. Memory is freed.

fn makes_copy(some_integer: i32) { // some_integer comes into scope
    println!("{}", some_integer);
} // Here, some_integer goes out of scope. Nothing special happens.

fn main() {
    let s = String::from("hello");
    takes_ownership(s); // s's value moves into the function...
                        // ... and so is no longer valid here.
    // println!("{}", s); // Error! Value borrowed here after move

    let x = 5;
    makes_copy(x);      // x would move, but i32 is Copy, so it's okay to still
                        // use x afterward.
    println!("{}", x); // OK
}

Functions can also return values, transferring ownership back to the calling scope.

4. References and Borrowing

Moving ownership all the time can be inconvenient. What if you want a function to use a value *without* taking ownership?

This is achieved through references. A reference allows you to refer to a value without taking ownership of it. This is also called borrowing.

4.1 Immutable Borrows (&T)

You can create immutable references using the & operator. These allow read-only access to the data.

fn calculate_length(s: &String) -> usize { // s is a reference to a String
    s.len()
} // Here, s goes out of scope. But because it does not have ownership of what
  // it refers to, nothing happens.

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1); // Pass an immutable reference to s1

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

The calculate_length function borrows s1 immutably. It can read s1 but not modify it. Ownership remains with s1 in main.

4.2 Mutable Borrows (&mut T)

You can create mutable references using &mut. These allow modifying the borrowed data.

fn change(some_string: &mut String) { // Takes a mutable reference
    some_string.push_str(", world");
}

fn main() {
    let mut s = String::from("hello"); // s must be mutable to borrow mutably

    change(&mut s); // Pass a mutable reference

    println!("{}", s); // Output: hello, world
}

4.3 Borrowing Rules

The compiler enforces strict rules about borrowing at compile time to prevent data races:

  1. Rule 1: At any given time, you can have EITHER:
    • One mutable reference (&mut T).
    • Any number of immutable references (&T).
  2. Rule 2: References must always be valid (they cannot outlive the data they point to - this is where lifetimes come in).

These rules prevent situations where data could be changed while others are reading it, or where multiple actors try to change data simultaneously.

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

    let r1 = &s; // OK - immutable borrow
    let r2 = &s; // OK - another immutable borrow
    println!("{}, {}", r1, r2); // Use r1 and r2

    // At this point, r1 and r2 are no longer used (their scope ends implicitly)

    let r3 = &mut s; // OK - no other borrows active
    println!("{}", r3);

    // let r4 = &s; // Error! Cannot immutably borrow while mutably borrowed by r3
    // let r5 = &mut s; // Error! Cannot mutably borrow again while already mutably borrowed by r3
}

4.4 Preventing Dangling References

The borrow checker also ensures references don't point to memory that has been deallocated (dangling references). Rust prevents this at compile time.

/*
// This code won't compile!
fn dangle() -> &String { // dangle returns a reference to a String
    let s = String::from("hello"); // s is a new String

    &s // we return a reference to the String, s
} // Here, s goes out of scope, and is dropped. Its memory goes away.
  // Danger! The reference returned points to invalid memory.

fn main() {
    let reference_to_nothing = dangle();
}
*/

Rust's compiler will reject the dangle function because the reference &s would outlive the value s it points to.

5. Lifetimes: Ensuring References are Valid

Lifetimes are the mechanism the borrow checker uses to ensure references remain valid. For many simple cases, the compiler can infer lifetimes automatically (this is called lifetime elision). However, sometimes you need to help the compiler by adding explicit lifetime annotations.

Lifetimes describe the scope for which a reference is valid. They don't change how long a value lives; they describe the relationship between the lifetimes of references and the values they point to.

5.1 Lifetime Annotations

Lifetime annotations use an apostrophe followed by a name (usually lowercase, often starting with 'a, 'b). They are added to reference types.

&'a i32     // A reference to an i32 with lifetime 'a
&'a mut i32 // A mutable reference to an i32 with lifetime 'a

5.2 Lifetimes in Function Signatures

When function parameters or return values are references, you sometimes need lifetime annotations to connect their validity scopes.

Consider a function that returns the longer of two string slices:

// The generic lifetime 'a defines a relationship between the lifetimes
// of the input references (x, y) and the returned reference.
// It says the returned reference will be valid for at least as long as
// the SHORTER of the lifetimes 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 string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
        // result is valid here because both string1 and string2 are valid.
         println!("The longest string is {}", result);
    } // string2 goes out of scope here.
    // println!("The longest string is {}", result); // Error! `result` may refer to `string2`, which is no longer valid.
                     // The lifetime 'a was constrained by the shorter lifetime of string2.
}

Lifetime annotations ensure the returned reference doesn't outlive the data it points to.

5.3 Lifetime Elision Rules

The compiler applies common-sense rules (elision rules) to infer lifetimes in many situations, reducing the need for explicit annotations. For example:

  • Each input parameter that is a reference gets its own lifetime.
  • If there is exactly one input lifetime parameter, that lifetime is assigned to all output lifetime parameters.
  • If there are multiple input lifetime parameters, but one of them is &self or &mut self (for methods), the lifetime of self is assigned to all output lifetime parameters.

These rules cover many common function patterns.

5.4 The Static Lifetime

The special lifetime 'static indicates that a reference can live for the entire duration of the program. String literals ("hello") have a 'static lifetime because their text is stored directly in the program's binary.

6. Conclusion: The Power of Compile-Time Checks

Rust's Ownership, Borrowing, and Lifetime system is a cornerstone of the language, providing compile-time memory safety guarantees without the performance cost of garbage collection. While it introduces a learning curve, mastering these concepts leads to highly reliable and performant code.

  • Ownership: Dictates who is responsible for cleaning up data.
  • Borrowing: Allows accessing data without taking ownership, via immutable (&) or mutable (&mut) references.
  • Lifetimes: Ensure that references never point to invalid memory.

By understanding and working with these rules, you leverage the Rust compiler to prevent entire classes of bugs before your program even runs.

7. Additional Resources

Related Articles

External Resources