In Part 1 of this series, we covered ownership in depth – move semantics, Copy vs Clone, the Drop trait, and what ownership actually means for production code. If you have not read that yet, start there.
In this part, we tackle borrowing. Specifically, we are going to build the mental model that makes the borrow checker make sense – not just the rules, but the reasoning behind them. By the end, compiler errors about borrows should feel like useful feedback rather than arbitrary restrictions.
What Borrowing Is and Why It Exists
Ownership is clean and safe, but it is also restrictive. If every function call consumed its arguments, you would spend most of your time threading values in and out of functions. Borrowing exists to let you use a value without taking ownership of it.
A borrow is a reference – a pointer to a value that someone else owns. The owner is still responsible for dropping the value; you are just borrowing access to it temporarily. When the borrow ends, the owner gets full control back.
There are two kinds of borrows in Rust:
- Shared references (
&T): read-only access. You can have as many of these as you want simultaneously. - Mutable references (
&mut T): read and write access. You can have exactly one at a time, and no shared references can exist at the same time.
These two rules – multiple shared or exactly one mutable – are the entire borrowing system. Everything the borrow checker does flows from enforcing these two invariants.
The Borrowing Rules Visualized
stateDiagram-v2
[*] --> Owned: Value created
Owned --> SharedBorrow: &T (immutable borrow)
Owned --> MutableBorrow: &mut T (mutable borrow)
SharedBorrow --> SharedBorrow: Multiple &T allowed simultaneously
SharedBorrow --> Owned: All borrows end
MutableBorrow --> Owned: Borrow ends
MutableBorrow --> [*]: Cannot add more borrows while &mut T is active
note right of MutableBorrow
Exclusive access:
No other &T or &mut T
allowed while active
end note
note right of SharedBorrow
Shared access:
Many &T allowed
No &mut T allowed
end note
Shared References: Reading Without Owning
A shared reference lets you read a value without moving it. The syntax is &value to create a reference and &T as the type:
fn print_length(s: &String) {
println!("Length: {}", s.len());
}
fn main() {
let s = String::from("hello world");
print_length(&s); // borrow s
print_length(&s); // borrow s again - fine, s is still owned here
println!("Original: {}", s); // s is still valid
}You can hold multiple shared references at the same time with no issues:
fn main() {
let data = vec![1, 2, 3, 4, 5];
let r1 = &data;
let r2 = &data;
let r3 = &data;
// all three are valid simultaneously
println!("{:?} {:?} {:?}", r1, r2, r3);
}This is safe because none of them can modify the data. Multiple readers with no writers is always safe.
Mutable References: Exclusive Write Access
A mutable reference gives you read and write access to a value. The syntax is &mut value:
fn append_greeting(s: &mut String) {
s.push_str(", world!");
}
fn main() {
let mut message = String::from("Hello");
append_greeting(&mut message);
println!("{}", message); // "Hello, world!"
}The restrictions kick in immediately:
fn main() {
let mut data = String::from("hello");
let r1 = &data; // shared borrow - ok
let r2 = &mut data; // ERROR: cannot borrow as mutable because it's already borrowed as immutable
println!("{} {}", r1, r2);
}And you cannot have two mutable references either:
fn main() {
let mut data = String::from("hello");
let r1 = &mut data;
let r2 = &mut data; // ERROR: cannot borrow `data` as mutable more than once
println!("{} {}", r1, r2);
}Why These Rules Exist: Data Race Prevention
The rules are not arbitrary. They prevent a specific class of bugs: data races. A data race occurs when two or more pointers access the same memory location, at least one is writing, and there is no synchronization. In concurrent code, data races cause undefined behavior. In single-threaded code, they cause logical corruption.
Rust’s borrowing rules make data races impossible at compile time. If you can only have one mutable reference, and no immutable references can coexist with a mutable one, then by definition two things cannot simultaneously read and write the same memory.
This is not just theoretical safety. It is practical: iterator invalidation bugs, use-after-reallocation, aliasing bugs – all of these are prevented by the same two rules. Consider this classic bug in C++:
// This is what Rust PREVENTS at compile time
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0]; // shared borrow of v
v.push(4); // ERROR: mutable borrow of v while shared borrow exists
println!("{}", first); // would be dangling if this compiled
}Pushing to a vector may reallocate its internal buffer. If first held a pointer to the old buffer, using it after the push would be a use-after-free. Rust rejects this at compile time.
Non-Lexical Lifetimes: The Borrow Checker Gets Smarter
Before Rust 2018, borrow scopes were tied to lexical scope – a borrow lasted until the end of the block it was declared in, even if you stopped using it earlier. This led to false positives in the borrow checker and frustrating workarounds.
Non-Lexical Lifetimes (NLL) changed this. The borrow checker now analyzes actual usage rather than lexical scope. A borrow ends at the last point it is used, not at the end of the enclosing block:
fn main() {
let mut data = String::from("hello");
let r1 = &data;
println!("{}", r1); // last use of r1 - borrow ends HERE (NLL)
// r1's borrow is already over, so this is fine
let r2 = &mut data;
r2.push_str(" world");
println!("{}", r2);
}Under the old lexical rules, this would have been a compile error because r1 and r2 are in the same scope block. With NLL, the compiler sees that r1 is last used before r2 is created, so there is no actual overlap. This change made a large amount of valid code compile that previously required workarounds.
The Borrow Checker Mental Model
Here is the model that experienced Rust developers use when reasoning about borrowing:
Think of a value as having a lock. The lock has two modes:
- Read lock (
&T): many readers can hold this simultaneously. No one can write while any reader holds it. - Write lock (
&mut T): only one holder, and it is exclusive – no readers, no other writers.
The borrow checker is just verifying that your code respects this locking discipline at compile time. Every error message about borrowing is the compiler telling you that you are trying to acquire a lock that is already held in an incompatible mode.
When you get a borrow checker error, ask yourself: what lock does this value currently hold, and what lock am I trying to acquire? That framing usually makes the error and the fix obvious.
Common Borrow Checker Errors and How to Read Them
Error E0502: Cannot Borrow as Mutable Because Also Borrowed as Immutable
fn main() {
let mut scores = vec![10, 20, 30];
let first = &scores[0]; // shared borrow
scores.push(40); // E0502: mutable borrow conflicts
println!("first: {}", first);
}Fix: stop using first before modifying, or copy the value out:
fn main() {
let mut scores = vec![10, 20, 30];
let first = scores[0]; // Copy the value - i32 is Copy
scores.push(40); // fine now, no active borrow
println!("first: {}", first);
}Error E0596: Cannot Borrow as Mutable Because Not Declared as Mutable
fn main() {
let config = String::from("debug");
config.push_str(" mode"); // E0596: cannot borrow as mutable
}Fix: declare the binding as mut:
fn main() {
let mut config = String::from("debug");
config.push_str(" mode"); // fine
}Error E0505: Cannot Move Because It Is Borrowed
fn consume(s: String) {
println!("{}", s);
}
fn main() {
let mut data = String::from("hello");
let r = &data;
consume(data); // E0505: cannot move out of `data` because it is borrowed
println!("{}", r);
}Fix: ensure the borrow ends before the move:
fn main() {
let mut data = String::from("hello");
{
let r = &data;
println!("{}", r);
} // r's borrow ends here
consume(data); // now fine
}Practical Patterns for Working With Borrowing
Pattern 1: Scope Borrows as Tightly as Possible
The narrower your borrows, the less they conflict with each other. With NLL this is often automatic, but sometimes you need explicit blocks:
fn main() {
let mut cache = std::collections::HashMap::new();
// look up or insert pattern
let needs_insert = {
let existing = cache.get("key"); // shared borrow starts
existing.is_none()
}; // shared borrow ends here
if needs_insert {
cache.insert("key", "value"); // mutable borrow - now safe
}
}Pattern 2: Return References From Functions Carefully
You can return references from functions, but the reference must not outlive the data it points to. The compiler enforces this through lifetime annotations (covered in Part 3), but you can often return references to data the caller passed in:
fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
if s1.len() >= s2.len() { s1 } else { s2 }
}
fn main() {
let s1 = String::from("long string");
let result;
{
let s2 = String::from("short");
result = longest(&s1, &s2);
println!("longest: {}", result); // fine - result used while s2 is alive
}
}Pattern 3: Splitting Borrows for Struct Fields
Rust allows borrowing multiple fields of a struct simultaneously as long as they are different fields. The borrow checker tracks borrows at the field level, not just the struct level:
struct Player {
name: String,
score: u32,
history: Vec,
}
fn main() {
let mut player = Player {
name: String::from("Alice"),
score: 0,
history: vec![],
};
// borrow two different fields mutably at the same time
let name = &player.name; // shared borrow of name
let history = &mut player.history; // mutable borrow of history
// different fields - this compiles fine
history.push(player.score);
println!("Player: {}", name);
} This only works with direct field access. If you call a method, the borrow checker cannot see inside the method and borrows the entire struct. That is why sometimes you need to restructure code to use field access directly instead of methods.
Pattern 4: The Entry API Pattern for HashMap
A classic beginner mistake is trying to check a HashMap and then mutate it in two separate steps:
use std::collections::HashMap;
fn main() {
let mut map: HashMap = HashMap::new();
// This approach causes borrow conflicts
// if !map.contains_key("visits") { // shared borrow
// map.insert("visits", 0); // mutable borrow while shared exists
// }
// The entry API does it in one atomic step
map.entry(String::from("visits"))
.and_modify(|count| *count += 1)
.or_insert(1);
println!("{:?}", map);
} The entry API is designed specifically to handle the check-then-modify pattern without triggering borrow conflicts. Learn it early – it comes up constantly with HashMaps.
Borrowing Across Function Boundaries
When a function takes a reference, it is making a promise: the reference will not outlive the data it points to. Rust enforces this through lifetime analysis. For simple cases, the compiler infers lifetimes automatically:
// Rust infers lifetimes here - the return value lives as long as the input
fn first_word(s: &str) -> &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);
// sentence cannot be modified here because word borrows from it
// sentence.clear(); // ERROR: cannot borrow as mutable
println!("first word: {}", word);
}Notice that sentence.clear() would be rejected even though it appears later in the code. The borrow checker sees that word holds a reference into sentence and that word is used after the point where clear() would be called. Clearing while a reference is active is not safe, so it is rejected.
Interior Mutability: When the Rules Need to Bend
Sometimes the compile-time borrow checker is too conservative for what you are trying to do. Rust provides interior mutability types that move the borrow checking to runtime for specific cases. The most common is RefCell<T>:
use std::cell::RefCell;
fn main() {
// RefCell allows mutation through a shared reference
let data = RefCell::new(vec![1, 2, 3]);
let r1 = data.borrow(); // runtime shared borrow
// let r2 = data.borrow_mut(); // would panic at runtime: already borrowed
println!("{:?}", *r1);
drop(r1); // release the borrow explicitly
let mut r2 = data.borrow_mut(); // now fine
r2.push(4);
println!("{:?}", *r2);
}The same rules apply – no simultaneous mutable and immutable borrows – but the enforcement happens at runtime with a panic instead of a compile error. Use RefCell when the borrow checker cannot statically verify that your access pattern is safe, but you know it is. We cover this in detail in Part 7 on smart pointers and interior mutability.
Designing APIs With Borrowing in Mind
When you write library code, think about what your functions need from their arguments and express that in the types:
- If you only need to read, take
&T - If you need to modify in place, take
&mut T - If you need to store it or transfer responsibility, take
T(owned) - For string arguments, prefer
&strover&String– it works with bothStringand string literals - For slice arguments, prefer
&[T]over&Vec<T>– it works with bothVecand arrays
These preferences are not just style – they make your API more flexible for callers without any cost to you.
What Comes Next
You now have the borrow checker mental model: shared borrows allow many readers, mutable borrows are exclusive, NLL ends borrows at last use, and the whole system exists to prevent data races and use-after-free at compile time.
But we have been glossing over something. When borrows cross function boundaries or live inside structs, the compiler needs more information about how long those borrows are valid. That is what lifetimes are for. In Part 3, we demystify lifetime annotations from the ground up – not as mysterious syntax to memorize, but as a way to give the compiler the context it needs to verify your borrows are safe.
References
- The Rust Programming Language – “References and Borrowing” (https://doc.rust-lang.org/book/ch04-02-references-and-borrowing.html)
- The Rustonomicon – “References” (https://doc.rust-lang.org/nomicon/references.html)
- Rust Blog – “Rust 2018 Edition and Non-Lexical Lifetimes” (https://blog.rust-lang.org/2018/12/06/Rust-1.31-and-the-2018-edition.html)
- JetBrains RustRover Blog – “Everything You Wanted to Ask About Rust” (https://blog.jetbrains.com/rust/2025/12/23/everything-you-wanted-to-ask-about-rust-answered-by-herbert-wolverson/)
- Rust Standard Library – “Entry API for HashMap” (https://doc.rust-lang.org/std/collections/hash_map/enum.Entry.html)
