Rust Borrowing: References and the Borrow Checker

Rust's ownership system is brilliant for memory safety, but it creates a practical problem: if every function call transfers ownership, you'd spend all your time moving values around and losing...

Key Insights

  • Rust’s borrow checker enforces memory safety at compile time by ensuring references never outlive their data and preventing data races through strict borrowing rules
  • You can have either one mutable reference OR any number of immutable references to data at the same time, but never both simultaneously
  • Understanding borrowing patterns like scope-based splitting and reference lifetimes is essential for writing idiomatic Rust code that works with the compiler instead of fighting it

Introduction to Ownership and Why Borrowing Matters

Rust’s ownership system is brilliant for memory safety, but it creates a practical problem: if every function call transfers ownership, you’d spend all your time moving values around and losing access to them. Borrowing solves this by letting functions temporarily access data without taking ownership.

Consider this ownership-based approach:

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len();
    (s, length) // Must return String to give ownership back
}

fn main() {
    let s1 = String::from("hello");
    let (s2, len) = calculate_length(s1);
    // s1 is invalid here - ownership moved
    println!("Length of '{}' is {}", s2, len);
}

This is clunky. We have to return the String just to keep using it. Borrowing lets us access the value without this ownership dance:

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

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

The ampersand & creates a reference - a pointer to the data without owning it. When the reference goes out of scope, nothing is dropped because it never owned the data.

Immutable References (&T)

Immutable references let you read data without taking ownership. The syntax &T means “a reference to a value of type T.” You can create as many immutable references as you want simultaneously:

fn main() {
    let s = String::from("hello");
    
    let r1 = &s;
    let r2 = &s;
    let r3 = &s;
    
    println!("{}, {}, {}", r1, r2, r3); // All valid simultaneously
}

This works because reading data is inherently safe - multiple readers can’t corrupt data. Functions commonly accept immutable references to avoid unnecessary copying:

fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();
    
    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }
    
    &s[..]
}

fn main() {
    let sentence = String::from("hello world");
    let word = first_word(&sentence);
    println!("First word: {}", word);
    // sentence still accessible here
}

The function borrows the String, extracts information, and returns a string slice (also a reference). The original String remains owned by the caller.

Mutable References (&mut T)

When you need to modify borrowed data, use mutable references with &mut T. The critical rule: you can have only ONE mutable reference to a piece of data in a particular scope:

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

This restriction prevents data races at compile time. You cannot have a mutable reference while immutable references exist:

fn main() {
    let mut s = String::from("hello");
    
    let r1 = &s;     // Immutable borrow
    let r2 = &s;     // Another immutable borrow - fine
    let r3 = &mut s; // ERROR! Cannot borrow as mutable while immutable refs exist
    
    println!("{}, {}, {}", r1, r2, r3);
}

The compiler error is explicit: “cannot borrow s as mutable because it is also borrowed as immutable.” The fix is to ensure mutable and immutable borrows don’t overlap:

fn main() {
    let mut s = String::from("hello");
    
    let r1 = &s;
    let r2 = &s;
    println!("{}, {}", r1, r2);
    // r1 and r2 no longer used after this point
    
    let r3 = &mut s; // Now valid - immutable refs out of scope
    r3.push_str(", world");
    println!("{}", r3);
}

Rust’s borrow checker is smart enough to end a borrow at its last use, not just at scope end. This is called Non-Lexical Lifetimes (NLL).

Borrow Checker Rules and Common Errors

The borrow checker enforces three fundamental rules:

  1. References must always be valid (no dangling pointers)
  2. You can have either one mutable reference OR any number of immutable references
  3. References cannot outlive the data they point to

Here’s how the compiler prevents dangling references:

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

The compiler won’t let you return a reference to data that’s about to be destroyed. The solution is to return the owned value:

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

Data race prevention is equally strict:

fn main() {
    let mut data = vec![1, 2, 3];
    
    let r1 = &mut data;
    let r2 = &mut data; // ERROR: cannot borrow as mutable more than once
    
    r1.push(4);
    r2.push(5);
}

The fix is to ensure mutable borrows don’t overlap:

fn main() {
    let mut data = vec![1, 2, 3];
    
    {
        let r1 = &mut data;
        r1.push(4);
    } // r1 dropped here
    
    let r2 = &mut data;
    r2.push(5);
}

Lifetimes and Reference Validity

Lifetimes are Rust’s way of tracking how long references remain valid. Most of the time, the compiler infers them, but sometimes you need explicit annotations:

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

The 'a lifetime annotation says: “the returned reference will live as long as the shorter of the two input references.” This lets the compiler verify the reference won’t outlive its data:

fn main() {
    let string1 = String::from("long string");
    let result;
    
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
    } // string2 dropped here
    
    println!("{}", result); // ERROR: string2 doesn't live long enough
}

Structs that hold references need lifetime annotations:

struct Excerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    
    let excerpt = Excerpt {
        part: first_sentence,
    };
    
    println!("{}", excerpt.part);
}

The 'a ensures the struct can’t outlive the data it references.

Practical Patterns and Best Practices

Real Rust code requires working with the borrow checker, not against it. One powerful pattern is splitting borrows - borrowing different parts of a structure independently:

struct Player {
    health: i32,
    position: (f32, f32),
}

impl Player {
    fn update(&mut self) {
        self.take_damage(10);
        self.move_to(5.0, 5.0);
    }
    
    fn take_damage(&mut self, amount: i32) {
        self.health -= amount;
    }
    
    fn move_to(&mut self, x: f32, y: f32) {
        self.position = (x, y);
    }
}

Rust allows borrowing different fields mutably because they’re distinct data:

fn main() {
    let mut player = Player {
        health: 100,
        position: (0.0, 0.0),
    };
    
    let health_ref = &mut player.health;
    let pos_ref = &mut player.position;
    
    *health_ref -= 10;
    *pos_ref = (1.0, 1.0);
}

When working with collections, use methods that return references instead of taking ownership:

fn main() {
    let mut scores = vec![10, 20, 30, 40];
    
    // get() returns Option<&T>
    if let Some(score) = scores.get(2) {
        println!("Score: {}", score);
    }
    
    // Can still mutate the vec after immutable borrow ends
    scores.push(50);
    
    // get_mut() returns Option<&mut T>
    if let Some(score) = scores.get_mut(1) {
        *score += 5;
    }
}

Sometimes you need to refactor to satisfy the borrow checker. If you find yourself fighting it, step back and consider whether your design needs adjustment:

// Instead of this pattern that fights the borrow checker:
// let item = &mut self.items[index];
// self.process(item); // Error: can't borrow self mutably twice

// Refactor to separate the data access:
fn process_item(&mut self, index: usize) {
    let value = self.items[index]; // Copy the value if it's Copy
    self.process(value);
}

// Or restructure to avoid simultaneous borrows:
fn process_all(&mut self) {
    let items = std::mem::take(&mut self.items); // Temporarily take ownership
    for item in items {
        self.process(item);
    }
    self.items = items; // Put it back
}

Conclusion

Rust’s borrowing system and borrow checker are what enable memory safety without garbage collection. By enforcing strict rules about references at compile time - one mutable reference XOR many immutable references, and ensuring references never outlive their data - Rust prevents entire classes of bugs that plague other systems languages.

The learning curve is real. You’ll fight the borrow checker initially, but this friction is teaching you to write safer code. The patterns become natural: prefer immutable borrows when possible, limit the scope of mutable borrows, and trust that when the compiler complains, it’s preventing a real bug.

Master these concepts and you’ll write concurrent, memory-safe code with confidence, knowing the compiler has your back.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.