References in Depth
A detailed look at Rust references as values, how the borrow checker guarantees safety, lifetime parameters, and the rules for sharing versus mutation.
Overview of References in Depth
After absorbing the basics of ownership and borrowing, you know that references let you access a value without taking ownership. But references themselves are not an abstract permission—they are values with distinct types, copy semantics, and strict rules that directly shape how you structure Rust code. This section takes you beyond the surface: you will see references as first-class values, understand the compile‑time machinery that keeps them safe, learn how lifetime parameters describe borrow relationships, and explore the rigorous interplay between shared and mutable access.
You will come away with a mental model that turns the borrow checker from an adversary into a predictable tool, reducing the guesswork when you write functions that take and return references.
References as Values
A Rust reference—whether &x or &mut x—is a value. It occupies memory, can be stored in variables, passed to functions, and returned from them, all under the same ownership system that governs every other value. The type of a reference spells out what it points to: &i32 is a shared reference to an i32, while &mut String is a unique reference that permits mutation. Like any safe pointer, a reference carries the address of the data it borrows, but the compiler eliminates the danger of dangling pointers through lifetime enforcement.
Because references are values, they obey the same assignment and scope rules you already know—with one important twist. Shared references (&T) implement the Copy trait. That means assigning one reference to another variable does not move it; both variables remain usable. Mutable references (&mut T) are not Copy, which prevents accidental duplication that would break the exclusivity guarantee.
let num = 42;
let r = #
let s = r; // r is Copy — no move
println!("{} {}", r, s); // r and s are both valid
Here r is a reference to num. Assigning r to s copies the address, so both references point to the same integer. This is not a move of the owned value; num remains untouched. You can keep reading through r because &i32 is Copy. If you tried the same with a String, the original variable would move and become invalid—but references behave differently by design.
References are copy (shared ones):
Shared references (&T) are Copy, so you can freely duplicate them without worrying about ownership transfer. This is why you can pass a &T to a function and still use it afterward. Mutable references are not Copy—duplicating a mutable reference would create two unique accesses, which is forbidden.
Dereferencing: reaching the underlying value
To get the value a reference points to, you use the dereference operator *. Writing *r follows the pointer and gives you the i32 that r borrows. In practice, Rust often dereferences for you automatically through deref coercion. When you call a method on a reference, the compiler will follow as many levels of indirection as needed until it finds a matching method on the target type. This removes the friction that would otherwise come from constantly writing *.
let v = vec![1, 2, 3];
let r = &v;
println!("Length: {}", r.len()); // auto-deref from &Vec<i32> to Vec<i32>
r is &Vec<i32>, but calling .len() on it works because the compiler sees that Vec<i32> has a len method and inserts the dereference for you. The same happens when you pass a reference to a function that expects a reference to a different type, as long as the deref chain is valid—for example, &String will automatically become &str when needed.
Explicit deref when you must:
While auto-deref covers most method calls and function arguments, you will still use explicit * when you need to assign a new value through a mutable reference (*r = 5) or when pattern matching.
Reference Safety
The defining promise of Rust references is that they are safe by construction. A reference must never outlive the data it points to, and at any given moment the code must respect the aliasing rules the compiler enforces. The borrow checker validates these guarantees at compile time, with zero runtime cost. Grasping how the borrow checker thinks about references is the key to writing code that compiles on the first try.
The dangling reference problem
If a reference could point to memory that has been freed or reused, you would have a dangling reference—a bug that in many languages manifests as crashes, silent data corruption, or security vulnerabilities. Rust catches every potential dangling reference before the program ever runs.
let r;
{
let x = 5;
r = &x;
} // x dropped here
println!("{}", r);
The compiler rejects this code with an error: error[E0597]: ``x`` does not live long enough. The inner scope creates x and immediately borrows it. When that scope ends, x is deallocated, but r still holds a pointer to the freed stack slot. The borrow checker sees that the lifetime of the reference r would extend beyond the lifetime of x, so it refuses to compile. There is no runtime check—the analysis happens entirely at compile time.
Never override this error without understanding the memory layout:
The borrow checker is not being overly conservative; it is identifying a real use‑after‑free bug. If you ever reach for unsafe to silence this error, make sure you can prove that the reference will be valid for its entire use window. The compiler is almost always right.
How the borrow checker works
The borrow checker reasons about references by assigning each one a lifetime—a region of code where that reference is guaranteed to be valid. For a reference &x, its lifetime must be fully contained within the lifetime of x. In practice, the compiler examines every creation and use of a reference, ensuring no reference is accessed after its referent has been dropped.
Beyond dangling prevention, the borrow checker also enforces the sharing versus mutation rule: you cannot mutate a value while any shared reference to it exists, and you cannot have more than one mutable reference to the same value at the same time. This rule eliminates data races at compile time, even in single-threaded code. We will examine that rule more closely in a dedicated section later on.
Non-lexical lifetimes (NLL)
Early versions of Rust relied on purely lexical scopes—the region delimited by { and }—to bound the lifetime of a reference. That model was sometimes too strict: a mutable borrow could be considered alive until the end of the block even if it was never used again, blocking other borrows that were logically safe. Non‑lexical lifetimes, introduced in Rust 2018, relaxed this by tracking the actual last point of use of each reference. As soon as a reference is no longer used, its borrow ends, allowing new borrows earlier.
let mut data = vec![1, 2, 3];
let r = &mut data;
r.push(4); // last use of r
// NLL: mutable borrow ends here
let s = &data; // now allowed
println!("{}", s[0]);
Without NLL, the mutable borrow r would remain active until the closing }, making s impossible. NLL understands that after r.push(4) the borrow is effectively dead, so creating the shared reference s is safe.
NLL is not a license to overlap borrows:
NLL shortens borrows that are no longer used, but it does not allow a mutable reference to be used again after an immutable borrow has been created. If you inserted a use of r after s, the compiler would still reject the code. The rule “no aliasing while mutation is possible” remains absolute.
Lifetime Parameters
When a function takes references as parameters and returns a reference, the compiler often needs help to connect the lifetimes of the inputs with the lifetime of the output. That is where lifetime parameters enter the picture. A lifetime parameter is a label that represents a region of validity—it does not change how long anything lives; it merely describes the relationship among several references so the borrow checker can verify safety.
Lifetime parameters follow the same syntactic pattern as generic type parameters: they are declared inside angle brackets after the function name, with a name that starts with an apostrophe, typically 'a, 'b, and so on. The annotation &'a i32 reads “a reference to an i32 with lifetime 'a.”
Why function signatures need lifetimes
Take a function that should return the longer of two string slices:
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() { x } else { y }
}
This will not compile. The compiler sees two input references and one output reference, but it cannot determine which input the output borrows from. The body could return either x or y. The borrow checker therefore demands that the signature explicitly state how the return lifetime relates to the input lifetimes. That relationship is expressed by giving all three references the same lifetime parameter:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
Now the signature says: “The returned reference is valid for as long as both x and y are valid.” At the call site, the compiler checks that there is some concrete lifetime 'a that satisfies this constraint—usually the intersection of the two actual lifetimes. If the caller tries to use the return value beyond the shorter of the two, compilation fails.
One lifetime parameter means the shorter one wins:
When you bind both input lifetimes to 'a, the effective lifetime of the returned reference is the smaller of the two. If x is a long‑lived string and y is a short‑lived one, the result will only live as long as y. The compiler will prevent you from storing the result in a scope that outlives the shorter input.
Lifetime elision
In many common patterns, Rust can infer the lifetimes without explicit annotations. This feature is called lifetime elision. The compiler applies a few deterministic rules; the most important ones are:
- Each input reference gets its own lifetime parameter.
- If there is exactly one input lifetime, it is assigned to all output lifetimes.
- If there are multiple input lifetimes but one of them is
&selfor&mut self, the lifetime ofselfis assigned to all output lifetimes (applies to methods).
That is why fn first_word(s: &str) -> &str compiles without any annotation: there is exactly one input lifetime, so the output inherits it. Elision covers the vast majority of everyday function signatures; you will write explicit lifetimes mainly when returning one of several inputs or when implementing traits with lifetime‑parameterized methods.
Lifetimes in struct definitions
If a struct stores a reference, the struct itself must be annotated with a lifetime parameter. This ensures that any instance of the struct cannot outlive the borrowed data it holds.
struct Excerpt<'a> {
part: &'a str,
}
When you construct an Excerpt, the concrete lifetime 'a is tied to the string slice you supply. The compiler will then prevent you from using the struct after the original string has been dropped. The lifetime parameter becomes part of the struct’s type, so Excerpt<'static> and Excerpt<'a> are distinct types if the lifetimes differ.
We will explore lifetimes—including elision rules, struct lifetimes, and complex constraints—in far greater detail in the next subtopic, Validating References with Lifetimes. For now, the essential takeaway is that lifetime parameters are a way to label how long a reference remains valid and to convey that information to the borrow checker when the compiler cannot figure it out alone.
Sharing Versus Mutation
References in Rust come in two flavours: shared (&T) and mutable (&mut T). This distinction is not simply about mutability; it enforces a compile‑time invariant that prevents data races and iterator invalidation before any code runs.
The core rule: one writer XOR many readers
For any given piece of data, Rust guarantees that you can have either one mutable reference or any number of shared references—never both at the same time. This is sometimes phrased as “multiple readers XOR single writer.” The borrow checker imposes this rule statically, so the guarantee holds in single‑threaded and multi‑threaded code alike.
let mut data = 10;
let r1 = &data;
let r2 = &data; // fine: multiple shared borrows
println!("{} {}", r1, r2);
let w = &mut data; // error: cannot borrow `data` as mutable
// because it is also borrowed as immutable
Here the two shared references are still active when w is created, so the compiler rejects the mutable borrow. Even though reading through r1 and r2 is harmless, Rust forbids the simultaneous existence of shared and mutable references to prevent the possibility of a mutable reference changing a value while another reference is reading it.
This is a hard compilation error:
Violating the XOR rule is not a warning; it is a compile‑time error. Safe Rust provides no escape hatch to ignore this rule—you cannot accidentally create a data race.
Mutable reference exclusivity
A mutable reference gives you exclusive access to the value. While a mutable reference is alive, no other reference—mutable or shared—can exist to the same value. This exclusivity is what makes mutation safe: you can modify the value through the mutable reference, and no other part of the program can observe the value in an intermediate state.
fn modify(val: &mut i32) {
*val += 1;
}
let mut x = 5;
modify(&mut x);
println!("{}", x); // 6
During the call to modify, the mutable reference is the only way to reach x. As soon as modify returns, the mutable borrow ends, and the shared access in println! is allowed again. The borrow checker enforces this sequencing with non‑lexical lifetimes, which means it can often end the mutable borrow before the end of the enclosing block if the reference is no longer used.
Borrow scopes and non‑lexical lifetimes
Non‑lexical lifetimes work in your favour here, too. The compiler tracks the last use of a mutable borrow and allows new shared borrows immediately after that point, even though the mutable borrow was declared in the same lexical scope.
let mut v = vec![1, 2, 3];
let r = &mut v;
r.push(4);
// NLL: mutable borrow ends here because r is not used again
let s = &v; // allowed
println!("{:?}", s);
As soon as the mutable reference is no longer needed, its borrow is released, making room for a shared reference. This often eliminates the need to introduce artificial blocks just to shorten a borrow.
Common mistake: holding a reference while mutating
A typical beginner pitfall is holding an immutable reference to a collection and then attempting to mutate the collection. The compiler rejects this even when the mutation might not seem to affect the borrowed element, because Rust does not analyze what you mutate—it only sees that a mutable access conflicts with an existing borrow.
let mut list = vec![1, 2, 3];
let first = &list[0]; // immutable borrow
list.push(4); // mutable borrow — error
println!("{}", first);
Even though the code might feel safe to a human reader, push could reallocate the vector’s buffer, invalidating the pointer behind first. Rust’s borrow checker eradicates this class of bugs entirely at compile time.
Iterator invalidation is also prevented:
The same mechanism protects iterators. If you borrow a collection immutably to iterate over it, you cannot modify the collection until the iterator is dropped. This eliminates the dangling‑iterator bugs that plague other languages.
Interior mutability: a controlled exception
There are times when you genuinely need to mutate data behind a shared reference—for instance, in a cache or a reference‑counted value. Rust provides types like RefCell<T> and Mutex<T> that offer interior mutability. These types shift the XOR enforcement from compile time to runtime, allowing mutation through shared references while still guaranteeing no data races. Interior mutability is an advanced topic explored later in the book; the important thing now is to know that the “one writer or many readers” rule is the default and that any deviation must be opted into explicitly.
Summary
References in Rust are far more than safe pointers—they are values that carry lifetime information and must obey strict aliasing rules. By understanding references as copyable values, the borrow checker’s prevention of dangling references, lifetime parameters as descriptive labels, and the XOR rule for sharing and mutation, you gain the ability to reason about memory safety without runtime overhead.
- A reference is a value with a specific type (
&Tor&mut T); shared references areCopy. - The borrow checker ensures that a reference never outlives the data it points to, statically.
- Lifetime parameters describe how the lifetimes of references in function signatures relate to each other; they do not extend or shorten any lifetime.
- The rule of “one mutable reference XOR many shared references” eliminates data races and iterator invalidation at compile time.
These concepts are the foundation on which Rust’s zero‑cost safety guarantees are built. With the principles from this chapter internalized, you are ready to write functions that the borrow checker will accept without a fight.