TL;DR
Deep dive into Rust borrowing and references — learn shared and mutable references, the borrow checker, and safe data access
Key concepts
- Rust borrowing
- Rust references
- Rust mutable references
- Rust borrow checker
- Rust shared references
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.
What You Already Know
By the end of this lesson you will write a three-function toolkit over a single vector that never leaves the function that declared it: one function reads it to find a maximum, one changes every element in place, and one builds a new collection from the parts that qualify. The signature of each is the whole exercise, and a check in the battery asserts that the caller still owns its data afterwards — precisely the thing a by-value parameter would destroy silently.
You arrive here able to move a value into a function or lend it with an ampersand (Ownership & Borrowing). What is new is the rule that governs several loans at once, and the surprising part of it: a borrow lives until its last use, not to the end of its scope, so a line you add at the bottom of a function can break a line in the middle. The capstone's task manager is built on exactly this — its filter produces a Vec<&Task>, a collection of borrowed views into a task list it never gives away, and its renderers take &[&Task] because reading is all they do. The reason that whole path can be written without the compiler objecting is the last-use rule you are about to meet.
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
The three patterns below are the same loop three times over. Read them for the choice of parameter type — that is the only thing changing, and it is what decides what each function is allowed to do.
Pattern 1: Read-Only Access
Fully annotated, with the reason for every decision:
// Read-only access: the parameter is &Vec<i32> and not Vec<i32> because
// summing does not consume anything. The caller keeps its data and can ask
// again on the very next line, which is the entire reason to write the &.
fn total(data: &Vec<i32>) -> i32 {
let mut sum = 0;
// `data` is a &Vec, so looping over it yields &i32 — hence the *.
for value in data {
sum += *value;
}
sum
}
fn main() {
let numbers = vec![1, 2, 3, 4, 5];
println!("total {}", total(&numbers));
// Possible only because total borrowed rather than took:
println!("total again {}", total(&numbers));
}
It prints total 15 twice. Here is the shape without the rationale spelled out:
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
One word changes in the signature. Work out what it forces before reading the comment:
// This one CHANGES the caller's data, so &Vec<i32> will not do: an element
// reached through a shared borrow is a &i32 and cannot be assigned to. The
// &mut is what turns each loop element into a &mut i32 you may write through.
fn scale(data: &mut Vec<i32>, factor: i32) {
for value in data {
*value *= factor;
}
}
fn main() {
// `mut` on the binding is the caller's half of the &mut contract.
let mut numbers = vec![1, 2, 3];
scale(&mut numbers, 10);
println!("{:?}", numbers);
}
It prints [10, 20, 30]. Downgrade that &mut Vec<i32> to &Vec<i32> and the body stops compiling with error[E0594]: cannot assign to *value, which is behind a & reference — and the diagnostic spells out why, underlining the loop with this iterator yields & references. The same loop over the same data does two different things depending on one word in the signature.
The equivalent written with an explicit iterator method, which you will meet again in the collections and iterators lessons:
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]
}
The third stage is the build task at the end of this lesson: three signatures, no comments, and a check that asserts the caller still owns its data.
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.
When to Refuse the help:
The section above showed how to read E0502's three spans by role. There is one more part of a rustc diagnostic worth naming, because it is the part most likely to lead you somewhere you did not want to go. Here is a different borrow error — a value moved into a function and then used afterwards:
error[E0382]: borrow of moved value: `log`
--> /tmp/main.rs:8:40
|
6 | let log = vec![4, 1, 7];
| --- move occurs because `log` has type `Vec<i32>`, which does not implement the `Copy` trait
7 | let n = summarise(log);
| --- value moved here
8 | println!("{} readings in {:?}", n, log);
| ^^^ value borrowed here after move
|
note: consider changing this parameter type in function `summarise` to borrow instead if owning the value isn't necessary
--> /tmp/main.rs:1:19
|
1 | fn summarise(log: Vec<i32>) -> usize {
| --------- ^^^^^^^^ this parameter takes ownership of the value
| |
| in this function
help: consider cloning the value if the performance cost is acceptable
|
7 | let n = summarise(log.clone());
| ++++++++
Read the help: last, and read it as a suggestion rather than an instruction. It proposes log.clone(), which would compile instantly — and would duplicate the entire vector to work around a function that only wanted to count its elements. The note: above it names the fix you actually want: change summarise to take &Vec<i32> and nothing needs copying at all. rustc offers the clone because it is the edit it can make mechanically without knowing your intent; the note is where it tells you what it suspects you meant.
The rule of thumb: a help: that adds .clone(), .to_owned() or .to_string() is a question, not an answer. Ask what it is copying and why the borrow could not be restructured instead. A help: that adds a missing &, a mut, or a lifetime name is usually just correct — those are edits that change the plumbing rather than the amount of work the program does.
And notice what the E0502 diagnostics earlier in this lesson do not have: a help: at all. Its absence is information. rustc offers a suggestion when it has a mechanical one; when two borrows genuinely conflict there is no edit it can propose, because the answer is a decision about which of the two borrows you really need and where it should end.
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.
// Keep a running largest, start it at readings[0], and walk the log with
// 'for reading in readings'. Looping over a &Vec hands you a &i32 each
// time, so compare and assign with *reading.
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.
// 'for reading in readings' over a &mut Vec hands you a &mut i32 each
// time, which is a reference you may WRITE through: *reading += 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]
// Start from the empty Vec already below, walk the log the same way, and
// push a copy of each reading that qualifies: kept.push(*reading).
fn above(readings: &Vec<i32>, floor: i32) -> Vec<i32> {
let mut kept: Vec<i32> = Vec::new();
let _ = readings;
let _ = floor;
let _ = &mut kept;
kept
}
// --- 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:
- Hold a shared borrow across the mutation. Add
let first = &readings[0];immediately before the firstassert_eq!, and addprintln!("first was {}", first);as the very last line ofmain. Predict which check breaks before running. None of them do — it never gets that far. Compilation fails witherror[E0502]: cannot borrow readings as mutable because it is also borrowed as immutable, naming the&readings[0]line, thecalibrate(&mut readings, 3)line, and your newprintln!as "immutable borrow later used here". Delete just that finalprintln!and it compiles again: the borrow's last use is what stretched it acrosscalibrate, exactly as the NLL section showed. - Take two overlapping borrows of opposite kinds. Add
let scratch = &mut readings;just before thelet high = above(&readings, 15);line, andscratch.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&mutcame 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.
Put the Order Back
Arrange the code
These five lines take a vector, reach into it with a mutable reference to change one element, then read the whole thing back. Put them in the order that runs — then answer the question the block is really about: four of the wrong arrangements fail because a name is used before it exists, but one fails for a completely different reason. Find that one and say what makes it different.
println!("{:?} sums to {}", log, total);let total: i32 = log.iter().sum();let slot = &mut log[1];let mut log = vec![4, 1, 7];*slot = 9;
Carrying the Idea Across
Transfer
Rust puts the permission to write into the reference type: &mut T can be written through, &T cannot, and a wrong write is rejected before the program runs. Zig draws the same distinction with *T and *const T. Which statement names what the two languages genuinely share, rather than a surface resemblance?
Key Takeaways
- Borrowing lets you use data without taking ownership
&Tcreates an immutable reference (read-only)&mut Tcreates a mutable reference (read-write)- You can have many
&TOR 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.
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.