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?
usersis an immutable borrow (&Vec<User>)user.nameis aString(owned type)let name = user.nametries 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.usersis the first mutable borrow, alive for the whole loopself.log()takes&mut self, a second mutable borrow of the entire struct, which includesusers- 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
| Situation | Approach | Example |
|---|---|---|
| Read-only | Immutable reference &T | fn print(user: &User) |
| Mutation needed | Mutable reference &mut T | fn update(user: &mut User) |
| Need ownership | Value T | fn consume(user: User) |
Copy types | Copy trait | fn calc(x: i32) |
Interior mutability
| Type | Use case | Thread-safe |
|---|---|---|
Cell<T> | Interior mutability for Copy types | No |
RefCell<T> | Runtime borrow checks | No |
Mutex<T> | Shared mutation across threads | Yes |
RwLock<T> | Read-heavy workloads | Yes |
Shared ownership
| Type | Use case | Thread-safe |
|---|---|---|
Rc<T> | Single-threaded sharing | No |
Arc<T> | Multi-threaded sharing | Yes |
Weak<T> | Break reference cycles | Depends |
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:
- Error code:
E0502(conflicting borrows) - Where it breaks: line 10
- Cause: immutable borrow on line 8
- 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:
- Learned to read error messages carefully
- Understood how ownership, borrowing, and lifetimes interact
- Picked up RefCell, Rc, Arc, and related patterns
- Started thinking in a way that differs from C++
Takeaway: Do not fight the borrow checker—learn Rust’s rules and lean into them.