Enforcing Borrowing Rules at Runtime with RefCell<T>

Learn how RefCell<T> shifts Rust's borrowing checks from compile time to runtime, when that is useful, and how to avoid runtime panics.

The standard borrowing rules in Rust are checked while you type — the compiler rejects code that would allow two mutable references to the same data or a mutable reference alongside an immutable one. That early feedback is invaluable, but it comes from a static analyzer that must be conservative. When the compiler cannot prove that a borrowing pattern is safe, it rejects the program even though the pattern might actually be correct at runtime. RefCell<T> exists for exactly those moments. It does not disable the borrowing rules; it simply enforces them later, when the program runs, and panics if you get them wrong.

Why runtime enforcement exists

Static analysis can only guarantee what it can prove. Some properties of a program are mathematically undecidable — the Halting Problem is the textbook example — and the Rust compiler naturally cannot verify every possible safe borrowing pattern. It errs on the side of rejection because accepting an unsound program would break the memory-safety guarantees Rust is built on.

The trade-off is real: a correct program can be rejected simply because the compiler cannot follow the same reasoning a human can. RefCell<T> gives you a way to tell the compiler “I am confident this follows the borrowing rules; check me when the code actually executes.” If your confidence was misplaced, the program panics at the exact point of violation instead of silently corrupting memory. The safety net remains — it is just held at a different height.

Compile time is still the default:

Prefer compile-time checks whenever possible. They catch mistakes earlier, produce zero runtime overhead, and give clearer error messages. Reach for RefCell<T> only when the compiler’s conservatism is blocking a design you know to be sound.

How RefCell<T> checks borrowing at runtime

Like Box<T>, a RefCell<T> owns the data inside it — single ownership still applies. The difference is how borrowing is policed. Box<T> exposes the usual compile-time reference rules. RefCell<T> instead maintains an internal counter that tracks how many immutable borrows and whether a mutable borrow are active at any moment.

The two key methods are:

  • borrow() — returns a smart pointer Ref<T> that acts like an immutable reference. It increments the immutable-borrow count.
  • borrow_mut() — returns RefMut<T>, which behaves like a mutable reference. It checks that no other borrows (immutable or mutable) are active.

When a Ref or RefMut is dropped, the corresponding counter is decremented. If you call borrow_mut() while a Ref is still alive, or try to take two RefMut values simultaneously, the method panics with a BorrowError or BorrowMutError. No undefined behavior occurs; the thread just halts.

Think of RefCell<T> as a traffic light inside the value. Compile-time checks are like a bridge engineer who forbids any vehicle heavier than a bicycle from crossing. RefCell<T> puts a weigh station on the bridge itself — if a truck shows up while another truck is already crossing, the bridge shuts down immediately. The rules haven’t changed; only the timing of enforcement has.

The interior mutability pattern

A direct consequence of runtime checks is that you can obtain a mutable borrow from an immutable RefCell<T>. The outer binding does not need to be declared mut. This ability — mutating internal state through an apparently immutable reference — is the interior mutability pattern.

use std::cell::RefCell;
fn main() {
    let data = RefCell::new(42);
    // `data` is not declared `mut`, yet we can mutate the inner value.
    *data.borrow_mut() += 1;
    println!("{}", data.borrow()); // prints 43
}

Without RefCell, the compiler would reject data.borrow_mut() because data is immutable. With RefCell, the call succeeds because the borrow rules are checked when borrow_mut() runs — at that moment, no other borrows exist, so the operation is legal.

The pattern is safe:

Interior mutability does not bypass the borrowing rules. It defers their enforcement. If you break the rules at runtime, the program panics — just as surely as a compile error would prevent execution. There is no silent unsoundness.

A concrete example — testing with mock objects

The classic scenario that demands RefCell<T> is a mock object in a unit test. Suppose you are building a rate limiter that sends warning messages when a value approaches a quota. The library defines a Messenger trait whose send method takes &self:

pub trait Messenger {
    fn send(&self, msg: &str);
}
pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}
impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }
    pub fn set_value(&mut self, value: usize) {
        self.value = value;
        let percentage = self.value as f64 / self.max as f64;
        if percentage >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage >= 0.9 {
            self.messenger.send("Urgent: You have used over 90% of your quota!");
        } else if percentage >= 0.75 {
            self.messenger.send("Warning: You have used over 75% of your quota!");
        }
    }
}

A test wants to verify that when set_value pushes the usage above 75%, the messenger receives exactly the right message. A mock messenger that records every call inside a Vec<String> would be ideal — but the trait forces send to take &self. Attempting to push to a regular Vec inside send fails to compile:

#[cfg(test)]
mod tests {
    use super::*;
    struct MockMessenger {
        sent_messages: Vec<String>,
    }
    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            self.sent_messages.push(String::from(message));
            // ^^^^^^^^^^^^^^^^^^^^^^ error: cannot borrow `self.sent_messages` as mutable
        }
    }
}

The compiler cannot see that only one test thread will call send at a time; from its viewpoint, &self is immutable and cannot yield a mutable reference to the vector. This is where RefCell<T> resolves the deadlock.

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;
    struct MockMessenger {
        sent_messages: RefCell<Vec<String>>,
    }
    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: RefCell::new(vec![]),
            }
        }
    }
    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            // borrow_mut() obtains a mutable reference to the inner Vec
            self.sent_messages.borrow_mut().push(String::from(message));
        }
    }
    #[test]
    fn it_sends_an_over_75_percent_warning() {
        let mock = MockMessenger::new();
        let mut tracker = LimitTracker::new(&mock, 100);
        tracker.set_value(80);
        // borrow() obtains an immutable reference for inspection
        assert_eq!(mock.sent_messages.borrow().len(), 1);
    }
}

The test now compiles and passes. RefCell<T> deferred the borrow check to runtime, where it succeeds because no conflicting borrows exist at the moment of the calls. If you run cargo test, you will see the test pass.

What happens when the rules are broken at runtime

If the mock’s send method accidentally created two mutable borrows of the same RefCell, the test would panic instead of failing to compile:

impl Messenger for MockMessenger {
    fn send(&self, message: &str) {
        let mut first = self.sent_messages.borrow_mut();
        let mut second = self.sent_messages.borrow_mut(); // panics: already borrowed
        first.push(String::from(message));
        second.push(String::from(message));
    }
}

Running cargo test would produce:

thread '...' panicked at 'already borrowed: BorrowMutError', src/lib.rs:...

Runtime panics abort the program:

A panic from borrow_mut() is not a recoverable error — it unwinds the stack and terminates the thread or process by default. You cannot catch it with a match expression; it indicates a logic error in your code that must be fixed.

Common mistakes and how to avoid them

Holding a Ref or RefMut longer than necessary. Every active borrow prevents other borrows. If a Ref returned by borrow() is still alive when you call borrow_mut(), you get a panic. The fix is to limit the scope of the guard — often by introducing a block or calling drop explicitly.

let imm = data.borrow();
// data.borrow_mut(); // would panic if uncommented
drop(imm);
// now data.borrow_mut() is fine

Expecting RefCell to work across threads. RefCell<T> is !Sync, meaning the compiler will refuse to let you share it between threads. The equivalent for multi-threaded contexts is Mutex<T> or RwLock<T>, which use OS-level synchronization instead of a simple counter.

RefCell is strictly single-threaded:

If you attempt to send a RefCell to another thread, you will receive a compile-time error. This is by design — the runtime counter inside RefCell is not atomic and would cause data races in concurrent code.

Treating RefCell as a general-purpose escape hatch. Overuse of interior mutability often points to a design that is fighting Rust’s ownership model. If every field is wrapped in RefCell, ask whether the struct is trying to be a shared mutable object in the style of languages with garbage collection. Splitting responsibilities across smaller, more focused types often eliminates the need for runtime checks.

Forgetting that runtime checks have a performance cost. Each call to borrow or borrow_mut updates internal counters. The overhead is small, but it is not zero. In tight loops, prefer compile-time-checked references when the design allows.

When to reach for RefCell<T>

The decision checklist is short:

  • You have a single owner of the data (if you need multiple owners, combine with Rc<T> — that is the next section).
  • You are in a single-threaded context.
  • The compiler rejects a borrowing pattern that you know obeys the rules at runtime.
  • The pattern typically involves mutating internal state through an immutable API — mock objects, lazy caches, internal counters in an otherwise read-only interface, or tracking state in callbacks where the trait enforces &self.

If all four conditions hold, RefCell<T> is the right tool.

Summary

RefCell<T> does not weaken Rust’s memory-safety guarantees; it moves the point of enforcement. The borrowing rules are identical — only the timing of the verdict changes. This shift from compile time to runtime allows designs like mock objects and lazy initialization that would otherwise be impossible under the compiler’s conservative static analysis.

The most important insight to carry forward is this: interior mutability is a pattern of last resort, not a default. Every borrow_mut() call is a place where a human is overriding the compiler’s judgment. When used sparingly and correctly, RefCell<T> integrates smoothly with the rest of Rust’s safety infrastructure.