Skip to editor content
learningrust.orglesson 8 of 26

Borrowing in Depth

Building on what we learned about ownership, this lesson takes a deeper look at borrowing - one of Rust's most powerful features for writing safe, efficient code without copying data.

Why Borrowing Matters

Without borrowing, you'd need to pass ownership around constantly, which can be inconvenient:

fn main() {
    let s1 = String::from("hello");
    let (s2, len) = calculate_length(s1);
    // s1 is moved, can't use it anymore!
    println!("The length of '{}' is {}.", s2, len);
}

// Awkward: returns the string back along with the length
fn calculate_length(s: String) -> (String, usize) {
    let length = s.len();
    (s, length)
}

With borrowing, this becomes much cleaner:

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1);
    // s1 is still valid!
    println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

Immutable References

By default, references are immutable - you can read but not modify:

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

    // Create immutable reference
    let r1 = &s;
    let r2 = &s;  // Multiple immutable refs are OK

    println!("{} and {}", r1, r2);
    // r1 and r2 are no longer used after this point

    println!("Original: {}", s);  // s is still valid
}

Mutable References

You met &mut back in Ownership & Borrowing: to modify borrowed data, the reference itself must be mutable. Here we pick that up and look at what the compiler enforces around it.

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

    change(&mut s);

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

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

Note that both the binding (let mut s) and the reference (&mut s) must be marked mutable — a &mut to an immutable binding won't compile. That single restriction is what powers the rules below.

The Borrowing Rules

Rust enforces these rules at compile time:

Rule 1: One Mutable OR Many Immutable

You can have either:

  • One mutable reference, OR
  • Any number of immutable references

But never both at the same time:

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

    let r1 = &s;     // OK - first immutable ref
    let r2 = &s;     // OK - second immutable ref
    println!("{} and {}", r1, r2);
    // r1 and r2 are no longer used

    let r3 = &mut s; // OK - mutable ref (no immutable refs active)
    println!("{}", r3);
}

This prevents data races:

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

    let r1 = &s;
    // let r2 = &mut s; // ERROR! Can't have mutable while immutable exists

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

Rule 2: References Must Be Valid

References must always point to valid data (no dangling references):

// This won't compile!
// fn dangle() -> &String {
//     let s = String::from("hello");
//     &s  // ERROR: s is dropped, reference would be invalid
// }

// Instead, return the owned value:
fn no_dangle() -> String {
    let s = String::from("hello");
    s  // Ownership is moved out
}

fn main() {
    let s = no_dangle();
    println!("{}", s);
}

Non-Lexical Lifetimes (NLL)

Modern Rust uses NLL - references are considered "active" only until their last use, not until the end of scope:

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

    let r1 = &s;
    let r2 = &s;
    println!("{} and {}", r1, r2);
    // r1 and r2's last use is here ^^^

    // This works because r1 and r2 are no longer "live"
    let r3 = &mut s;
    println!("{}", r3);
}

That "last use" clause does far more work than it looks, and it is the single thing most likely to make the borrow checker feel arbitrary. In the program below, line D takes a mutable borrow of log while first and second — both shared borrows of the same log — are still in scope. Line F is commented out. Decide what this prints, and what uncommenting line F would change, before you run it.

Predict

Lines A and B take two shared borrows of log. Line D then takes a MUTABLE borrow of the same log, while first and second are both still in scope. Does this compile, what does it print, and what would uncommenting line F change?

fn main() {
  let mut log = String::from("start");

  let first = &log;                   // line A: shared borrow of log
  let second = &log;                  // line B: a second shared borrow

  println!("{} / {}", first, second); // line C: LAST use of both borrows

  log.push_str(" + appended");        // line D: mutable borrow of log

  println!("{}", log);                // line E
  // println!("{}", first);           // line F: commented out
}

It compiles, and prints start / start then start + appended. Both shared borrows are last read on line C, so under NLL they are no longer live by line D and push_str may take its mutable borrow — even though first and second are still in scope. Uncomment line F and the very same program stops compiling with error[E0502]: cannot borrow log as mutable because it is also borrowed as immutable, naming three lines: A where the shared borrow starts, D where the mutable borrow collides, and F as the "immutable borrow later used here". Carry this forward: a borrow lives until its last use, which is why a line added at the bottom of a function can break a line in the middle.

Reborrowing

You can reborrow from a mutable reference:

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

    // Reborrow: create immutable ref from mutable ref
    let r2 = &*r1;  // or just: let r2 = &r1;
    println!("{}", r2);

    // r1 is still valid after r2 is done
    r1.push_str(" world");
    println!("{}", r1);
}

Borrowing in Structs

Structs can hold references, but need lifetime annotations:

// Simple case: owned data (no lifetimes needed)
struct User {
    name: String,
    age: u32,
}

fn main() {
    let user = User {
        name: String::from("Alice"),
        age: 30,
    };
    println!("User: {}, Age: {}", user.name, user.age);
}

Borrowing Patterns

Pattern 1: Read-Only Access

fn print_info(data: &Vec<i32>) {
    for item in data {
        println!("{}", item);
    }
}

fn main() {
    let numbers = vec![1, 2, 3, 4, 5];
    print_info(&numbers);
    print_info(&numbers);  // Can borrow again
}

Pattern 2: Modify in Place

fn double_values(data: &mut Vec<i32>) {
    for item in data.iter_mut() {
        *item *= 2;
    }
}

fn main() {
    let mut numbers = vec![1, 2, 3, 4, 5];
    double_values(&mut numbers);
    println!("{:?}", numbers);  // [2, 4, 6, 8, 10]
}

Pattern 3: Split Borrowing

You can borrow different parts of a struct simultaneously:

struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let mut point = Point { x: 0, y: 0 };

    let x_ref = &mut point.x;
    let y_ref = &mut point.y;  // OK! Different fields

    *x_ref = 10;
    *y_ref = 20;

    println!("Point: ({}, {})", point.x, point.y);
}

Common Borrowing Errors

Error: Borrowed Value Moved

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

    // let s2 = s;  // ERROR: can't move while borrowed

    println!("{}", r);  // r still in use
}

Error: Mutable Borrow While Immutable Exists

fn main() {
    let mut v = vec![1, 2, 3];
    let first = &v[0];

    // v.push(4);  // ERROR: can't mutate while immutably borrowed

    println!("First: {}", first);
}

That commented-out push is the most common way this rule bites in real code, and it looks entirely innocent: hold on to an element, then append. The next program does exactly that and refuses to build. It is the NLL rule from earlier with real consequences — work out which line extends the shared borrow past the push, and commit that hypothesis before changing anything.

Debug

This program remembers the first sensor reading, appends a late one, and then reports both. It refuses to compile. The compiler names three lines — identify which one is actually keeping the shared borrow alive, then fix it so the program prints its report and 'All checks passed.'

fn main() {
  let mut readings = vec![12, 7, 19, 4];

  // Remember the first reading so we can report it at the end.
  let baseline = &readings[0];

  // A late reading arrives and gets appended.
  readings.push(23);

  let last = readings[readings.len() - 1];
  println!("baseline {} -> latest {}", baseline, last);

  assert_eq!(*baseline, 12, "baseline should still be the first reading");
  assert_eq!(readings.len(), 5, "the late reading should have been appended");
  println!("All checks passed.");
}

Expected output: baseline 12 -> latest 23 All checks passed.

The bug is that baseline holds a reference into the vector, and the println! below the push keeps that reference alive across it — so the shared borrow and push's mutable borrow overlap, and you get error[E0502]. The reason is not bureaucratic: push may outgrow the current buffer and reallocate, moving every element, which would leave baseline pointing at freed memory. The fix is to take a copy rather than a view — let baseline = readings[0]; with no ampersand, and assert_eq!(baseline, 12, ...) with no dereference. That works because i32 is Copy; for a Vec<String> you would write readings[0].clone() and pay for the copy on purpose. This is the general escape hatch when a borrow and a mutation want to overlap: stop borrowing, start owning.

Recall

Without scrolling up: in Ownership & Borrowing you learned that handing a String to a function that takes it by value MOVES it, and the caller can no longer use it. Now compare that with what this lesson is about. Which statement correctly separates a move error from a borrow error?

Both errors get blamed on a line below the one that caused them, which is why they blur together — but they are different failures with different fixes. A move error (E0382) means the value left: one name gave it away and another tried to read it afterwards; you fix it by lending with & instead of giving. A borrow error (E0502, E0499) means the value never went anywhere — the owner still owns it — and the only problem is that two views were live at overlapping times; you fix it by ending one live range sooner or copying the value out. The error code alone tells you which situation you are in.

Practice Exercise

Reading the borrowing rules is not the same as picking the right reference under your own name. This is a build task: a small program that reports its own pass/fail. One Vec<i32> lives in main and never leaves it; three helper functions have to work on it without taking it away. Run the starter as-is and it fails immediately, naming the first check that did not pass. Implement each function until every check passes and it prints All checks passed.

The three signatures are the lesson in miniature: two take &Vec<i32> (lend, read only) and one takes &mut Vec<i32> (lend, and allow changes in place). Watch the second check especially — it asserts that main still owns its vector after the first call, which is precisely what a by-value parameter would silently destroy.

Build

Finish the build. Three functions are stubbed out and the checks below them fail until each one behaves. Run it as-is to see which check fails first, decide what that function is missing, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down. Do not change any of the three signatures: the whole point is that none of them takes ownership.

// A tiny reading-log toolkit. NONE of these functions may take ownership:
// main declares the log once and must still own it on the last line.

// TODO 1: return the largest reading, WITHOUT taking ownership of readings.
//   peak(&vec![3, 9, 4]) -> 9. The caller promises the log is never empty.
//   Hint: iter() then copied() gives i32 values; max() gives an Option.
fn peak(readings: &Vec<i32>) -> i32 {
  let _ = readings;
  0
}

// TODO 2: add amount to EVERY reading, IN PLACE, through the mutable borrow.
//   calibrate(&mut v, 3) leaves the caller's v with every element 3 higher.
//   Hint: iter_mut() hands you a &mut i32 per element; *item += amount.
fn calibrate(readings: &mut Vec<i32>, amount: i32) {
  let _ = readings;
  let _ = amount;
}

// TODO 3: return a NEW Vec holding only the readings at or above floor,
//   in their original order, leaving readings untouched.
//   above(&vec![1, 5, 9], 5) -> vec![5, 9]
fn above(readings: &Vec<i32>, floor: i32) -> Vec<i32> {
  let _ = readings;
  let _ = floor;
  Vec::new()
}

// --- Build checks: these must all pass. Do not edit below this line. ---
fn main() {
  let mut readings = vec![12, 7, 19, 4];

  assert_eq!(peak(&readings), 19, "peak should find the largest reading");
  assert_eq!(readings.len(), 4, "peak must BORROW: main still owns readings");

  calibrate(&mut readings, 3);
  assert_eq!(readings, vec![15, 10, 22, 7], "calibrate should shift every reading in place");

  let high = above(&readings, 15);
  assert_eq!(high, vec![15, 22], "above should keep only readings >= 15");
  assert_eq!(readings, vec![15, 10, 22, 7], "above must not disturb the original");

  println!("All checks passed.");
  println!("Readings: {:?}", readings);
  println!("Peak: {}", peak(&readings));
  println!("High: {:?}", high);
}

Expected output: All checks passed. Readings: [15, 10, 22, 7] Peak: 22 High: [15, 22]

Once it passes, try two variations and predict each before running:

  1. Hold a shared borrow across the mutation. Add let first = &readings[0]; immediately before the first assert_eq!, and add println!("first was {}", first); as the very last line of main. Predict which check breaks before running. None of them do — it never gets that far. Compilation fails with error[E0502]: cannot borrow readings as mutable because it is also borrowed as immutable, naming the &readings[0] line, the calibrate(&mut readings, 3) line, and your new println! as "immutable borrow later used here". Delete just that final println! and it compiles again: the borrow's last use is what stretched it across calibrate, exactly as the NLL section showed.
  2. Take two overlapping borrows of opposite kinds. Add let scratch = &mut readings; just before the let high = above(&readings, 15); line, and scratch.push(0); just after it. Decide what the compiler says before running. You get the mirror image of variation 1 — error[E0502]: cannot borrow readings as immutable because it is also borrowed as mutable — because the &mut came first this time. The rule is symmetric: it does not care which kind of borrow arrives first, only that a mutable one and any other one are live at the same moment.

Key Takeaways

  • Borrowing lets you use data without taking ownership
  • &T creates an immutable reference (read-only)
  • &mut T creates a mutable reference (read-write)
  • You can have many &T OR one &mut T, never both
  • References must always point to valid data
  • NLL makes the borrow checker smarter about when refs are "live"
  • Understanding borrowing is essential for writing idiomatic Rust

Master borrowing and you'll write safe, efficient Rust code!

Next Steps

With borrowing mastered, you're ready for closures — anonymous functions that capture variables from their environment. Because closures borrow (or move) the values they capture, the borrowing rules you just learned are exactly what govern how they behave.

Next lesson

Closures

Rust closures tutorial — learn how closures capture variables, understand Fn, FnMut, and FnOnce traits, and use closures with iterators

25 min