The struct is a single unit to the borrow checker
You have a GameState struct. It holds a score and a log. You want to read the score to decide what message to append to the log. You write the function. The compiler rejects you. It says you cannot borrow log mutably while score is borrowed immutably.
This feels wrong. The fields are independent. Reading score cannot affect log. Mutating log cannot affect score. Rust should know this. Rust does not know this.
The borrow checker treats the entire struct as one atomic unit. When you borrow a field, you borrow the struct. When you mutate a field, you mutate the struct. The checker sees two borrows on the same struct. One is immutable. One is mutable. The rules forbid this combination. The fields being disjoint is irrelevant to the checker's current logic.
Why the compiler blocks disjoint fields
Rust's safety guarantees rely on tracking references. A mutable reference promises exclusive access. An immutable reference promises shared access. The compiler enforces these promises by tracking the lifetime and scope of every borrow.
When you write &self, you create a reference to the whole struct. The compiler does not currently track borrows at the field level for general cases. It tracks the reference to the struct. If you hold &self, you hold a reference to the entire struct. No one else can hold &mut self while you hold &self, even if they only want to touch a different field.
This is a limitation of the borrow checker implementation, not a fundamental law of the language. The compiler could theoretically prove that self.a and self.b never alias. It does not do this yet. The community calls this "field-level borrowing." It is a long-standing goal for the compiler. Until that feature lands, the checker remains conservative. It blocks the code to prevent hypothetical aliasing bugs, even when the code is safe.
The borrow checker sees the box, not the contents. Work with the box.
Minimal example: E0502 strikes
Consider a simple pair of values. You want to read one and update the other.
struct Pair {
x: i32,
y: String,
}
fn update_pair(p: &mut Pair) {
// Borrow x immutably to read it
let val = p.x;
// Try to borrow y mutably to write
// The compiler rejects this with E0502
p.y.push_str(&val.to_string());
}
The compiler emits E0502 (cannot borrow as mutable because it is also borrowed as immutable). The error points to p.y. It explains that p is already borrowed immutably by the line reading p.x.
The borrow of p.x creates an immutable borrow of p. The scope of that borrow extends until val is last used. The mutation of p.y requires a mutable borrow of p. The scopes overlap. The compiler stops you.
This happens even though x and y are distinct fields. The reference p covers the whole struct. The checker cannot split the reference.
RefCell moves the check to runtime
RefCell<T> solves this by moving the borrow check from compile time to runtime. It allows interior mutability. You can hold an immutable reference to a struct containing RefCell fields and still mutate those fields.
RefCell enforces the same borrowing rules as the compiler, but it checks them when the program runs. If you violate the rules, the program panics. This trade-off gives you flexibility at the cost of a runtime check.
You wrap the fields you need to access independently in RefCell. You call borrow() for immutable access and borrow_mut() for mutable access. These methods return guard types that track the borrow duration. When the guard drops, the borrow ends.
use std::cell::RefCell;
struct Pair {
x: RefCell<i32>,
y: RefCell<String>,
}
fn update_pair(p: &Pair) {
// Borrow x immutably. Returns a Ref guard.
let val = *p.x.borrow();
// Borrow y mutably. Returns a RefMut guard.
// This works because RefCell checks at runtime.
p.y.borrow_mut().push_str(&val.to_string());
}
The function takes &Pair, not &mut Pair. This is the key pattern. You use RefCell when you need to mutate state behind a shared reference. If you had &mut Pair, you could mutate both fields without RefCell because &mut already grants exclusive access to the entire struct. RefCell shines when you only have &self.
RefCell trades compile-time errors for runtime panics. Use it when the logic is correct but the compiler is blind.
How RefCell tracks borrows
RefCell maintains two counters inside: one for immutable borrows and one for mutable borrows. The invariant is simple. You can have any number of immutable borrows, or exactly one mutable borrow, but never both at the same time.
When you call borrow(), the immutable counter increments. When the returned Ref guard drops, the counter decrements. When you call borrow_mut(), the code checks that the immutable counter is zero and the mutable counter is zero. If so, it sets the mutable counter to one. If not, it panics.
This mechanism allows the borrow checker to remain satisfied. The struct itself is never mutated. The RefCell wrapper is mutated, but RefCell implements Copy-like semantics for the wrapper while protecting the inner value. The compiler sees only the wrapper changing, which is allowed behind &self because RefCell uses UnsafeCell internally to bypass the compiler's checks.
The runtime check is cheap. It involves reading and writing a few integers. It does not allocate. It does not lock threads. It is suitable for performance-critical code where the logic requires disjoint field access.
Realistic example: A self-validating config
A common pattern is a configuration object that validates its own state. You might have a raw string and a parsed list. You want to read the raw string to update the parsed list.
use std::cell::RefCell;
struct Config {
raw: RefCell<String>,
parsed: RefCell<Vec<String>>,
}
impl Config {
fn new(raw: String) -> Self {
Config {
raw: RefCell::new(raw),
parsed: RefCell::new(Vec::new()),
}
}
fn refresh(&self) {
// Borrow raw immutably to read the source
let raw_val = self.raw.borrow();
// Split the string into parts
let parts: Vec<String> = raw_val
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
// Borrow parsed mutably to update the cache
// This works because raw and parsed are separate RefCells
*self.parsed.borrow_mut() = parts;
}
}
fn main() {
let config = Config::new(String::from("alpha, beta, gamma"));
config.refresh();
// Access the parsed data
let result = config.parsed.borrow();
println!("Parsed: {:?}", *result);
}
The refresh method takes &self. This allows you to call refresh on a shared reference. You might store Config in a collection or pass it to a function that only accepts immutable references. RefCell enables this mutation.
Notice the convention in the code. The borrows are kept as short as possible. raw_val is used to compute parts, then it drops. The Ref guard for raw is dropped before borrow_mut() is called on parsed. This prevents holding multiple borrows longer than necessary.
The community calls this the "minimum borrow surface" rule. Keep borrows scoped tightly. Drop guards early. This reduces the risk of runtime panics and makes the code easier to reason about.
Convention aside: When cloning a RefCell, use RefCell::clone(&cell) instead of cell.clone(). Both compile and both clone the wrapper, not the inner value. The explicit form signals to readers that you are cloning the reference wrapper, avoiding confusion with a deep clone of the contents.
Pitfalls: Panics and recursive borrows
RefCell panics if you violate borrowing rules at runtime. This is the price of flexibility. The most common panic occurs when you try to borrow mutably while an immutable borrow is active.
use std::cell::RefCell;
struct Data {
value: RefCell<i32>,
}
impl Data {
fn bad_method(&self) {
let borrow = self.value.borrow();
// This panics: already borrowed: BorrowMutError
*self.value.borrow_mut() = 10;
}
}
The panic happens because borrow() holds an immutable borrow. borrow_mut() requires exclusive access. The runtime check catches the conflict and aborts.
Another pitfall is recursive borrowing. If a method holds a borrow and calls another method that borrows the same RefCell, you get a panic.
use std::cell::RefCell;
struct Node {
children: RefCell<Vec<Node>>,
}
impl Node {
fn add_child(&self, child: Node) {
// This panics if add_child is called recursively
// or if the child somehow references this node
self.children.borrow_mut().push(child);
}
}
If child contains a reference back to self, or if the logic creates a cycle where borrow_mut is called while a borrow is active, the program crashes. This is why RefCell is dangerous in graph structures with back-edges. You must ensure the borrow graph is acyclic or use a different strategy.
Use try_borrow() and try_borrow_mut() if you need to handle errors gracefully. These methods return Result instead of panicking. They are useful in parsers or state machines where a borrow conflict indicates a recoverable error rather than a bug.
RefCell panics are logic errors. If your code panics on a borrow, your logic is flawed. Fix the logic. Do not suppress the panic.
Decision: When to use RefCell vs alternatives
Use RefCell<T> when you need to mutate fields behind an immutable reference and the borrow checker cannot prove the fields are disjoint. Use RefCell<T> when you are building a trait object that requires interior mutability, such as a state machine stored in a collection. Use RefCell<T> when the runtime cost of borrow checking is acceptable and the flexibility outweighs the risk of panics.
Use Cell<T> when the field holds a Copy type and you only need to replace the entire value, never borrow it. Cell is faster than RefCell because it has no runtime borrow tracking. It supports get() and set(), but not borrowing.
Use &mut self and restructure your code when the borrows are sequential. Often, storing a value in a temporary variable or returning early lets Non-Lexical Lifetimes resolve the conflict without interior mutability. The compiler is smart about disjoint borrows if the scopes do not overlap.
Use destructuring or split methods when you can separate the struct into independent values before borrowing. For example, let (a, b) = (&mut pair.a, &mut pair.b) sometimes works for simple cases, though field-level borrowing is still limited. Splitting the data allows independent borrows.
Use UnsafeCell<T> only when you are writing a library that provides a safe wrapper like RefCell. Never use UnsafeCell directly in application code. It requires manual proof of safety and is error-prone.
Don't reach for interior mutability to fix bad design. Reach for it when the design is sound and the checker is stuck.