Rust Borrowing, References, and the Borrow Checker: Core Concepts

Rust Borrowing, References, and the Borrow Checker
In the previous module, we established that assigning a heap-backed variable to a new variable (or passing it into a function) results in a Move. The original variable loses ownership of the physical data and becomes entirely invalid.
While theoretically elegant for preventing Double-Free errors, explicitly returning ownership back outward from every single function call is ergonomically painful.
To solve this, Rust allows you to use a value without taking ownership of it. This feature is called Borrowing, and it is implemented via References.
However, borrowing introduces a massive security hole: Data Races. If multiple actors borrow the data simultaneously and try to alter it, chaotic behavior ensues. Rust closes this hole securely with the most infamous construct in the entire language: The Borrow Checker.
1. Creating References (&)
A reference works conceptually like a pointer in C. It is an address pointing to a specific location in memory where the data is actually stored.
However, unlike a C pointer—which can be wildly incremented to point at unauthorized memory addresses, causing buffer overflows—a Rust reference is mathematically guaranteed to perfectly map to a valid, initialized value of a specific type. You cannot do pointer arithmetic on safe Rust references.
You create a reference using the ampersand & symbol.
fn main() {
let s1 = String::from("hello");
// We pass `&s1` (a reference to s1) instead of `s1`.
// The data is borrowed, NOT moved!
let length = calculate_length(&s1);
// Because s1 didn't lose ownership, it is perfectly valid here.
println!("The length of '{}' is {}.", s1, length);
}
// The function signature now expects a reference to a String: &String
fn calculate_length(s: &String) -> usize {
s.len()
} // Here, `s` goes out of scope. But because it doesn't OWN the data,
// `drop` is NOT called! The memory is kept safe.When variables use references to look at data without taking ownership, we call it borrowing. As in real life, if a person owns something, you can borrow it from them. When you’re done, you must give it back.
2. Mutable References (&mut)
If we attempt to fundamentally change something we are borrowing via a standard & reference, the compiler will aggressively stop us.
fn main() {
let s = String::from("hello");
// change(&s); // ERROR: Cannot borrow `s` as mutable
}
fn change(some_string: &String) {
// some_string.push_str(", world");
}By default, just as variables are immutable, references are immutable. We are not allowed to modify something we merely borrowed.
If you specifically want to mutate the borrowed data, you must do two things:
- Make the original variable explicitly mutable (
let mut s). - Create and pass a Mutable Reference (
&mut s).
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");
}Now the function can physically alter the heap data owned by the original scope.
But with this great power comes an incredibly strict pair of constraints. Enter the Borrow Checker.
3. The Rules of Borrowing
Rust developers spend more time thinking about these two rules than almost anything else. If you internalize them now, you will save yourself hundreds of hours of frustration.
At any given time, for a specific piece of data, either:
- Rule A: You can have an infinite number of immutable references (
&T). - Rule B: You can have exactly one mutable reference (
&mut T).
You cannot have both at the same time.
Why Rule B exists: The Data Race Prevention
Imagine if Rust allowed multiple mutable references to the same data on a multi-threaded server. Thread 1 takes &mut Data and begins looping through an array to read its elements. Simultaneously, Thread 2 takes &mut Data and aggressively deletes the array entirely.
Thread 1 will instantly crash. This is a Data Race.
By enforcing at compile-time that only one mutable reference can exist, Rust makes data races fundamentally impossible.
let mut s = String::from("hello");
let r1 = &mut s;
// let r2 = &mut s; // ERROR: cannot borrow `s` as mutable more than once at a time
// println!("{}, {}", r1, r2);Why Rule A and Rule B conflict
You also cannot have a mutable reference while holding an immutable reference. This prevents the "Reader/Writer" problem.
let mut s = String::from("hello");
let r1 = &s; // no problem
let r2 = &s; // no problem
// let r3 = &mut s; // BIG PROBLEM
// println!("{}, {}, and {}", r1, r2, r3);If multiple users are currently holding read-only references (r1 and r2), they have an absolute mathematical guarantee that the data will not suddenly change beneath them while they are looking at it. By requesting r3 (a mutable reference), you are threatening to break that guarantee. The compiler will reject your code.
The Borrow Checker Logic
| Example | Description | |
|---|---|---|
| Multiple Readers | let x = &a; let y = &a; | Safe. Multiple components can read the exact same data. No data races. |
| Exclusive Writer | let x = &mut a; | Safe. Only one component can edit. No other component can read it while it edits. |
| Violating the Matrix | let x = &a; let y = &mut a; | Unsafe. The compiler throws E0502: cannot borrow a as mutable because it is also borrowed as immutable. |
4. Dangling References
In C/C++, it’s terrifyingly easy to create a Dangling Pointer—a pointer that references a location in memory that has already been given to someone else or completely freed by the OS.
The Rust Borrow Checker mathematically prevents dangling references through Scope Analysis.
fn main() {
let reference_to_nothing = dangle();
}
fn dangle() -> &String { // dangle returns a reference to a String
let s = String::from("hello"); // s is a new String
&s // we return a reference to the String, s
} // Here, s goes out of scope, and is dropped. Its memory goes away.
// BUT we tried to return a reference to it!If this code was allowed to compile, reference_to_nothing would be pointing to invalid heap memory. Instead of a runtime segfault, the Rust compiler fails immediately:
ERROR: this function's return type contains a borrowed value, but there is no value for it to be borrowed from.
The compiler recognized that the physical memory for s was destroyed before the reference reached the outer scope. The solution? Do not return a reference; return the actual String, triggering an explicit Move of ownership back to the caller!
Summary and Next Steps
The Borrow Checker is a notorious gatekeeper. It forces you to construct your software architecturally sound from the ground up, isolating readers from writers. While this leads to "fighting the borrow checker" in your first few weeks of learning Rust, it eventually yields to profound empowerment. You can fearlessly refactor massively concurrent applications knowing the compiler will not let you merge a subtle race condition.
Now that we understand how primitive data, moving, and borrowing work within the memory management model, we can apply this knowledge. In the next module, we leave primitives behind and learn how to construct complex custom domains using structs and implementation blocks to build object-oriented patterns in Rust.
Read next: Rust Structs and Methods: Building Custom Data Types →
Quick Knowledge Check
Which of the following scenarios is physically permitted by the exact rules of the Rust Borrow Checker?
- Creating four mutable references (&mut T) to the same data at the exact same time, provided they are in the same scope.
- Holding two immutable references (&T) and one mutable reference (&mut T) concurrently.
- Holding seventy-five immutable references (&T) to the exact same Variable simultaneously. ✓
- Returning a generic reference (&T) to a variable that was allocated and dropped entirely inside the returning function.
Explanation: Rule A of the Borrow Checker states you can have an infinite number of immutable (&T) readers. All other options violate either the exclusive writer rule (Rule B) or safety regarding dangling pointers.
