Skip to content
← All Rust articles

Rust explainer

Understanding borrow-checker errors

About 6 min to read. Allow extra time to work through examples.

Understand what a borrow error protects, express non-overlapping access clearly, and distinguish compiler guarantees from unsafe-code responsibilities.

This article uses Rust. You can make it your practice mode to browse related content across the site.

A borrow error identifies a conflict between operations on the same value. The useful starting point is to find those operations: where the borrow begins, what needs the reference later, and what happens in between.

Sometimes the conflict exposes a possible memory-safety bug. Sometimes the intended operations are safe, but their relationship is not expressed in a form the compiler accepts. Both cases deserve a close look at the code.

Start with the value that must stay valid

This complete example is intentionally rejected:

fn main() {
    let mut values = vec![10, 20];
    let first = &values[0];
    values.push(30);
    println!("{first}");
}

first refers to an element inside the vector. push needs mutable access and may reallocate the vector's storage. The later print still needs the reference. Rust rejects the overlapping immutable and mutable borrows with E0502, preventing code that could use a reference into the old allocation.

If you only need the integer, copy it before modifying the vector:

fn main() {
    let mut values = vec![10, 20];
    let first = values[0];
    values.push(30);
    assert_eq!(first, 10);
    assert_eq!(values, [10, 20, 30]);
}

Here first is an independent integer. For a non-Copy value, consider whether you need a clone, an owned value moved out safely, or simply a different order of operations. Choose based on what the program needs.

What the checker can establish

Rustc checks borrowing on MIR, an intermediate representation of the program's control flow. Its analyses track moves, initialization and the points where references must remain valid. This is why a borrow can often end before the enclosing source-code block does.

“A borrow ends at its last use” is a useful starting rule, not a complete description. Returned references, captured values and destructors can also impose lifetime requirements. The compiler computes constraints; it does not predict every possible runtime behavior exactly.

Function signatures matter too. At an ordinary function call, the caller relies on the declared types and lifetimes rather than a custom summary of every field the callee happens to touch. The callee's body is checked separately. A method taking &mut self therefore requests mutable access to the receiver, even if its body currently updates only one field.

The compiler development guide describes these analysis stages if you want the implementation details.

Express separate access explicitly

The checker does not establish every relationship between arbitrary slice indices. When you need mutable access to two regions, use an API that supplies that relationship:

fn main() {
    let mut values = [10, 20, 30, 40];
    let (left, right) = values.split_at_mut(2);
    left[0] += right[0];
    right[0] = 0;
    assert_eq!(values, [40, 20, 0, 40]);
}

split_at_mut returns non-overlapping slices. It checks the split point and panics if the point exceeds the length. Its implementation must uphold the safety requirements of the returned references; a signature alone cannot make an incorrect unsafe implementation sound.

Other APIs express related relationships:

Need Useful approach
Mutable access to separate slice elements get_disjoint_mut checks bounds and overlap
Visit each slice element mutably iter_mut yields separate element references
Look up or insert a map entry The map's entry API manages one entry operation
Move a field out while leaving a valid replacement mem::take or mem::replace
Borrow separate struct fields Access or destructure the fields directly

Mutable iteration does not require releasing each element reference before obtaining the next one. This also works:

fn main() {
    let mut values = [1, 2];
    let mut items = values.iter_mut();
    let first = items.next().unwrap();
    let second = items.next().unwrap();
    *first += *second;
    *second = 0;
    assert_eq!(values, [3, 0]);
}

Both references coexist because they refer to different elements. The iterator must preserve that property as it advances.

Treat rejection as information

Borrow checking is conservative: an intended operation can be safe yet rejected in a particular form. That does not mean every rejection is a compiler defect, or that every limitation will remain forever. Compiler analyses can improve. Nor does accepted code establish that your algorithm is correct: it can still return the wrong answer, panic or deadlock.

Try these questions when reading a diagnostic:

  1. Could the intervening operation invalidate a reference or require conflicting access?
  2. Can you use the reference earlier, retain an independent value, or narrow the data a helper borrows?
  3. Does a safe library API express the required relationship?
  4. If the reason remains unclear, can you reduce the code to a small example and check the relevant diagnostic documentation or compiler issue?

These are debugging steps, not an exhaustive classification of errors or a claim about how often each cause occurs.

What changes inside unsafe

An unsafe block permits operations such as dereferencing a raw pointer. It does not disable ordinary type or borrow checking. The programmer must establish the safety conditions of each unsafe operation, and a safe wrapper must uphold its contract for every valid safe caller.

A // SAFETY: comment should explain why the conditions hold at that point: for example, what keeps a pointer valid and why an access does not overlap another mutable access. The comment helps review; its presence is not a machine-checked proof. Testing and tools can find mistakes, but passing tests alone does not prove all possible uses safe.

In Rust 2024, unsafe_op_in_unsafe_fn warns by default about unsafe operations inside an unsafe function without an explicit unsafe block. A project can deny that lint, but the edition does not itself make every instance a hard error. The edition guide explains the distinction between declaring caller obligations and performing unsafe operations in the body.

Borrow checking and Clippy

Borrow errors enforce language rules and cannot be silenced with a lint allowance. Clippy provides configurable checks covering correctness, performance and style. Some identify likely bugs; others suggest clearer code. A Clippy allowance does not remove a borrow error, and suppressing a warning does not establish that the code is correct. This site's grading rules may also reject lint allowances that a personal project permits.

What formal proofs cover

RustBelt provides a machine-checked safety proof for a language modeling a realistic subset of Rust, together with a method for verifying unsafe library abstractions. That is evidence about a formal model and the abstractions verified within it. It is not a proof that every version of rustc and every library implementation is free of bugs.

Check your understanding Revisit one borrowing exercise. What did its repair change? Show answer

In copying out of a borrow, compare retaining an integer with retaining a reference into a vector. In splitting a slice, identify which elements each returned slice contains.

Explain the repair in terms of the data: what remains borrowed, what can change, and why the two operations can now coexist. You do not need to label the original error as a failure by either you or the compiler.

step 24 of 24

Ownership II: Borrowing and the Borrow Checker

You've reached the last step

Revisit the track to review its lessons and exercises, or explore another topic.

Explore more tracks