Rust · lesson 9 of 30
Beginner
15- 1 Introduction to Rust
- 2 Setting Up Rust
- 3 Variables in Rust
- 4 Data Types in Rust
- 5 Operators in Rust
- 6 Control Flow in Rust
- 7 Functions in Rust
- 8 Ownership in Rust
- 9 Borrowing and References
- 11 Structs in Rust
- 12 Enums in Rust
- 13 Pattern Matching in Rust
- 17 Collections in Rust
- 25 Testing in Rust
- 26 File I/O in Rust
Intermediate
9Advanced
6Borrowing and References
Learn how Rust's borrow checker works — shared references, mutable references, and the rules that prevent data races.
By SkillByExample · Updated
Why Borrowing?
Moving ownership into every function and moving it back out again is tedious and impractical for real programs. Borrowing solves this by letting you pass a reference to a value — a pointer that grants temporary access without transferring ownership. The original owner retains ownership and the value is not dropped when the reference goes away. This is the mechanism that makes Rust’s ownership system actually usable in practice.
fn calculate_length(s: &String) -> usize {
s.len()
} // s is a reference — it does not own the String, so nothing is dropped here
fn main() {
let s = String::from("hello world");
let len = calculate_length(&s); // pass a reference with &
println!("'{}' has {} bytes", s, len); // s is still valid — we only lent it
}
The & in &String creates a shared reference. The function borrows the value without taking ownership.
Shared References &T
Shared references are read-only. Their key property is that you can have any number of shared references to the same value simultaneously, because none of them can modify the data. This maps directly to a principle from concurrent programming: multiple readers are always safe.
fn main() {
let s = String::from("hello");
// All three references coexist peacefully — none can mutate s
let r1 = &s;
let r2 = &s;
let r3 = &s;
println!("{} {} {}", r1, r2, r3); // all three are valid
}
Shared references implement Copy, so passing &T to a function does not move the reference — the original binding remains valid.
Mutable References &mut T
To modify data through a reference, you need a mutable reference &mut T. Mutable references grant exclusive write access: while a mutable reference exists, no other references to the same data — shared or mutable — may exist. This exclusivity is what allows the compiler to prove at compile time that no data races are possible.
fn append_world(s: &mut String) {
s.push_str(", world"); // can modify because we have a mutable reference
}
fn main() {
let mut s = String::from("hello"); // the variable itself must be mut
append_world(&mut s); // pass a mutable reference with &mut
println!("{}", s); // hello, world
}
Both the variable and the reference must be marked mut.
The Borrow Rules
The compiler enforces two rules that together prevent data races and dangling pointers. These rules are what the borrow checker exists to enforce. When you understand them, borrow checker errors become straightforward to diagnose and fix.
Rule 1: At any given time, you can have either:
- Any number of shared references (
&T), or - Exactly one mutable reference (
&mut T) — with no shared references at the same time
Rule 2: References must always be valid — you cannot have a reference to data that has been freed (no dangling references).
Violating Rule 1 — mixing shared and mutable borrows:
fn main() {
let mut s = String::from("hello");
let r1 = &s; // shared borrow begins — OK
let r2 = &s; // second shared borrow — OK, multiple readers allowed
// let r3 = &mut s; // ERROR: cannot borrow mutably while shared borrows exist
println!("{} {}", r1, r2);
}
Violating Rule 1 — two mutable borrows at the same time:
fn main() {
let mut s = String::from("hello");
let r1 = &mut s; // mutable borrow — exclusive access
// let r2 = &mut s; // ERROR: cannot have two mutable borrows simultaneously
println!("{}", r1);
}
Non-Lexical Lifetimes (NLL)
Before Rust 2018, borrows lasted until the end of the enclosing block regardless of where the reference was last used. Non-Lexical Lifetimes (NLL) changed this: a borrow now ends at the last point where the reference is used, not at the closing brace. This makes many patterns that previously looked like borrow conflicts work correctly.
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{} {}", r1, r2);
// r1 and r2 are not used after this line — their borrows end here (NLL)
let r3 = &mut s; // OK under NLL — the shared borrows have already ended
r3.push_str("!");
println!("{}", r3);
}
Without NLL this would have been an error because r1, r2, and r3 are all lexically in scope until the closing brace.
Dangling References
The compiler’s second borrow rule — references must always be valid — prevents dangling pointers entirely. If you try to return a reference to local data that will be freed when the function returns, the compiler rejects the program.
// fn dangle() -> &String { // ERROR: missing lifetime specifier
// let s = String::from("hello");
// &s // s is dropped at the end of this function — this would be a dangling pointer!
// }
The fix is to return the String itself — transfer ownership to the caller instead of lending a reference:
fn no_dangle() -> String {
let s = String::from("hello");
s // ownership moves to the caller — no reference, no dangling pointer problem
}
Slices — Borrowing a Contiguous Sequence
Slices are references to a contiguous portion of a collection. They borrow a sub-range of the data without copying it, and the borrow rules apply: you cannot mutate the original collection while a slice borrowing it exists.
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &b) in bytes.iter().enumerate() {
if b == b' ' {
return &s[0..i]; // return a slice borrowing the portion before the space
}
}
s // whole string if no space found
}
fn main() {
let sentence = String::from("hello world");
let word = first_word(&sentence); // word borrows part of sentence
// sentence.clear(); // ERROR: cannot mutate while word (a borrow) is alive
println!("first word: {}", word); // hello
}
String slices (&str) are particularly common. &str is always a borrowed slice of UTF-8 string data — it can point into a String, into a string literal in the binary, or into any other string storage.
Method Receivers
The same borrow rules apply to method calls. The self parameter in an impl block takes one of three forms, each with different ownership semantics:
struct Counter {
value: i32,
}
impl Counter {
fn get(&self) -> i32 { // shared borrow — read-only, does not consume
self.value
}
fn increment(&mut self) { // mutable borrow — can modify, does not consume
self.value += 1;
}
fn consume(self) -> i32 { // takes ownership — Counter is consumed (dropped after)
self.value
}
}
fn main() {
let mut c = Counter { value: 10 };
println!("{}", c.get()); // 10 — shared borrow, c still usable
c.increment(); // mutable borrow, c still usable after
println!("{}", c.get()); // 11
let v = c.consume(); // c is moved into consume — c is no longer valid
// println!("{}", c.get()); // ERROR: c was moved
println!("{}", v); // 11
}
Interior Mutability with Cell and RefCell
Sometimes the borrow rules are too conservative for a pattern you need — for example, mutating data through a shared reference in a single-threaded setting. Rust provides interior mutability types that move the borrow checks from compile time to runtime, panicking if you violate them instead of failing to compile.
use std::cell::RefCell;
fn main() {
// RefCell<T> wraps a value and tracks borrows at runtime
let data = RefCell::new(vec![1, 2, 3]);
let r1 = data.borrow(); // runtime shared borrow — like &T
println!("{:?}", *r1); // [1, 2, 3]
drop(r1); // release the borrow explicitly before mutating
data.borrow_mut().push(4); // runtime mutable borrow — like &mut T
println!("{:?}", data.borrow()); // [1, 2, 3, 4]
}
RefCell moves the borrow checks from compile time to runtime — it panics if you violate the rules at runtime. Use it sparingly, only when the compile-time rules genuinely cannot model your access pattern.
Summary of Borrow Rules
Any number of &T (shared) — at the same time, OR
Exactly one &mut T (exclusive) — no shared borrows at the same time
References must never outlive the data they point to.
These two rules are what the borrow checker enforces. When you see a borrow checker error, ask: “Am I trying to have a mutable reference while a shared one exists?” or “Am I returning a reference to local data?”
Practice
Each task gives you a program that fails, either at compile time or at run time. Predict the error, then make it work without removing any of the output it was meant to print.
-
Make this compile by moving one line.
fn main() { let mut s = String::from("hello"); let r1 = &s; s.push_str(", world"); println!("r1 = {}", r1); println!("s = {}", s); }
Solution
fn main() {
let mut s = String::from("hello");
let r1 = &s;
println!("r1 = {}", r1); // last use of r1: the shared borrow ends here
s.push_str(", world"); // now the mutable borrow is allowed
println!("s = {}", s);
}
$ rustc reorder.rs
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> reorder.rs:5:5
|
4 | let r1 = &s;
| -- immutable borrow occurs here
5 | s.push_str(", world");
| ^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
6 | println!("r1 = {}", r1);
| -- immutable borrow later used here
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0502`.
$ rustc reorder_fixed.rs && ./reorder_fixed
r1 = hello
s = hello, world
push_str takes &mut self, so line 5 needs a mutable borrow while r1 is still live. The error marks
all three points: where the shared borrow starts, where the conflict happens, and the later use that keeps
the borrow alive. Moving that use above push_str lets NLL end the borrow early.
- Uncomment the
clear(). In the slices example, declaresentencewithlet mut(sinceclearneeds it) and uncommentsentence.clear();, then compile. Fix it so the program prints the first word and clears the sentence.
Solution
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &b) in bytes.iter().enumerate() {
if b == b' ' {
return &s[0..i];
}
}
s
}
fn main() {
let mut sentence = String::from("hello world");
let word = first_word(&sentence);
println!("first word: {}", word); // last use of word: the borrow ends here
sentence.clear();
println!("sentence is now {:?}", sentence);
}
$ rustc clear.rs
error[E0502]: cannot borrow `sentence` as mutable because it is also borrowed as immutable
--> clear.rs:14:5
|
13 | let word = first_word(&sentence);
| --------- immutable borrow occurs here
14 | sentence.clear();
| ^^^^^^^^^^^^^^^^ mutable borrow occurs here
15 | println!("first word: {}", word);
| ---- immutable borrow later used here
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0502`.
$ rustc clear_fixed.rs && ./clear_fixed
first word: hello
sentence is now ""
word points into sentence’s buffer. If clear() were allowed to run first, word would refer to
bytes that no longer hold hello. That is exactly the bug a C programmer would ship, and here it is
rejected at compile time. Using word before mutating is the fix.
-
Return a reference to a local. Uncomment
dangleand call it frommain. Read what the compiler suggests, and pick the right suggestion.fn dangle() -> &String { let s = String::from("hello"); &s } fn main() { let r = dangle(); println!("{}", r); }
Solution
fn no_dangle() -> String {
let s = String::from("hello");
s
}
fn main() {
let r = no_dangle();
println!("{}", r);
}
$ rustc dangle.rs
error[E0106]: missing lifetime specifier
--> dangle.rs:1:16
|
1 | fn dangle() -> &String {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime, but this is uncommon unless you're returning a borrowed value from a `const` or a `static`
|
1 | fn dangle() -> &'static String {
| +++++++
help: instead, you are more likely to want to return an owned value
|
1 - fn dangle() -> &String {
1 + fn dangle() -> String {
|
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0106`.
$ rustc dangle_fixed.rs && ./dangle_fixed
hello
The function takes no reference parameters, so there is nothing the returned reference could borrow from.
That is what “no value for it to be borrowed from” means. The 'static suggestion would only move the
error elsewhere, because s is still dropped at the closing brace. Returning the owned String is the
real fix.
-
Use a value after
consume. This program reads the counter after handing it toconsume. Make it compile while still printing both lines.struct Counter { value: i32, } impl Counter { fn get(&self) -> i32 { self.value } fn increment(&mut self) { self.value += 1; } fn consume(self) -> i32 { self.value } } fn main() { let mut c = Counter { value: 10 }; c.increment(); let v = c.consume(); println!("consumed {}", v); println!("still there? {}", c.get()); }
Solution
struct Counter {
value: i32,
}
impl Counter {
fn get(&self) -> i32 { self.value }
fn increment(&mut self) { self.value += 1; }
fn consume(self) -> i32 { self.value }
}
fn main() {
let mut c = Counter { value: 10 };
c.increment();
println!("still there? {}", c.get()); // read while c is still owned here
let v = c.consume(); // c moves into consume; no uses after this
println!("consumed {}", v);
}
$ rustc consume.rs
error[E0382]: borrow of moved value: `c`
--> consume.rs:16:33
|
12 | let mut c = Counter { value: 10 };
| ----- move occurs because `c` has type `Counter`, which does not implement the `Copy` trait
13 | c.increment();
14 | let v = c.consume();
| --------- `c` moved due to this method call
15 | println!("consumed {}", v);
16 | println!("still there? {}", c.get());
| ^ value borrowed here after move
|
note: `Counter::consume` takes ownership of the receiver `self`, which moves `c`
--> consume.rs:8:16
|
8 | fn consume(self) -> i32 { self.value }
| ^^^^
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0382`.
$ rustc consume_fixed.rs && ./consume_fixed
still there? 11
consumed 11
The note points straight at the receiver: self without & takes ownership, so c is gone after line
14. get takes &self, so it has to borrow before the move, not after. Read through &self or
&mut self first and consume last.
-
Panic a
RefCellat run time. This version takes out thedrop(r1)line from theRefCellexample. It compiles. Run it, then fix it without callingdrop.use std::cell::RefCell; fn main() { let data = RefCell::new(vec![1, 2, 3]); let r1 = data.borrow(); println!("{:?}", *r1); data.borrow_mut().push(4); println!("{:?}", data.borrow()); }
Solution
use std::cell::RefCell;
fn main() {
let data = RefCell::new(vec![1, 2, 3]);
{
let r1 = data.borrow();
println!("{:?}", *r1);
} // r1 goes out of scope here, releasing the shared borrow
data.borrow_mut().push(4);
println!("{:?}", data.borrow());
}
$ rustc refcell.rs && ./refcell
[1, 2, 3]
thread 'main' (80) panicked at refcell.rs:9:10:
RefCell already borrowed
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
$ rustc refcell_fixed.rs && ./refcell_fixed
[1, 2, 3]
[1, 2, 3, 4]
NLL doesn’t help here. r1 is a guard object, and it holds its runtime borrow until it is dropped at the
end of its scope, not at its last use. So borrow_mut on line 9 found a live shared borrow and panicked.
An inner block ends the guard’s life just as drop(r1) did. This is the cost of RefCell: the same rule
as before, but you find out when the program runs instead of when it compiles.