Sharing Versus Mutation

Learn how Rust enforces a strict separation between shared read access and exclusive write access through references, why that rule prevents entire classes of bugs, and how to work with the borrow checker to structure safe, composable code.

When you hold a shared reference (&T) to a value, Rust guarantees that no other part of the program can modify that value while the reference is alive. A mutable reference (&mut T) works the opposite way: it grants exactly one writer exclusive access and forbids any reader or other writer at the same time. This single rule—shared XOR mutable—is the foundation of Rust's memory safety without a garbage collector.

Understanding why the rule exists and how it shapes real code turns the borrow checker from a gatekeeper you fight into a design tool you rely on.

Two Kinds of Access

Rust divides access through references into two disjoint modes. The type of the reference encodes the permission you have.

  • &T — a shared reference. You can read the data but not change it. Many &T references to the same data can exist simultaneously.
  • &mut T — a mutable, exclusive reference. You can read and write. Only one &mut T can exist for a given piece of data at any time, and no &T can coexist with it.

These permissions are enforced entirely at compile time. The runtime representation of a reference is a plain pointer with no extra bookkeeping.

fn main() {
    let mut message = String::from("hello");
    let r1 = &message;    // shared borrow
    let r2 = &message;    // another shared borrow — allowed
    println!("{r1} and {r2}");
    let r3 = &mut message; // mutable borrow — allowed here because r1 and r2 are no longer used
    r3.push_str(" world");
    println!("{r3}");
}

The example works because the shared references r1 and r2 end their last use before r3 is created. If we had tried to print r1 after the mutable borrow, the compiler would stop us with an error.

The mental model to carry through the rest of this section: a shared reference is a read-only view handed out to an unlimited number of observers. A mutable reference is an exclusive lock that guarantees no other observer is reading or writing while the lock is held.

Why the Rule Exists

Rust's aliasing XOR mutation rule eliminates data races at compile time. A data race is defined by three simultaneous conditions:

  1. Two or more pointers access the same memory location.
  2. At least one pointer writes.
  3. No synchronization mechanism coordinates the accesses.

In languages that allow aliased mutation, data races are a common source of crashes, silently corrupted data, and security vulnerabilities. Rust sidesteps the problem entirely by making it impossible to have two pointers where one is a writer and the others are readers or writers at the same time. If a value is reachable via any &T, no &mut T to it can exist. If a value is reachable via a &mut T, no other reference—shared or mutable—to the same value can exist.

Not just concurrency:

The aliasing restriction applies to single-threaded code as well. It prevents issues like iterator invalidation, where modifying a collection while iterating over it would cause undefined behavior in many languages. In Rust, you cannot obtain a mutable reference to a collection while any iterator (which holds a shared borrow) is alive.

The rule is not a performance optimization; it is a soundness requirement. Without it, the compiler could not guarantee that a &T value does not change under your feet, which would break type safety and allow undefined behavior.

The Borrow Checker in Action

The compiler tracks every reference's scope—the span of code from its creation to its last use—and verifies that overlapping scopes obey the sharing rules. These checks happen during compilation, so there is zero runtime cost.

A simple violation looks like this:

fn main() {
    let mut v = vec![1, 2, 3];
    let r = &v[0];         // shared borrow
    v.push(4);              // mutable borrow — ERROR
    println!("{r}");
}

The error message is specific:

error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable

v.push(4) requires a mutable borrow of v, but the shared borrow r is still in scope because it is used later in the println!. The compiler refuses to compile this code.

This is non-negotiable:

Rust will never allow a shared reference and a mutable reference to the same data to coexist, even if you are certain it is safe in a particular case. The language treats any aliased mutation as undefined behavior potential and rejects it outright. There is no escape hatch through safe code.

The fix is to ensure the shared borrow does not overlap with the mutable borrow. One way is to shorten the shared borrow's scope by using it before the mutation:

fn main() {
    let mut v = vec![1, 2, 3];
    let r = &v[0];
    println!("{r}");        // last use of r
    v.push(4);              // now allowed — r is no longer live
}

This pattern relies on non-lexical lifetimes (NLL), which the Rust compiler has used since 2018. A reference's lifetime does not extend to the end of the enclosing block; it ends after its last use.

Non-Lexical Lifetimes — Sharing After Last Use

The compiler looks at the actual control flow and usage to determine when a reference is no longer needed. This means you can often use a mutable borrow after a shared borrow has effectively ended, even within the same block.

fn main() {
    let mut data = 10;
    let shared = &data;
    let other_shared = &data;
    println!("{shared} {other_shared}");
    // shared and other_shared are no longer used after this line
    let mutable = &mut data;
    *mutable += 5;
    println!("{mutable}");
}

The code compiles because the compiler sees that shared and other_shared are dead before mutable is created. This is a huge practical improvement over early Rust, where the borrow scope was tied strictly to the block in which the reference was declared.

You can test this yourself:

If the code above compiles on any recent Rust edition, you are seeing NLL at work. Try moving the println!("{shared}") after the let mutable line to see the borrow checker refuse it, even though the references exist in the same block.

The key insight for developers: organize your code so that reads happen together, then mutate afterwards. The compiler will often accept the code without any extra scopes or curly braces.

Sharing and Mutation Across Function Boundaries

The same rules apply when references cross function calls. A function that takes &self cannot mutate the struct if another part of the code holds a shared reference to that same struct.

struct Counter {
    count: u32,
}
impl Counter {
    fn read(&self) -> u32 {
        self.count
    }
    fn increment(&mut self) {
        self.count += 1;
    }
}
fn main() {
    let mut counter = Counter { count: 0 };
    let view = &counter;       // shared borrow
    let n = view.read();        // OK: &self method
    // counter.increment();    // ERROR: cannot borrow counter as mutable
    println!("{n}");
}

The borrow checker sees that calling increment on counter requires a mutable borrow, which conflicts with the shared borrow view that is still live. Even though view.read() is an immutable method, it is still a use of the shared reference that keeps it alive.

The solution is to drop the shared reference before mutating, either by restructuring the code or by using a block to confine the borrow.

Splitting Borrows — Borrowing Different Fields

The borrow checker understands struct fields individually. You can hold a shared reference to one field and a mutable reference to a different field of the same struct simultaneously, because the memory is disjoint.

struct Point {
    x: f64,
    y: f64,
}
fn main() {
    let mut p = Point { x: 1.0, y: 2.0 };
    let x_ref = &p.x;       // shared borrow of p.x
    let y_ref = &mut p.y;   // mutable borrow of p.y — OK, different field
    *y_ref = 3.0;
    println!("x: {x_ref}, y: {y_ref}");
}

This works because the compiler can prove that p.x and p.y do not overlap in memory. The same principle extends to methods that take &self and &mut self on different fields, but often you need to split the struct into smaller pieces or use helper functions that borrow only the necessary fields.

Whole-struct borrows block field-level splitting:

If you take a mutable reference to the entire struct, you cannot then take a reference to a single field, even if it is shared. The borrow checker treats &mut p as a lock on all of p. If you need fine-grained access, design your functions to accept separate references to the fields you need.

Common Mistakes and How to Fix Them

Beginners run into three recurring patterns when learning the sharing vs mutation rules.

Mistake 1: Trying to mutate through &T.

fn append_world(s: &String) {
    s.push_str(" world"); // ERROR: cannot borrow `*s` as mutable
}

The signature promises a shared reference, so the caller expects the value will not change. Rust enforces that promise. To mutate, change the parameter to &mut String.

Mistake 2: Creating a mutable reference while holding a shared reference.

let mut items = vec![10, 20, 30];
let first = &items[0];
items.clear(); // ERROR: mutable borrow conflicts with `first`
println!("{first}");

Even though clear does not touch the indexed element directly, it mutates the vector's internal state (length), invalidating first. The borrow checker prevents this entire class of bugs. The fix is to either clone the value before clearing, or drop the shared reference before calling clear.

Mistake 3: Forgetting that mut on a binding and &mut are separate concepts.

let mut x = 5;
let r = &x;    // r is &i32, immutable reference
*r = 10;       // ERROR: cannot assign through &i32

The mut on x allows reassigning x or taking a &mut x, but r is an immutable reference regardless. To mutate through a reference, the reference itself must be &mut.

Interior Mutability — A Controlled Exception

The strict separation of sharing and mutation raises a question: what if you genuinely need to mutate data that is reachable through multiple shared references? Rust provides the interior mutability pattern through types like Cell and RefCell, which move the aliasing check from compile time to runtime.

Cell<T> allows mutation via shared references for Copy types by replacing the entire value atomically. RefCell<T> allows borrowing a mutable reference through a shared reference at runtime, panicking if the borrow rules are violated. Both types uphold Rust's safety guarantees; they merely enforce the rules dynamically instead of statically.

use std::cell::RefCell;
fn main() {
    let shared = RefCell::new(5);
    let r1 = &shared;
    let r2 = &shared;
    *r1.borrow_mut() += 1;
    *r2.borrow_mut() += 1;
    println!("{}", shared.borrow());
}

This compiles and runs, printing 7. The borrow_mut calls succeed because they happen sequentially; if one RefMut guard were held while the other was requested, the program would panic at runtime. Interior mutability is a powerful tool but should be used sparingly—it signals that you are explicitly opting out of compile-time borrow checking for that data.

Prefer compile-time safety:

Reach for RefCell only when the structure of your code genuinely requires shared mutable access. In many cases, restructuring to own data or pass mutable references directly results in simpler, more predictable code.

Structuring Code to Work With the Rules

The borrow checker's restrictions often guide you toward designs that are easier to reason about anyway. Patterns that emerge:

  • Read-first, mutate-later: Collect all necessary data in shared references, use them, then obtain a mutable reference to update state.
  • Use scopes to limit borrows: Wrap temporary borrows in {} to drop them before a mutation.
  • Split data into separate structs: If two parts of your application operate on logically independent data, keep them in separate structs so they can be borrowed independently.
  • Prefer owned values at API boundaries: When a function truly needs exclusive access, consider taking ownership rather than a mutable reference, returning the value if necessary. This simplifies lifetimes.

The phrase "Taking arms against a sea of objects" captures the feeling many developers have when first encountering the rule across a large codebase. The borrow checker forces you to confront the ownership graph of your data. That confrontation is uncomfortable but ultimately productive: it produces a map of who can read and who can write, which is the exact information a maintenance programmer needs to understand a codebase.

Summary

Rust’s sharing-versus-mutation rule enforces a clean separation: a value can be shared immutably with many observers, or it can be mutated exclusively by one, but never both at the same time. This is not a style preference—it is the mechanism that eliminates data races and iterator invalidation without a garbage collector.

The borrow checker implements this rule through static analysis of reference lifetimes. Non-lexical lifetimes make the analysis practical in everyday code, often allowing a shared reference to end early so a mutable borrow can begin. Structuring reads before writes, splitting borrows across fields, and turning to interior mutability only when necessary are the primary techniques for working with the rule rather than against it.