AL.
🇪🇸 ES
Back to blog
Tooling · 10 min read

Borrowing in Rust: How It Works Under the Hood and Why It Matters

Understand Rust borrowing, ownership, and the borrow checker with examples of immutable vs mutable references and memory safety.


If you’re coming from languages like JavaScript, Python, or even C++, Rust’s borrowing system can feel like hitting a wall. The compiler yells at you about lifetimes, mutable references, and ownership, and you wonder why something so simple in other languages requires so much ceremony in Rust.

But here’s the thing: Rust’s borrowing system is what makes it special. It’s the mechanism that enables memory safety without garbage collection, prevents data races at compile time, and eliminates entire classes of bugs that plague other systems languages.

In this article, we’ll dive deep into Rust’s borrowing system, understand how it works under the hood, and see why it matters for writing safe, concurrent code.

A Quick Ownership Recap

Before we talk about borrowing, let’s recap Rust’s ownership model:

Three ownership rules:

  1. Each value in Rust has an owner
  2. There can only be one owner at a time
  3. When the owner goes out of scope, the value is dropped
fn main() {
    let s1 = String::from("hello");
    let s2 = s1; // s1's ownership moves to s2

    // println!("{}", s1); // Error: s1 is no longer valid
    println!("{}", s2); // OK
}

The problem with ownership alone is that it’s too restrictive. If every function call transferred ownership, you’d have to return values just to give ownership back:

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len();
    (s, length) // Return ownership
}

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

This is verbose and impractical. Enter borrowing.

What Is Borrowing?

Borrowing lets you reference a value without taking ownership. Think of it like borrowing a book from a library: you can read it, but you don’t own it, and you have to return it.

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

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

The & symbol creates a reference. The parameter s: &String means “a reference to a String.” This is an immutable reference.

Immutable References (&T)

By default, references in Rust are immutable. You can read the data, but you can’t modify it:

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

    println!("{} and {}", r1, r2); // OK: multiple immutable references
}

You can have any number of immutable references to a value.

Why? Because if no one can modify the data, it’s perfectly safe for multiple parts of the code to read it simultaneously.

But try to modify it:

fn change(s: &String) {
    s.push_str(", world"); // Error: cannot borrow as mutable
}

The compiler rejects this because s is an immutable reference.

Mutable References (&mut T)

To modify borrowed data, you need a mutable reference:

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

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

Note two things:

  1. The variable s must be declared mut
  2. We pass &mut s and the function takes &mut String

Here’s where Rust gets strict.

The Borrow Checker Rules

The borrow checker enforces these rules at compile time:

Rule 1: You can have either (but not both):

  • One mutable reference
  • Any number of immutable references

Rule 2: References must always be valid (no dangling references)

Let’s see what this means in practice.

No Multiple Mutable References

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

    let r1 = &mut s;
    let r2 = &mut s; // Error: cannot borrow `s` as mutable more than once

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

Why? Because mutable aliasing is the root of data races. If two parts of the code could modify the same data simultaneously, chaos ensues.

The restriction prevents this entirely at compile time.

No Mixing Mutable and Immutable References

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

    let r1 = &s;     // OK
    let r2 = &s;     // OK
    let r3 = &mut s; // Error: cannot borrow as mutable because also borrowed as immutable

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

Why? If you have an immutable reference, you expect the data not to change. Allowing a mutable reference would violate that expectation.

Scope Matters: Non-Lexical Lifetimes (NLL)

Since Rust 2018, the borrow checker uses non-lexical lifetimes. References are only considered active until their last use, not until the end of their scope:

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

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

    let r3 = &mut s; // OK: no conflict
    println!("{}", r3);
}

This compiles because r1 and r2 aren’t used after println!, so the borrow checker knows they’re no longer active when r3 is created.

Lifetimes: The Basics

Lifetimes are Rust’s way of tracking how long references are valid. Most of the time, the compiler infers them, but sometimes you need to annotate them explicitly.

Every reference has a lifetime, even if it’s implicit:

fn main() {
    let r;
    {
        let x = 5;
        r = &x; // Error: `x` does not live long enough
    }
    println!("{}", r);
}

The compiler rejects this because x goes out of scope before r is used, creating a dangling reference.

Explicit Lifetime Annotations

When a function returns a reference, Rust needs to know the relationship between input and output lifetimes:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

The 'a lifetime annotation means “the returned reference will live as long as the shortest-lived input reference.”

fn main() {
    let string1 = String::from("long string");
    let result;
    {
        let string2 = String::from("short");
        result = longest(string1.as_str(), string2.as_str());
        println!("{}", result); // OK: result used before string2 is dropped
    }
    // println!("{}", result); // Error: string2 is out of scope
}

Common Borrowing Patterns

Pattern 1: Borrowing in Loops

fn main() {
    let mut numbers = vec![1, 2, 3, 4, 5];

    for n in &numbers {
        println!("{}", n); // Immutable borrow
    }

    for n in &mut numbers {
        *n *= 2; // Mutable borrow, dereference with *
    }

    println!("{:?}", numbers); // Prints [2, 4, 6, 8, 10]
}

Pattern 2: Method Calls with Self Borrowing

struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 { // Immutable borrow
        self.width * self.height
    }

    fn set_width(&mut self, width: u32) { // Mutable borrow
        self.width = width;
    }

    fn consume(self) -> u32 { // Takes ownership
        self.width * self.height
    }
}

fn main() {
    let mut rect = Rectangle { width: 30, height: 50 };
    println!("Area: {}", rect.area());
    rect.set_width(40);
    // let area = rect.consume(); // rect is moved here
    // println!("{}", rect.width); // Error: rect was moved
}

Pattern 3: Splitting Borrows

You can borrow different parts of a struct mutably at the same time:

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

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

    let x_ref = &mut p.x;
    let y_ref = &mut p.y;

    *x_ref += 1;
    *y_ref += 2;

    println!("{}, {}", p.x, p.y); // Prints 1, 2
}

This works because the borrow checker knows p.x and p.y are separate fields.

Common Pitfalls and How to Fix Them

Pitfall 1: Borrowing While Mutating a Collection

fn main() {
    let mut v = vec![1, 2, 3, 4, 5];

    let first = &v[0]; // Immutable borrow

    v.push(6); // Error: cannot borrow as mutable because also borrowed as immutable

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

Why it fails: v.push(6) might reallocate the vector’s memory, invalidating first.

Fix: Don’t hold the reference across the mutation:

fn main() {
    let mut v = vec![1, 2, 3, 4, 5];

    {
        let first = &v[0];
        println!("{}", first);
    } // first goes out of scope

    v.push(6); // OK now
}

Pitfall 2: Iterator Invalidation

fn main() {
    let mut v = vec![1, 2, 3, 4, 5];

    for i in &v {
        v.push(*i); // Error: cannot borrow as mutable
    }
}

Fix: Collect what you need first:

fn main() {
    let mut v = vec![1, 2, 3, 4, 5];
    let to_add: Vec<i32> = v.iter().copied().collect();

    for i in to_add {
        v.push(i);
    }
}

Pitfall 3: Returning References to Local Variables

fn dangle() -> &String { // Error: missing lifetime specifier
    let s = String::from("hello");
    &s // s is dropped here, reference would be invalid
}

Fix: Return the owned value:

fn no_dangle() -> String {
    let s = String::from("hello");
    s // Ownership is moved to the caller
}

How Borrowing Prevents Data Races

A data race occurs when:

  1. Two or more threads access the same memory
  2. At least one access is a write
  3. There’s no synchronization

Rust’s borrowing rules prevent this at compile time:

use std::thread;

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

    thread::spawn(|| {
        data.push(4); // Error: closure may outlive the current function
    });
}

The compiler rejects this because data is borrowed by the closure, but the main thread could also access it.

Fix with ownership transfer:

use std::thread;

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

    thread::spawn(move || { // move ownership to the thread
        data.push(4);
    });
}

Or use synchronization primitives like Arc<Mutex<T>> for shared mutable state.

Comparison with Other Languages

C++

C++ has references, but they don’t prevent:

  • Use-after-free
  • Data races
  • Dangling pointers

You can create multiple mutable aliases in C++, leading to undefined behavior.

Java/Go

These use garbage collection and allow shared mutable state. Data races are possible and must be prevented with locks or other synchronization.

Rust

Rust enforces safety at compile time. No garbage collection overhead, no runtime race detection needed. If it compiles, it’s safe.

Conclusion

Rust’s borrowing system is strict, but that strictness pays dividends:

  • Memory safety without garbage collection
  • Fearless concurrency with compile-time data race prevention
  • Performance from knowing references can’t alias mutably

The learning curve is real, but once it clicks, you’ll wonder how you ever lived without it. The borrow checker isn’t your enemy—it’s your pair programmer who never sleeps and catches bugs before they ship.

Key takeaways:

  • Use immutable references (&T) for read-only access
  • Use mutable references (&mut T) when you need to modify
  • Remember the rules: one mutable or many immutable references
  • Let the compiler guide you—error messages are surprisingly helpful
  • Understand lifetimes conceptually, even if you don’t annotate them often

Welcome to the borrow checker. It’ll make you a better programmer.