Skip to main content
Rust · lesson 9 of 30
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.

By SkillByExample · Updated

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?”

Practice

Each task gives you a program that fails, either at compile time or at run time. Predict the error, then make it work without removing any of the output it was meant to print.

  1. Make this compile by moving one line.

    fn main() {
        let mut s = String::from("hello");
    
        let r1 = &s;
        s.push_str(", world");
        println!("r1 = {}", r1);
        println!("s  = {}", s);
    }
Solution
fn main() {
    let mut s = String::from("hello");

    let r1 = &s;
    println!("r1 = {}", r1); // last use of r1: the shared borrow ends here
    s.push_str(", world");   // now the mutable borrow is allowed
    println!("s  = {}", s);
}
$ rustc reorder.rs
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> reorder.rs:5:5
  |
4 |     let r1 = &s;
  |              -- immutable borrow occurs here
5 |     s.push_str(", world");
  |     ^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
6 |     println!("r1 = {}", r1);
  |                         -- immutable borrow later used here

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0502`.
$ rustc reorder_fixed.rs && ./reorder_fixed
r1 = hello
s  = hello, world

push_str takes &mut self, so line 5 needs a mutable borrow while r1 is still live. The error marks all three points: where the shared borrow starts, where the conflict happens, and the later use that keeps the borrow alive. Moving that use above push_str lets NLL end the borrow early.

  1. Uncomment the clear(). In the slices example, declare sentence with let mut (since clear needs it) and uncomment sentence.clear();, then compile. Fix it so the program prints the first word and clears the sentence.
Solution
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];
        }
    }
    s
}

fn main() {
    let mut sentence = String::from("hello world");
    let word = first_word(&sentence);
    println!("first word: {}", word); // last use of word: the borrow ends here
    sentence.clear();
    println!("sentence is now {:?}", sentence);
}
$ rustc clear.rs
error[E0502]: cannot borrow `sentence` as mutable because it is also borrowed as immutable
  --> clear.rs:14:5
   |
13 |     let word = first_word(&sentence);
   |                           --------- immutable borrow occurs here
14 |     sentence.clear();
   |     ^^^^^^^^^^^^^^^^ mutable borrow occurs here
15 |     println!("first word: {}", word);
   |                                ---- immutable borrow later used here

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0502`.
$ rustc clear_fixed.rs && ./clear_fixed
first word: hello
sentence is now ""

word points into sentence’s buffer. If clear() were allowed to run first, word would refer to bytes that no longer hold hello. That is exactly the bug a C programmer would ship, and here it is rejected at compile time. Using word before mutating is the fix.

  1. Return a reference to a local. Uncomment dangle and call it from main. Read what the compiler suggests, and pick the right suggestion.

    fn dangle() -> &String {
        let s = String::from("hello");
        &s
    }
    
    fn main() {
        let r = dangle();
        println!("{}", r);
    }
Solution
fn no_dangle() -> String {
    let s = String::from("hello");
    s
}

fn main() {
    let r = no_dangle();
    println!("{}", r);
}
$ rustc dangle.rs
error[E0106]: missing lifetime specifier
 --> dangle.rs:1:16
  |
1 | fn dangle() -> &String {
  |                ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime, but this is uncommon unless you're returning a borrowed value from a `const` or a `static`
  |
1 | fn dangle() -> &'static String {
  |                 +++++++
help: instead, you are more likely to want to return an owned value
  |
1 - fn dangle() -> &String {
1 + fn dangle() -> String {
  |

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0106`.
$ rustc dangle_fixed.rs && ./dangle_fixed
hello

The function takes no reference parameters, so there is nothing the returned reference could borrow from. That is what “no value for it to be borrowed from” means. The 'static suggestion would only move the error elsewhere, because s is still dropped at the closing brace. Returning the owned String is the real fix.

  1. Use a value after consume. This program reads the counter after handing it to consume. Make it compile while still printing both lines.

    struct Counter {
        value: i32,
    }
    
    impl Counter {
        fn get(&self) -> i32 { self.value }
        fn increment(&mut self) { self.value += 1; }
        fn consume(self) -> i32 { self.value }
    }
    
    fn main() {
        let mut c = Counter { value: 10 };
        c.increment();
        let v = c.consume();
        println!("consumed {}", v);
        println!("still there? {}", c.get());
    }
Solution
struct Counter {
    value: i32,
}

impl Counter {
    fn get(&self) -> i32 { self.value }
    fn increment(&mut self) { self.value += 1; }
    fn consume(self) -> i32 { self.value }
}

fn main() {
    let mut c = Counter { value: 10 };
    c.increment();
    println!("still there? {}", c.get()); // read while c is still owned here
    let v = c.consume();                   // c moves into consume; no uses after this
    println!("consumed {}", v);
}
$ rustc consume.rs
error[E0382]: borrow of moved value: `c`
  --> consume.rs:16:33
   |
12 |     let mut c = Counter { value: 10 };
   |         ----- move occurs because `c` has type `Counter`, which does not implement the `Copy` trait
13 |     c.increment();
14 |     let v = c.consume();
   |               --------- `c` moved due to this method call
15 |     println!("consumed {}", v);
16 |     println!("still there? {}", c.get());
   |                                 ^ value borrowed here after move
   |
note: `Counter::consume` takes ownership of the receiver `self`, which moves `c`
  --> consume.rs:8:16
   |
 8 |     fn consume(self) -> i32 { self.value }
   |                ^^^^

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0382`.
$ rustc consume_fixed.rs && ./consume_fixed
still there? 11
consumed 11

The note points straight at the receiver: self without & takes ownership, so c is gone after line 14. get takes &self, so it has to borrow before the move, not after. Read through &self or &mut self first and consume last.

  1. Panic a RefCell at run time. This version takes out the drop(r1) line from the RefCell example. It compiles. Run it, then fix it without calling drop.

    use std::cell::RefCell;
    
    fn main() {
        let data = RefCell::new(vec![1, 2, 3]);
    
        let r1 = data.borrow();
        println!("{:?}", *r1);
    
        data.borrow_mut().push(4);
        println!("{:?}", data.borrow());
    }
Solution
use std::cell::RefCell;

fn main() {
    let data = RefCell::new(vec![1, 2, 3]);

    {
        let r1 = data.borrow();
        println!("{:?}", *r1);
    } // r1 goes out of scope here, releasing the shared borrow

    data.borrow_mut().push(4);
    println!("{:?}", data.borrow());
}
$ rustc refcell.rs && ./refcell
[1, 2, 3]

thread 'main' (80) panicked at refcell.rs:9:10:
RefCell already borrowed
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
$ rustc refcell_fixed.rs && ./refcell_fixed
[1, 2, 3]
[1, 2, 3, 4]

NLL doesn’t help here. r1 is a guard object, and it holds its runtime borrow until it is dropped at the end of its scope, not at its last use. So borrow_mut on line 9 found a live shared borrow and panicked. An inner block ends the guard’s life just as drop(r1) did. This is the cost of RefCell: the same rule as before, but you find out when the program runs instead of when it compiles.

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.