Skip to main content
Rust beginner Lesson 9 of 30

Borrowing and References

Learn how Rust's borrow checker works — shared references, mutable references, and the rules that prevent data races.

Why Borrowing?

Moving ownership into every function and moving it back out again is tedious and impractical for real programs. Borrowing solves this by letting you pass a reference to a value — a pointer that grants temporary access without transferring ownership. The original owner retains ownership and the value is not dropped when the reference goes away. This is the mechanism that makes Rust’s ownership system actually usable in practice.

fn calculate_length(s: &String) -> usize {
    s.len()
} // s is a reference — it does not own the String, so nothing is dropped here

fn main() {
    let s = String::from("hello world");
    let len = calculate_length(&s); // pass a reference with &
    println!("'{}' has {} bytes", s, len); // s is still valid — we only lent it
}

The & in &String creates a shared reference. The function borrows the value without taking ownership.

Shared References &T

Shared references are read-only. Their key property is that you can have any number of shared references to the same value simultaneously, because none of them can modify the data. This maps directly to a principle from concurrent programming: multiple readers are always safe.

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

    // All three references coexist peacefully — none can mutate s
    let r1 = &s;
    let r2 = &s;
    let r3 = &s;

    println!("{} {} {}", r1, r2, r3); // all three are valid
}

Shared references implement Copy, so passing &T to a function does not move the reference — the original binding remains valid.

Mutable References &mut T

To modify data through a reference, you need a mutable reference &mut T. Mutable references grant exclusive write access: while a mutable reference exists, no other references to the same data — shared or mutable — may exist. This exclusivity is what allows the compiler to prove at compile time that no data races are possible.

fn append_world(s: &mut String) {
    s.push_str(", world"); // can modify because we have a mutable reference
}

fn main() {
    let mut s = String::from("hello"); // the variable itself must be mut
    append_world(&mut s);             // pass a mutable reference with &mut
    println!("{}", s); // hello, world
}

Both the variable and the reference must be marked mut.

The Borrow Rules

The compiler enforces two rules that together prevent data races and dangling pointers. These rules are what the borrow checker exists to enforce. When you understand them, borrow checker errors become straightforward to diagnose and fix.

Rule 1: At any given time, you can have either:

  • Any number of shared references (&T), or
  • Exactly one mutable reference (&mut T) — with no shared references at the same time

Rule 2: References must always be valid — you cannot have a reference to data that has been freed (no dangling references).

Violating Rule 1 — mixing shared and mutable borrows:

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

    let r1 = &s;     // shared borrow begins — OK
    let r2 = &s;     // second shared borrow — OK, multiple readers allowed
    // let r3 = &mut s; // ERROR: cannot borrow mutably while shared borrows exist

    println!("{} {}", r1, r2);
}

Violating Rule 1 — two mutable borrows at the same time:

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

    let r1 = &mut s;  // mutable borrow — exclusive access
    // let r2 = &mut s; // ERROR: cannot have two mutable borrows simultaneously

    println!("{}", r1);
}

Non-Lexical Lifetimes (NLL)

Before Rust 2018, borrows lasted until the end of the enclosing block regardless of where the reference was last used. Non-Lexical Lifetimes (NLL) changed this: a borrow now ends at the last point where the reference is used, not at the closing brace. This makes many patterns that previously looked like borrow conflicts work correctly.

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

    let r1 = &s;
    let r2 = &s;
    println!("{} {}", r1, r2);
    // r1 and r2 are not used after this line — their borrows end here (NLL)

    let r3 = &mut s; // OK under NLL — the shared borrows have already ended
    r3.push_str("!");
    println!("{}", r3);
}

Without NLL this would have been an error because r1, r2, and r3 are all lexically in scope until the closing brace.

Dangling References

The compiler’s second borrow rule — references must always be valid — prevents dangling pointers entirely. If you try to return a reference to local data that will be freed when the function returns, the compiler rejects the program.

// fn dangle() -> &String { // ERROR: missing lifetime specifier
//     let s = String::from("hello");
//     &s // s is dropped at the end of this function — this would be a dangling pointer!
// }

The fix is to return the String itself — transfer ownership to the caller instead of lending a reference:

fn no_dangle() -> String {
    let s = String::from("hello");
    s // ownership moves to the caller — no reference, no dangling pointer problem
}

Slices — Borrowing a Contiguous Sequence

Slices are references to a contiguous portion of a collection. They borrow a sub-range of the data without copying it, and the borrow rules apply: you cannot mutate the original collection while a slice borrowing it exists.

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

fn main() {
    let sentence = String::from("hello world");
    let word = first_word(&sentence); // word borrows part of sentence
    // sentence.clear(); // ERROR: cannot mutate while word (a borrow) is alive
    println!("first word: {}", word); // hello
}

String slices (&str) are particularly common. &str is always a borrowed slice of UTF-8 string data — it can point into a String, into a string literal in the binary, or into any other string storage.

Method Receivers

The same borrow rules apply to method calls. The self parameter in an impl block takes one of three forms, each with different ownership semantics:

struct Counter {
    value: i32,
}

impl Counter {
    fn get(&self) -> i32 {         // shared borrow — read-only, does not consume
        self.value
    }

    fn increment(&mut self) {      // mutable borrow — can modify, does not consume
        self.value += 1;
    }

    fn consume(self) -> i32 {      // takes ownership — Counter is consumed (dropped after)
        self.value
    }
}

fn main() {
    let mut c = Counter { value: 10 };
    println!("{}", c.get());   // 10 — shared borrow, c still usable
    c.increment();             // mutable borrow, c still usable after
    println!("{}", c.get());   // 11
    let v = c.consume();       // c is moved into consume — c is no longer valid
    // println!("{}", c.get()); // ERROR: c was moved
    println!("{}", v);         // 11
}

Interior Mutability with Cell and RefCell

Sometimes the borrow rules are too conservative for a pattern you need — for example, mutating data through a shared reference in a single-threaded setting. Rust provides interior mutability types that move the borrow checks from compile time to runtime, panicking if you violate them instead of failing to compile.

use std::cell::RefCell;

fn main() {
    // RefCell<T> wraps a value and tracks borrows at runtime
    let data = RefCell::new(vec![1, 2, 3]);

    let r1 = data.borrow();       // runtime shared borrow — like &T
    println!("{:?}", *r1);        // [1, 2, 3]
    drop(r1);                     // release the borrow explicitly before mutating

    data.borrow_mut().push(4);    // runtime mutable borrow — like &mut T
    println!("{:?}", data.borrow()); // [1, 2, 3, 4]
}

RefCell moves the borrow checks from compile time to runtime — it panics if you violate the rules at runtime. Use it sparingly, only when the compile-time rules genuinely cannot model your access pattern.

Summary of Borrow Rules

Any number of &T  (shared)      — at the same time, OR
Exactly one &mut T (exclusive)  — no shared borrows at the same time

References must never outlive the data they point to.

These two rules are what the borrow checker enforces. When you see a borrow checker error, ask: “Am I trying to have a mutable reference while a shared one exists?” or “Am I returning a reference to local data?”

Frequently Asked Questions

What is the difference between &T and &mut T?
&T is a shared (immutable) reference — many can exist simultaneously. &mut T is an exclusive (mutable) reference — only one can exist at a time, and no shared references may exist concurrently.
What is the borrow checker?
The borrow checker is the part of the Rust compiler that enforces reference rules at compile time, ensuring no dangling pointers or data races.
What is NLL?
Non-Lexical Lifetimes (NLL) is a borrow checker improvement introduced in Rust 2018. It ends a borrow as soon as the reference is last used, rather than at the end of the enclosing block.