Rust Ownership Debugging Case Study

Key takeaways

Solve real Rust ownership, borrowing, and lifetime errors beginners hit: reading borrow checker messages, RefCell, Rc, Arc, and multithreaded patterns—without fighting the compiler.

Introduction

“It works in C++—why not in Rust?” is something you hear a lot when learning Rust. This article uses real ownership errors to show how to understand the borrow checker and fix them.

Most of these errors are not the compiler being pedantic. The equivalent C++ code often compiles and then fails at runtime: an iterator invalidated by a push_back, a reference to a destroyed local, a data race on a counter. Rust rejects the program before it runs, and the error message usually tells you exactly which two uses conflict. The skill to build is reading that message as a description of a real hazard, then choosing between restructuring the code, copying data, or opting into shared ownership. In my experience the first option is right far more often than beginners expect; reaching straight for clone() or Rc<RefCell<T>> makes the error go away but hides a design question.

Each case below shows the failing code, the compiler’s message, why the rule exists, and several fixes with their trade-offs.


Case 1: “cannot move out of borrowed content”

Problem code

struct User {
    name: String,
    email: String,
}

fn process_users(users: &Vec<User>) {
    for user in users {
        let name = user.name; // ❌ cannot move out of `user.name`
        println!("Processing: {}", name);
    }
}

Error message

error[E0507]: cannot move out of `user.name` which is behind a shared reference
  --> src/main.rs:8:20
   |
8  |         let name = user.name;
   |                    ^^^^^^^^^ move occurs because `user.name` has type `String`, which does not implement the `Copy` trait

Why is this an error?

  • users is an immutable borrow (&Vec<User>)
  • user.name is a String (owned type)
  • let name = user.name tries to move ownership out
  • You cannot move out of borrowed data!

If the move were allowed, the Vec that the caller still owns would contain a User whose name had been taken away, and when the caller later dropped the vector, the string would be freed twice. Copy types such as i32 do not have this problem because copying them duplicates the value, which is why let id = user.id; compiles and let name = user.name; does not.

Fixes

// Option 1: use references only
fn process_users(users: &Vec<User>) {
    for user in users {
        let name = &user.name; // ✅ borrow
        println!("Processing: {}", name);
    }
}

// Option 2: clone
fn process_users(users: &Vec<User>) {
    for user in users {
        let name = user.name.clone(); // ✅ copy the string
        println!("Processing: {}", name);
    }
}

// Option 3: take ownership
fn process_users(users: Vec<User>) { // remove &
    for user in users {
        let name = user.name; // ✅ move is allowed
        println!("Processing: {}", name);
    }
}

Borrowing (option 1) is the default choice whenever you only read the data; it costs nothing. Cloning (option 2) allocates a new String per user, which is fine for a handful of items and a real cost inside hot loops. Taking ownership (option 3) is right when the caller is done with the vector, for example when converting Vec<User> into another structure. A fourth option is std::mem::take(&mut user.name), which moves the string out and leaves an empty one behind, but it requires &mut access. Also note that &Vec<User> as a parameter is usually written &[User], which accepts slices and arrays as well; Clippy suggests this with ptr_arg.


Case 2: “cannot borrow as mutable more than once”

Problem code

struct ChatRoom {
    users: Vec<User>,
    messages: Vec<String>,
}

impl ChatRoom {
    fn log(&mut self, msg: String) {
        self.messages.push(msg);
    }

    fn broadcast(&mut self, sender: &User) {
        let msg = format!("{}: Hello", sender.name);
        
        // ❌ cannot borrow `*self` as mutable more than once
        for user in &mut self.users {
            user.notify();
            self.log(msg.clone()); // 💥 second mutable borrow (of all of self)!
        }
    }
}

Error message

error[E0499]: cannot borrow `*self` as mutable more than once at a time
  --> src/main.rs:16:13
   |
14 |         for user in &mut self.users {
   |                     ---------------
   |                     |
   |                     first mutable borrow occurs here
   |                     first borrow later used here
15 |             user.notify();
16 |             self.log(msg.clone());
   |             ^^^^^^^^^^^^^^^^^^^^^ second mutable borrow occurs here

Why is this an error?

  • &mut self.users is the first mutable borrow, alive for the whole loop
  • self.log() takes &mut self, a second mutable borrow of the entire struct, which includes users
  • Rust allows only one mutable reference to the same data at a time

The detail that confuses people is that writing self.messages.push(msg.clone()) directly inside the same loop compiles. The borrow checker tracks borrows per field when it can see the field accesses, so self.users and self.messages are disjoint. A method signature, however, only says “I need &mut self”; the compiler does not look inside log to see that it only touches messages. The rule protects against a real bug: if log pushed to self.users instead, the loop’s iterator would be invalidated mid-iteration, exactly the C++ push_back-while-iterating crash.

Fixes

Here is the broadcast implementation:

// Option 1: split borrows across fields
impl ChatRoom {
    fn broadcast(&mut self, sender: &User) {
        let msg = format!("{}: Hello", sender.name);
        
        let users = &mut self.users;
        let messages = &mut self.messages;
        
        for user in users {
            user.notify();
            messages.push(msg.clone()); // ✅ different fields, no &mut self call
        }
    }
}

// Option 2: push the message before the loop
impl ChatRoom {
    fn broadcast(&mut self, sender: &User) {
        let msg = format!("{}: Hello", sender.name);
        self.messages.push(msg.clone()); // add first
        
        for user in &mut self.users {
            // no longer borrowing messages here
            user.notify();
        }
    }
}

// Option 3: use indices
impl ChatRoom {
    fn broadcast(&mut self, sender: &User) {
        let msg = format!("{}: Hello", sender.name);
        
        for i in 0..self.users.len() {
            self.users[i].notify();   // borrow ends at the end of this statement
            self.log(msg.clone());    // ✅ no borrow of users is alive here
        }
    }
}

Option 1 makes the disjointness explicit and is what I reach for first. Option 2 reorders the work so the two mutations never overlap, which is often the cleanest when the order does not matter. Option 3 works because each self.users[i] borrow lasts only for one statement, so self.log sees no outstanding borrow; the cost is a bounds check per access and the fact that the compiler no longer protects you if log changes the length of users (you would get an index panic instead of a compile error). A fourth pattern for larger structs is to split the state into sub-structs and give helpers only the part they need, for example fn log(messages: &mut Vec<String>, msg: String).


Case 3: “lifetime may not live long enough”

Problem code

struct UserCache {
    users: Vec<User>,
}

impl UserCache {
    fn find(&self, name: &str) -> Option<&User> {
        self.users.iter().find(|u| u.name == name)
    }
    
    fn get_or_create(&mut self, name: &str) -> &User {
        // ❌ lifetime error
        if let Some(user) = self.find(name) {
            return user; // 💥 borrow active but &mut self needed below
        }
        
        self.users.push(User { name: name.to_string(), email: String::new() });
        self.users.last().unwrap()
    }
}

Error message

error[E0502]: cannot borrow `self.users` as mutable because it is also borrowed as immutable
  --> src/main.rs:15:9
   |
12 |         if let Some(user) = self.find(name) {
   |                             ---- immutable borrow occurs here
13 |             return user;
   |                    ---- returning this value requires that `*self` is borrowed for `'1`
...
15 |         self.users.push(...);
   |         ^^^^^^^^^^^ mutable borrow occurs here

This one is surprising because the code is actually safe: when find returns Some, the function returns immediately and never reaches push. The borrow checker in stable Rust (non-lexical lifetimes, NLL) cannot express “the borrow lasts until the end of the function only on this branch”. Because user is returned, its borrow must live as long as the returned &User, and NLL extends that requirement to the whole function body, which then conflicts with push. This is the well-known “NLL problem case #3”, and the experimental next-generation borrow checker, Polonius, accepts the original code. Until that ships, the fixes restructure the code so that no borrow crosses the branch.

Fixes

// Option 1: use an index
impl UserCache {
    fn get_or_create(&mut self, name: &str) -> &User {
        if let Some(idx) = self.users.iter().position(|u| u.name == name) {
            return &self.users[idx]; // ✅ fresh borrow
        }
        
        self.users.push(User { name: name.to_string(), email: String::new() });
        self.users.last().unwrap()
    }
}

// Option 2: Entry API (HashMap)
use std::collections::HashMap;

struct UserCache {
    users: HashMap<String, User>,
}

impl UserCache {
    fn get_or_create(&mut self, name: &str) -> &User {
        self.users.entry(name.to_string())
            .or_insert_with(|| User { name: name.to_string(), email: String::new() })
    }
}

The index version works because position returns a plain usize and the immutable borrow ends as soon as it returns; the new borrow &self.users[idx] is created on the returning branch only. It does two lookups on a hit and is O(n), which is fine for small caches. If lookups by name are the main operation, a HashMap is the better structure anyway, and its entry API is designed for exactly this “get or insert” shape: one hash lookup, and the returned reference is tied to &mut self. Note that entry needs an owned key, so name.to_string() allocates even when the user already exists.


Case 4: “cannot return reference to local variable”

Problem code

fn get_default_user() -> &User {
    let user = User {
        name: "Guest".to_string(),
        email: "[email protected]".to_string(),
    };
    &user // ❌ `user` is dropped at the end of the function
}

Error message

error[E0515]: cannot return reference to local variable `user`
  --> src/main.rs:8:5
   |
8  |     &user
   |     ^^^^^ returns a reference to data owned by the current function

Strictly, the signature fn get_default_user() -> &User fails even earlier with error[E0106]: missing lifetime specifier, because a returned reference with no input references has nothing to borrow from; the compiler then suggests &'static User. Either way the message points at the same hazard as a dangling pointer in C++: user lives on the stack frame that is destroyed when the function returns. In C++ this compiles with at most a warning (-Wreturn-local-addr) and reads garbage later.

Fixes

// Option 1: return ownership
fn get_default_user() -> User {
    User {
        name: "Guest".to_string(),
        email: "[email protected]".to_string(),
    }
}

// Option 2: 'static lifetime (only works with const-constructible values)
fn get_default_user() -> &'static User {
    static DEFAULT: User = User {
        name: String::new(),  // String::new() is a const fn, so this compiles
        email: String::new(), // but "Guest".to_string() would not
    };
    &DEFAULT
}

// Option 2b: lazy_static
use lazy_static::lazy_static;

lazy_static! {
    static ref DEFAULT_USER: User = User {
        name: "Guest".to_string(),
        email: "[email protected]".to_string(),
    };
}

fn get_default_user() -> &'static User {
    &DEFAULT_USER
}

// Option 3: heap allocation with Box
fn get_default_user() -> Box<User> {
    Box::new(User {
        name: "Guest".to_string(),
        email: "[email protected]".to_string(),
    })
}

Returning the value (option 1) is almost always the answer. Moving a User out of a function copies three words per String (pointer, length, capacity), not the string data, and the compiler often constructs it directly in the caller’s slot. C++ developers tend to avoid returning by value out of habit; in Rust it is the idiomatic default. Box<User> (option 3) adds a heap allocation for no benefit here; it only makes sense for large or recursive types or trait objects.

A 'static reference fits when the default really is one shared, immutable value. A plain static must be built by const evaluation, which rules out to_string(). lazy_static! or its successor once_cell::sync::Lazy (in the standard library as std::sync::LazyLock since Rust 1.80) initializes on first access instead. Prefer LazyLock in new code, since it needs no macro or extra crate.


Case 5: Shared state across threads

Problem code

use std::thread;

struct Counter {
    count: i32,
}

fn main() {
    let counter = Counter { count: 0 };
    
    let handle = thread::spawn(|| {
        counter.count += 1; // ❌ closure may outlive the current function
    });
    
    handle.join().unwrap();
}

Error message

error[E0373]: closure may outlive the current function, but it borrows `counter`, which is owned by the current function
  --> src/main.rs:9:31
   |
9  |     let handle = thread::spawn(|| {
   |                                ^^ may outlive borrowed value `counter`
10 |         counter.count += 1;
   |         ------- `counter` is borrowed here

thread::spawn requires its closure to be 'static: the new thread may keep running after main’s stack frame is gone, so it cannot hold references into it. Adding move fixes the lifetime error by moving counter into the thread, but then main can no longer see the updated count, and moving it into two threads is impossible. The fixes below are the three standard ways to share across threads. If the threads are guaranteed to finish inside the current function, std::thread::scope (Rust 1.63+) lets them borrow local data directly, since the scope joins every thread before it returns.

Fixes

// Option 1: Arc + Mutex (thread-safe sharing)
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let counter_clone = Arc::clone(&counter);
    
    let handle = thread::spawn(move || {
        let mut count = counter_clone.lock().unwrap();
        *count += 1;
    });
    
    handle.join().unwrap();
    println!("Count: {}", *counter.lock().unwrap());
}

// Option 2: channels (message passing)
use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();
    
    thread::spawn(move || {
        tx.send(1).unwrap();
    });
    
    let count = rx.recv().unwrap();
    println!("Count: {}", count);
}

// Option 3: atomic type (AtomicI32)
use std::sync::Arc;
use std::sync::atomic::{AtomicI32, Ordering};

fn main() {
    let counter = Arc::new(AtomicI32::new(0));
    let counter_clone = Arc::clone(&counter);
    
    let handle = thread::spawn(move || {
        counter_clone.fetch_add(1, Ordering::SeqCst);
    });
    
    handle.join().unwrap();
    println!("Count: {}", counter.load(Ordering::SeqCst));
}

Arc<Mutex<T>> is the general answer and the one to use when the shared state is more than a single number. Arc provides shared ownership with an atomic reference count, and Mutex provides exclusive access; Rc or RefCell would be rejected here with an error saying they cannot be sent between threads safely, because they are not Send/Sync. Two pitfalls are common. The lock guard lives until the end of its scope, so holding let mut count = ...lock().unwrap(); across slow work serializes every thread; keep the guarded region small. And lock().unwrap() panics if another thread panicked while holding the lock (the mutex is “poisoned”), which is usually the right behavior, but worth knowing when you see PoisonError in a log.

Channels avoid shared state entirely by moving values between threads, which scales better as a design and makes ownership explicit. Atomics are the cheapest option for a single counter or flag; Ordering::Relaxed is enough for a pure counter whose value is read only after join, while SeqCst is the safe default when the atomic also guards other data.


Pattern cheat sheet: what to use when

Ownership vs borrowing

SituationApproachExample
Read-onlyImmutable reference &Tfn print(user: &User)
Mutation neededMutable reference &mut Tfn update(user: &mut User)
Need ownershipValue Tfn consume(user: User)
Copy typesCopy traitfn calc(x: i32)

Interior mutability

TypeUse caseThread-safe
Cell<T>Interior mutability for Copy typesNo
RefCell<T>Runtime borrow checksNo
Mutex<T>Shared mutation across threadsYes
RwLock<T>Read-heavy workloadsYes

Shared ownership

TypeUse caseThread-safe
Rc<T>Single-threaded sharingNo
Arc<T>Multi-threaded sharingYes
Weak<T>Break reference cyclesDepends

Common combinations

These tables are a vocabulary, not a menu to pick from first. The order I try when a borrow error appears is: shorten or reorder borrows; pass ownership instead of references; clone small data; and only then introduce Rc/Arc for shared ownership and RefCell/Mutex for shared mutation. Each step to the right gives up some compile-time checking in exchange for flexibility.

// Single-threaded: Rc + RefCell
use std::rc::Rc;
use std::cell::RefCell;

let shared = Rc::new(RefCell::new(vec![1, 2, 3]));
let clone = Rc::clone(&shared);

shared.borrow_mut().push(4);
println!("{:?}", clone.borrow()); // [1, 2, 3, 4]

// Multi-threaded: Arc + Mutex
use std::sync::{Arc, Mutex};

let shared = Arc::new(Mutex::new(vec![1, 2, 3]));
let clone = Arc::clone(&shared);

thread::spawn(move || {
    clone.lock().unwrap().push(4);
});

Rc<RefCell<T>> is the single-threaded equivalent of Arc<Mutex<T>>: Rc gives several owners, RefCell allows mutation through a shared reference by counting borrows at runtime. The runtime check is the catch. Holding a borrow() while calling code that does borrow_mut() on the same cell compiles fine and panics with already borrowed: BorrowMutError when that path executes. The other classic mistake with Rc is a reference cycle, such as parent and child nodes pointing at each other with Rc; neither count reaches zero and both leak. Use Weak for the back-pointer.


Hands-on example: event system

C++-style (does not compile)

struct EventManager {
    listeners: Vec<Box<dyn Fn(&Event)>>,
}

impl EventManager {
    fn subscribe(&mut self, callback: impl Fn(&Event) + 'static) {
        self.listeners.push(Box::new(callback));
    }
    
    fn publish(&self, event: &Event) {
        for listener in &self.listeners {
            listener(event); // ✅ this part is fine
        }
    }
}

// Problem: what if a subscriber wants to mutate the EventManager?
fn main() {
    let mut mgr = EventManager { listeners: vec![] };
    
    mgr.subscribe(|event| {
        mgr.publish(event); // ❌ cannot borrow `mgr` as mutable
    });
}

Rust-style (Rc + RefCell)

use std::rc::Rc;
use std::cell::RefCell;

struct EventManager {
    listeners: Vec<Box<dyn Fn(&Event)>>,
}

fn main() {
    let mgr = Rc::new(RefCell::new(EventManager { listeners: vec![] }));
    let mgr_clone = Rc::clone(&mgr);
    
    mgr.borrow_mut().subscribe(Box::new(move |event| {
        // use mgr_clone inside the closure
        mgr_clone.borrow().publish(event);
    }));
}

(Assume the same subscribe and publish methods as above; subscribe accepts the boxed closure.) This compiles, but it deserves a warning. When some code later calls mgr.borrow().publish(&e), the listener runs mgr_clone.borrow() again, which is fine because several shared borrows can coexist, and it re-publishes the same event, so this particular listener recurses forever. If a listener instead tries to subscribe from inside a callback, it needs borrow_mut() while publish still holds borrow(), and the program panics at runtime. The listener also keeps mgr alive through its own Rc, forming a cycle that is never freed.

The underlying lesson is that callback systems which call back into their owner are hard to make safe in any language; C++ just lets them fail at runtime by invalidating the listener vector. In Rust, the more robust designs avoid the re-entrancy: listeners return commands or push into a queue that the manager processes after the dispatch loop, or events travel over a channel.


Debugging tips

How to read the error message

error[E0502]: cannot borrow `x` as mutable because it is also borrowed as immutable
  --> src/main.rs:10:5
   |
8  |     let r = &x;
   |             -- immutable borrow occurs here
9  |     
10 |     x.push(1);
   |     ^^^^^^^^^ mutable borrow occurs here
11 |     println!("{}", r);
   |                    - immutable borrow later used here

Reading order:

  1. Error code: E0502 (conflicting borrows)
  2. Where it breaks: line 10
  3. Cause: immutable borrow on line 8
  4. Where it is still used: through line 11

The third annotation, “later used here”, is the one people skip and the one that matters most. Since NLL, a borrow lasts only until its last use, not until the end of the block. If you delete or move the println! on line 11 above line 10, the error disappears, because r is no longer alive when x is mutated. Many borrow errors are fixed simply by moving a use earlier or copying a value out of the reference before the mutation.

rustc —explain

$ rustc --explain E0502

This error indicates that you are trying to borrow a value as mutable when it
is already borrowed as immutable.
...

Clippy

$ cargo clippy

warning: this `RefCell` reference is held across an await point
  --> src/main.rs:10:9
   |
10 |     let data = cell.borrow();
   |         ^^^^
   |
   = help: ensure the reference is dropped before calling `await`

This lint (await_holding_refcell_ref) catches a runtime-panic risk that the compiler cannot: another task may run during the await and try to borrow_mut() the same cell. Clippy also flags needless clone() calls and &Vec<T> parameters, both of which tend to appear while fighting borrow errors.

When I am stuck on an error for more than a few minutes, the fastest route is usually to reduce it: copy the function into a new file, replace unrelated types with stubs, and keep deleting lines while the error persists. What remains is often three or four lines, and at that size the conflict the compiler describes becomes obvious.


Conclusion

The borrow checker feels restrictive at first, but it guarantees memory safety at compile time. With these cases you have:

  1. Learned to read error messages carefully
  2. Understood how ownership, borrowing, and lifetimes interact
  3. Picked up RefCell, Rc, Arc, and related patterns
  4. Started thinking in a way that differs from C++

Takeaway: Do not fight the borrow checker—learn Rust’s rules and lean into them.