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
- What the Borrow Checker Actually Prevents
- The Aliasing Problem (What C++ Quietly Ignores)
- Understanding Borrow Checker Errors
- Decision Tree
- Common Patterns
- When to Use
RcandRefCell(Carefully) - Lessons for Any Language (Even C++)
- The Real Takeaway
- Quick Reference
- References
Getting Past the Initial Frustration
I spent a good chunk of my first week with Rust fighting the borrow checker. It felt like the compiler was deliberately being difficult—like it had opinions about my code that it shouldn’t have. I’d spend hours refactoring, only to hit another wall. But somewhere around that third or fourth rewrite, it clicked: the compiler wasn’t being difficult. It was pointing at genuine design problems I’d missed.
Here’s what actually shifted my perspective:
The errors aren’t blockers—they’re feedback. When the compiler yells at you, it’s not saying your idea is wrong. It’s saying “two parts of your code are trying to mutate the same data simultaneously, and that’s a real problem.” It’s not a language limitation; it’s a design issue waiting to be fixed.
There are always solutions. And they’re usually better than what you had:
- Pick a single place in your code that actually owns and mutates the data
- Split your data structures so functions only need what they use (not the whole thing)
- Defer mutations—collect changes in a queue and apply them in a controlled phase
RefCell and Rc aren’t escape hatches. I used them early as a way to sidestep the checker, and it never ended well. They’re tools for specific problems, not band-aids for poor design.
What the Borrow Checker Actually Prevents
The Stuff That Gets Stopped
Rust’s borrow checker catches the hard memory bugs—the ones that silently corrupt your program:
- Use-after-free: You try to access memory that’s already been freed
- Double-free: You free the same block twice
- Dangling pointers: You hold a reference to memory that no longer exists
- Data races: Two threads access the same data at the same time without synchronization
- Iterator invalidation: You modify a collection while iterating over it
- Aliased mutation: Multiple parts of your code try to mutate the same thing simultaneously
Here’s a real example—the kind that haunts C++ developers:
// 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 Still Slips Through
Rust won’t save you from these:
- Logic bugs: Off-by-one errors, wrong algorithm, bad business logic
- Deadlocks: You can still create circular lock dependencies
- Memory leaks: If you create cycles with
Rc, they’ll leak - Integer overflow: Won’t catch it in release mode
- Panics: You can still call
unwrap()onNoneor divide by zero - Resource exhaustion: You can still run out of handles or memory
These still compile fine, but they’re broken:
// ✅ 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 This Actually Matters
Here’s the thing: those memory bugs? They’re the hard ones. Microsoft and Google have published numbers showing that roughly 70% of their security vulnerabilities come from memory safety issues. Rust eliminates that entire category at compile time.
You still need tests. You’ll still have bugs. But they’ll be logic bugs you can reason about, not mysterious crashes from memory corruption happening three function calls deep.
The Aliasing Problem (What C++ Quietly Ignores)
In C++, you can write code like this and it’ll compile fine. But it has a ticking time bomb inside:
// C++: Compiles. Might crash later (or not). Who knows?
class GameWorld {
std::vector<Entity> entities;
PhysicsEngine physics;
public:
void update() {
physics.simulate(entities); // Mutates entities
for (auto& e : entities) { // Iterating entities
e.updateBehavior(entities); // Also mutates entities!
}
}
};
The problem isn’t obvious at first glance:
physics.simulate()mutates theentitiesvector- The loop is iterating over that same vector
updateBehavior()might add or remove entities, causing a reallocation- When the vector reallocates, all those references become garbage
This is a classic iterator invalidation bug. In C++, you only find it when:
- The vector gets big enough to reallocate
- Someone actually calls
updateBehavior()in a way that modifies the collection - You have a customer report about a crash
Rust won’t let you write this:
// Rust: Rejected at compile time
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
}
}
}
But here’s the key: the real question isn’t syntax. It’s design.
Who actually owns the right to mutate entities right now? Physics? Each entity during its update? The main loop? They can’t all own it. You have to pick one.
Rust forces this decision upfront. And that’s not a language limitation—that’s forcing clarity.
Understanding Borrow Checker Errors
When you hit a borrow checker error, it usually falls into one of a few patterns. Learning to recognize them makes them much less frustrating.
“Cannot borrow as mutable more than once”
This is the most common one. It means you’re trying to pass the same thing mutably to two different places at once.
struct InputHandler {
mouse: MouseState,
keyboard: KeyboardState,
camera: Camera,
}
impl InputHandler {
fn process(&mut self) {
// This fails—you're passing &mut self to both
// handle_mouse(&mut self.mouse, &mut self.camera);
// handle_keyboard(&mut self.keyboard, &mut self.camera);
// Solution: Destructure to make independence explicit
let Self { mouse, keyboard, camera } = self;
handle_mouse(mouse, camera); // OK!
handle_keyboard(keyboard, camera); // OK!
}
}
What’s happening: Once you destructure, Rust sees that mouse and keyboard are independent. They’re not both borrowing from the same container. This is the error telling you: “These two operations don’t actually need to share ownership of the whole handler—split it up.”
“Borrowed value does not live long enough”
This one shows up when you have a reference that outlives the data it points to.
struct Tag<'a> {
label: &'a str,
}
fn make_tag() -> Tag<'_> {
let s = String::from("session-42");
Tag { label: &s }
// ERROR: s is dropped here, but we're returning a reference to it
}
The problem: s is allocated on the stack inside make_tag(). When the function returns, the stack unwinds and s is gone. But you’re trying to hand back a reference to it. That reference now points to deallocated memory.
Solutions:
// Option 1: Own the data instead of borrowing
struct Tag {
label: String,
}
fn make_tag() -> Tag {
Tag { label: String::from("session-42") }
}
// Option 2: Borrow from the caller
fn make_tag<'a>(label: &'a str) -> Tag<'a> {
Tag { label } // Caller controls the lifetime
}
What this teaches: Lifetimes are saying “who lives longer?” If you can’t answer that clearly, your design needs rethinking.
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
This is my favorite solution for games and complex systems. The idea is simple: don’t mutate while you’re reading.
Instead:
- Phase 1 (Read-only): Iterate through all entities, check their state, decide what needs to happen. Collect all changes into a list.
- Phase 2 (Mutate): Process the list of changes and apply them.
Separating these phases eliminates the aliasing problem entirely:
fn update(&mut self) {
let mut events = Vec::new();
// Phase 1: Only read. No mutations.
for entity in &self.entities {
if entity.should_spawn() {
events.push(SpawnEvent {
position: entity.position,
});
}
}
// Phase 2: Now mutate. No reads of entities happening.
for event in events {
self.handle_spawn(event); // Safe to mutate now
}
}
Why this works:
- While you’re iterating
&self.entities, you’re not mutating them - When you mutate, you’re not iterating
- No concurrent access to the same data
- Predictable, testable order of operations
Game engines use this pattern extensively because it scales beautifully. One master event queue per frame, and you never have aliasing issues.
When to Use Rc and RefCell (Carefully)
Both of these tools let you work around the borrow checker. But they’re not free passes. Use them only when you have a genuine need, not as a band-aid for poor design.
RefCell for interior mutability you control
RefCell lets you mutate through an immutable reference. It panics if you try to borrow mutably while an immutable borrow exists.
// ✅ Good: Internal caching
struct Metrics {
hits: RefCell<u64>,
}
impl Metrics {
fn hit(&self) { // &self, not &mut self
*self.hits.borrow_mut() += 1;
}
}
// ❌ Bad: Shared mutation with unclear ownership
let config = Rc::new(RefCell::new(Config::default()));
module_a(config.clone()); // Who controls it now?
module_b(config.clone()); // Both can change it
// This compiles, but it's chaos. Who's responsible for what?
The first example is good because the RefCell is internal—callers don’t know or care about it. The second is a code smell: two modules now share mutable ownership of the same data, and there’s no clear protocol.
Rc for truly shared read-only data
// ✅ Good: Shared immutable data
let theme = Rc::new(Theme { color: "blue" });
let button = Button { theme: theme.clone() };
let label = Label { theme: theme.clone() };
// Both share the same theme. It's immutable, so no issues.
// ❌ Bad: Shared mutable ownership
let state = Rc::new(RefCell::new(State::default()));
// Now you've recreated garbage collection and shared mutable state.
// The borrow checker is gone. You're back to manual coordination.
Use Rc<T> when you genuinely need multiple owners of the same immutable data. Combine it with RefCell only when necessary, and understand the performance cost (runtime borrow checks are slower than compile-time checks).
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
Lessons for Any Language (Even C++)
The borrow checker isn’t unique to Rust. These principles work in C++, Python, Go, or anything else:
1. Make ownership explicit – Don’t hide it behind raw pointers or ambiguous parameters:
// ❌ What does this function do with the callback? Takes ownership? Borrows it?
void setCallback(std::function<void()>* cb);
// ✅ Clear intent
void setCallback(std::unique_ptr<std::function<void()>> cb); // Takes ownership
void setCallbackRef(std::function<void()>& cb); // Borrows from caller
2. Minimize shared mutable state – Ask yourself:
- Does everything in this class really need to mutate together?
- Can some parts be read-only?
- Can mutations be deferred into a separate phase?
3. Question every shared_ptr – It’s the C++ equivalent of “I don’t know who owns this”:
shared_ptr<Config> config; // Who owns it? Everyone? No one?
// Hard to say. Easy to make mistakes.
unique_ptr<Config> config; // Only one owner. Clear responsibility.
The borrow checker just forces you to answer these questions at compile time instead of debugging them at runtime.
The Real Takeaway
Here’s what changed for me: I stopped seeing borrow checker errors as the compiler being difficult. They’re the compiler asking questions you should be asking yourself:
- Who owns this data? Not vague assumptions—an actual answer.
- When does it get mutated? In one place, or scattered everywhere?
- What depends on what? Is this coupling intentional or accidental?
- Can I simplify this? Copy small values? Use indices? Defer mutations?
These questions improve code in any language. Rust just forces you to answer them before your code runs.
The practical impact:
- 70% fewer memory bugs (the hard category that nobody plans for)
- Clearer architecture (you can’t hide design decisions)
- Better tests (because you’ve thought through ownership upfront)
- Transferable skills (these principles apply everywhere)
When you hit a borrow checker error, take it as feedback, not a blocker. Nine times out of ten, your code will be better after you fix it.
Quick Reference
| Situation | Solution | Tool |
|---|---|---|
| Multiple mutable borrows | Split into independent fields | Destructuring |
| Need data from borrowed struct | Copy small values (IDs, positions) | Copy trait |
| Cross-struct references | Use indices/IDs instead | HashMap<ID, T> |
| Deferred mutations | Collect events, apply later | Vec<Event> |
| Internal caching | Interior mutability | Cell, OnceCell |
| Shared read-only data | Reference counting | Rc<T> |
| Tree with parent links | Break cycles | Rc + Weak |