Rust’s Borrow Checker: How It Prevents Memory Bugs (and Where You Still Need to Be Careful)

Rust’s Borrow Checker: How It Prevents Memory Bugs (and Where You Still Need to Be Careful)

The borrow checker was the most confusing thing I encountered as a Rust beginner coming from C++. It looked like a compiler plus linter at first, but I was wrong. Here I explore how to understand and use it to grasp Rust’s design philosophy

Getting Past the Initial Frustration

The borrow checker frustrated me at first. After a long night of refactoring, I realized: the compiler wasn’t blocking my code—it was exposing design problems early.

Three things that changed my perspective:

  • tip Errors are hints, not blockers. The compiler points at fixable design issues.

  • warning If the compiler complains, it’s usually protecting you: two parts of your code are trying to change the same data at the same time. Fix it by:
  • Choose a single place that mutates the data.
  • Split independent fields so each function only needs what it uses.
  • Queue changes and apply them later (event queue).

  • important Only use RefCell or Rc when you really need them—not as a quick fix.

What the Borrow Checker Actually Prevents

What Rust Prevents: Memory corruption, data races, use-after-free, iterator invalidation
⚠️ What It Doesn’t: Logic bugs, deadlocks, memory leaks (with Rc cycles), panics
🎯 Why It Matters: Eliminates the most dangerous 70% of bugs, forces architectural clarity

✅ What Rust PREVENTS

  • Use-After-Free - Accessing freed memory
  • Double-Free - Freeing memory twice
  • Dangling Pointers - References to deallocated memory
  • Data Races - Concurrent unsynchronized access
  • Iterator Invalidation - Modifying while iterating
  • Aliased Mutation - Multiple mutable access to same data

Example of what Rust catches:

// C++: Compiles, crashes later
std::vector<int> vec = {1, 2, 3};
int* ptr = &vec[0];
vec.push_back(4);  // Reallocates!
*ptr = 10;         // ❌ Dangling pointer - UB
// Rust: Rejected at compile time
let mut vec = vec![1, 2, 3];
let ptr = &vec[0];
vec.push(4);       // ❌ ERROR: cannot borrow as mutable
*ptr = 10;         // while borrowed immutably

⚠️ What Rust DOESN’T Prevent

  • Logic Bugs - Wrong algorithms, off-by-one errors
  • Deadlocks - Thread synchronization issues
  • Memory Leaks - With Rc cycles
  • Integer Overflow - In release mode
  • Panics - Runtime errors from unwrap(), division by zero
  • Resource Exhaustion - Running out of memory/handles

Examples that still compile but can be wrong:

// ✅ Compiles fine, but logic bug
fn calculate_average(numbers: &[i32]) -> i32 {
    numbers.iter().sum::<i32>() / numbers.len() as i32
    // Panics if numbers is empty!
}

// ✅ Compiles fine, but deadlock possible
let mutex1 = Arc::new(Mutex::new(1));
let mutex2 = Arc::new(Mutex::new(2));

// Thread A: locks mutex1, then mutex2
// Thread B: locks mutex2, then mutex1
// = Classic deadlock (still possible in Rust!)

Why It Still Matters

The borrow checker eliminates a whole class of bugs that plague C/C++:

Microsoft and Google data shows ~70% of security vulnerabilities are memory safety issues. Rust eliminates these at compile time.

The borrow checker doesn’t prevent all bugs—it prevents the most dangerous ones. You still need tests, but you’re debugging logic, not memory corruption.

What C++ Hides, Rust Reveals

Aliased Mutation Bug

// C++: Compiles fine, breaks later
class GameWorld {
    std::vector<Entity> entities;
    PhysicsEngine physics;
    
public:
    void update() {
        physics.simulate(entities);      // Mutates entities
        for (auto& e : entities) {       // Concurrent iteration
            e.updateBehavior(entities);  // Can modify vector!
        }
    }
};

What’s happening:

graph TD
    A[update called] --> B[physics.simulate mutates entities]
    A --> C[for loop iterates entities]
    B --> D[Vector may reallocate]
    C --> E[entity.updateBehavior mutates entities]
    D --> F[Iterator invalidation!]
    E --> F
    
    style F fill:#ff6b6b,stroke:#c92a2a,color:#fff
    style D fill:#ffe066,stroke:#f08c00
    style E fill:#ffe066,stroke:#f08c00

Hidden problems:

  • Iterator invalidation (vector reallocation)
  • Order-dependent behavior
  • Race conditions when threaded
  • No clear ownership

Rust Makes It Clear

// Rust: Rejected immediately
impl GameWorld {
    fn update(&mut self) {
        self.physics.simulate(&mut self.entities);
        
        for e in &mut self.entities {
            e.update_behavior(&mut self.entities);  
            // ❌ ERROR: cannot borrow as mutable more than once
        }
    }
}

The real question:

flowchart TD
    A[Borrow checker error] --> B{Who owns mutation<br/>of entities at this moment?}
    B --> C[Physics engine?]
    B --> D[Each entity?]
    B --> E[Game loop?]
    C --> F[They can't ALL own it!]
    D --> F
    E --> F
    F --> G[Choose ONE clear owner]
    G --> H[Options:<br/>1. Phase separation<br/>2. Event queue<br/>3. Copy small data<br/>4. Use indices]
    
    style B fill:#ffe066,stroke:#f08c00
    style F fill:#ff6b6b,stroke:#c92a2a,color:#fff
    style G fill:#51cf66,stroke:#2f9e44
    style H fill:#51cf66,stroke:#2f9e44

The compiler asks: “Who owns mutation at this moment?” This is an architectural decision C++ lets you skip.

Reading Errors as Design Feedback

Pattern 1: Split Borrows

Error:

cannot borrow `game_state` as mutable more than once

Meaning: Your data structure doesn’t match your access patterns.

Fix:

struct InputHandler {
    mouse: MouseState,
    keyboard: KeyboardState,
    camera: Camera,
}

impl InputHandler {
    fn process(&mut self) {
        // This fails
        // handle_mouse(&mut self.mouse, &mut self.camera);
        // handle_keyboard(&mut self.keyboard, &mut self.camera);
        
        // Destructure to show independence
        let Self { mouse, keyboard, camera } = self;
        
        handle_mouse(mouse, camera);
        handle_keyboard(keyboard, camera);  // OK!
    }
}

mouse and keyboard don’t need the whole handler—only their specific data. The error revealed independence.

Pattern 2: Lifetime Contracts

Problem:

struct Tag<'a> {
    label: &'a str,
}

fn make_tag() -> Tag<'_> {
    let s = String::from("session-42");
    Tag { label: &s }
    // ERROR: borrowed value does not live long enough
}

Memory diagram:

sequenceDiagram
    participant Caller
    participant make_tag
    participant Stack
    
    Caller->>make_tag: call make_tag()
    activate make_tag
    make_tag->>Stack: create String "session-42"
    activate Stack
    Note over Stack: String lives here<br/>on the stack
    make_tag->>make_tag: create Tag { label: &s }
    Note over make_tag: Tag references String
    make_tag-->>Caller: return Tag
    deactivate make_tag
    destroy Stack
    Note over Caller: DANGER: Tag references<br/>deallocated memory!<br/>Dangling pointer!

Solutions:

// Option 1: Own the data
struct Tag {
    label: String,
}

fn make_tag() -> Tag {
    Tag { label: String::from("session-42") }
}

// Option 2: Borrow from caller
fn make_tag<'a>(label: &'a str) -> Tag<'a> {
    Tag { label }  // Caller controls lifetime
}

Lifetimes are contracts. Who outlives whom? If you can’t answer, redesign.

Pattern 3: Interior Mutability

When it works:

// ✅ Caching: mutation is internal
use std::cell::OnceCell;

struct Image {
    path: String,
    bytes: OnceCell<Vec<u8>>,  // Lazy load
}

impl Image {
    fn data(&self) -> &[u8] {  // Note: &self, not &mut
        self.bytes.get_or_init(|| load_from_disk(&self.path))
    }
}

When it doesn’t:

// ❌ Observable mutation behind &self
impl Settings {
    fn set_mode(&self, m: Mode) {  // Looks const but isn't!
        self.mode.borrow_mut().replace(m);
    }
}

If callers can see the change, require &mut self. Interior mutability is for internal details only.

Decision Tree

flowchart TD
    A[Borrow checker error] --> B{Real aliasing risk?<br/>Two mutations at once}
    B -->|Yes| C[Redesign:<br/>• Split state<br/>• Copy small data<br/>• Use indices/IDs<br/>• Event queue]
    B -->|No| D{Mutation visible<br/>externally?}
    D -->|Yes| E[Wrong abstraction:<br/>Require &mut self<br/>or refactor phases]
    D -->|No| F[Interior mutability OK:<br/>RefCell, Cell, OnceCell]
    F --> G{Truly shared<br/>ownership?}
    G -->|Yes| H[Use Rc/Arc:<br/>Read-mostly data<br/>Avoid Rc RefCell unless necessary]
    G -->|No| I[Choose single owner:<br/>Pass refs or indices]
    
    style C fill:#ff6b6b,stroke:#c92a2a,color:#fff
    style E fill:#ff6b6b,stroke:#c92a2a,color:#fff
    style F fill:#51cf66,stroke:#2f9e44
    style H fill:#ffe066,stroke:#f08c00
    style I fill:#51cf66,stroke:#2f9e44

Common Patterns

1. Copy Small Data

// Fighting borrows
fn spawn_projectile(&mut self, entity_id: EntityId) {
    let entity = &self.entities[entity_id];
    let proj = Projectile::new(entity.position);
    self.projectiles.push(proj);  // ERROR: already borrowed
}

// Copy what you need
fn spawn_projectile(&mut self, entity_id: EntityId) {
    let position = self.entities[entity_id].position;  // Copy
    let proj = Projectile::new(position);
    self.projectiles.push(proj);  // OK!
}

Copying small data (12 bytes for Vec3) breaks the borrow dependency.

2. Use Indices, Not References

// Cross-references everywhere
struct Player<'a> {
    weapon: &'a Weapon,  // Lifetime hell
}

// Use handles
type WeaponId = usize;

struct Player {
    weapon_id: WeaponId,  // Just an index
}

struct GameWorld {
    players: Vec<Player>,
    weapons: Vec<Weapon>,  // Central storage
}

impl GameWorld {
    fn get_player_weapon(&self, player_id: usize) -> &Weapon {
        let weapon_id = self.players[player_id].weapon_id;
        &self.weapons[weapon_id]
    }
}

Why this works:

  • No lifetime constraints
  • Easy to swap/modify
  • Serialization is simple
  • Clear ownership (store owns data)

3. Event Queue Pattern

How it works:

The event queue pattern is used to avoid aliasing and borrowing issues by separating the decision phase (when you decide what needs to change) from the mutation phase (when you actually apply those changes). In a game engine, for example, entities and the main loop can push events (like spawning or damage) into a queue. The queue collects all events, and then the world processes them in a controlled, predictable order. This ensures that you never mutate data while iterating over it, and all changes happen in a single, well-defined phase.

The diagram below shows how different systems interact with the event queue:

  • Entities: Push events (e.g., SpawnEvent, DamageEvent) into the queue.
  • Loop: Drains the queue, triggering event handling.
  • Queue: Holds all pending events.
  • World: Handles each event, applying mutations.
flowchart TB
    Entities -->|push SpawnEvent| Queue
    Entities -->|push DamageEvent| Queue
    Loop -->|drain| Queue
    Queue -->|handle SpawnEvent| World
    Queue -->|handle DamageEvent| World

picture 0

// ✅ Defer mutations
fn update(&mut self) {
    let mut events = Vec::new();
    
    // Phase 1: Read-only iteration
    for entity in &self.entities {
        if entity.should_spawn() {
            events.push(SpawnEvent { 
                position: entity.position,
                // ... 
            });
        }
    }
    
    // Phase 2: Apply mutations
    for event in events {
        self.handle_spawn(event);
    }
}

Separate “read & decide” from “mutate & apply”. This is how game engines avoid aliasing issues.

When You Need to Break the Rules

RefCell: Runtime Borrow Checking

// Good: Internal caching
struct Metrics {
    hits: RefCell<u64>,
}

impl Metrics {
    fn hit(&self) {
        *self.hits.borrow_mut() += 1;
    }
}

// Bad: Hiding shared mutation
let config = Rc::new(RefCell::new(Config::default()));
module_a(config.clone());  // Who decides?
module_b(config.clone());  // Unclear!

Rc: Shared Ownership

// Good: Shared read-only data
let theme = Rc::new(Theme { color: "blue" });
let button = Button { theme: theme.clone() };
let label = Label { theme: theme.clone() };

// Bad: Shared mutation everywhere
let state = Rc::new(RefCell::new(State::default()));
// Now everyone can mutate—responsibility lost!

Breaking Reference Cycles

Pattern for tree structures:

graph TD
    Parent[Parent Node<br/>Rc RefCell Node] -->|Rc owning| Child1[Child 1<br/>Rc RefCell Node]
    Parent -->|Rc owning| Child2[Child 2<br/>Rc RefCell Node]
    Child1 -.->|Weak non-owning| Parent
    Child2 -.->|Weak non-owning| Parent
    
    style Parent fill:#51cf66,stroke:#2f9e44
    style Child1 fill:#74c0fc,stroke:#1971c2
    style Child2 fill:#74c0fc,stroke:#1971c2
  • Rc (Strong): Keeps data alive
  • Weak: Doesn’t keep data alive, breaks cycles
  • Parent owns children → when parent drops, children can be freed
  • Children don’t own parent → no circular reference

What This Means for C++ Developers

These lessons transfer to any language:

  1. Make ownership explicit
    // ❌ Unclear
    void setCallback(std::function<void()>* cb);
       
    // ✅ Clear ownership
    void setCallback(std::unique_ptr<std::function<void()>> cb);
    void setCallbackRef(std::function<void()>& cb);  // Caller owns
    
  2. Minimize shared mutable state
    • Can one component be read-only?
    • Can mutations be queued and batched?
  3. Question every shared_ptr
    • Is this truly shared ownership?
    • Or lazy design?

These questions matter in any language. Rust just forces you to answer upfront.

Summary

The borrow checker forces architectural clarity.

Questions it makes you ask:

  • Who owns this data?
  • When does mutation happen?
  • What depends on what?
  • Is this coupling intentional?

Impact:

graph TB
    A[Level 1: Safety<br/>Eliminates 70% of bugs<br/>No use-after-free<br/>No data races] --> B[Level 2: Design<br/>Forces clear ownership<br/>Makes dependencies explicit<br/>Prevents hidden coupling]
    B --> C[Level 3: Thinking<br/>Architectural mindset<br/>Questions assumptions<br/>Transferable to any language]
    
    style A fill:#51cf66,stroke:#2f9e44
    style B fill:#74c0fc,stroke:#1971c2
    style C fill:#ffe066,stroke:#f08c00

When the compiler says “no,” it’s revealing a design clarity issue—not a language limitation.

Quick Reference

SituationSolutionTool
Multiple mutable borrowsSplit into independent fieldsDestructuring
Need data from borrowed structCopy small values (IDs, positions)Copy trait
Cross-struct referencesUse indices/IDs insteadHashMap<ID, T>
Deferred mutationsCollect events, apply laterVec<Event>
Internal cachingInterior mutabilityCell, OnceCell
Shared read-only dataReference countingRc<T>
Tree with parent linksBreak cyclesRc + Weak

References