Telex logo Telex

Rust Patterns That Matter #7: Split Borrows

Post 7 of 22 in Rust Patterns That Matter. Companion series: Building a Chat Server in Rust.

Previous: #6: Rc<RefCell<T>> | Next: #8: Index-Based Design

You have a struct with two fields. A method needs to read one and write the other. The compiler blocks it - it sees a borrow of the whole struct, not individual fields. The fix is usually a small structural change, not a fight.

The motivation

A renderer holds both a configuration and a buffer. The render method reads the configuration to decide what to draw, then writes to the buffer.

struct Renderer {
    config: Config,
    buffer: Vec<u8>,
}

impl Renderer {
    fn render(&mut self) {
        let width = self.get_width(); // borrows &self
        self.buffer.resize(width, 0);  // borrows &mut self — ERROR
    }

    fn get_width(&self) -> usize {
        self.config.width
    }
}

The compiler:

error[E0502]: cannot borrow `self.buffer` as mutable because
              it is also borrowed as immutable

get_width takes &self, which borrows the entire struct. While that borrow is alive (because width could theoretically be a reference into self), the compiler won't let you mutably borrow self.buffer.

Why the compiler does this

Method calls taking &self or &mut self borrow the whole struct. The compiler doesn't look inside the method to see which fields it actually touches. From its perspective, get_width(&self) might be reading self.buffer, and if it is, then the simultaneous &mut self.buffer would be unsound.

Direct field access is different. If you write self.config.width instead of calling a method, the compiler can see that the borrow is only on self.config, and self.buffer is disjoint. The problem is specifically about method calls that borrow the whole struct.

Pattern 1: Direct field access

The simplest fix - inline the field access instead of calling a method:

fn render(&mut self) {
    let width = self.config.width;  // borrows self.config only
    self.buffer.resize(width, 0);   // borrows self.buffer — disjoint, OK
}

The compiler knows self.config and self.buffer are different fields. No conflict. This works because the compiler does do field-level borrow tracking for direct access - it's method calls that erase field information.

Pattern 2: Destructuring

When you need to work with multiple fields across several operations, destructure the struct to give each field its own binding:

fn render(&mut self) {
    let Renderer { config, buffer } = self;
    // config: &Config (borrowed from self)
    // buffer: &mut Vec<u8> (borrowed from self)
    // These are disjoint borrows — compiler is happy

    let width = config.width;
    buffer.resize(width, 0);
}

Destructuring splits self into independent borrows. The compiler can see that config and buffer don't overlap.

Pattern 3: Helper functions on fields

If the logic is complex enough to warrant methods, make them take the individual fields rather than &self:

impl Renderer {
    fn render(&mut self) {
        Renderer::fill_buffer(&self.config, &mut self.buffer);
    }

    fn fill_buffer(config: &Config, buffer: &mut Vec<u8>) {
        buffer.resize(config.width, 0);
    }
}

The function signature explicitly takes &Config and &mut Vec<u8>. The compiler can see these are disjoint borrows of self.

Pattern 4: Temporary variables

If you only need to read a value from one field, copy or clone it before touching the other field:

fn render(&mut self) {
    let width = self.get_width(); // borrows and releases &self
    // width is an owned usize — no borrow alive
    self.buffer.resize(width, 0);  // fine — no conflicting borrow
}

Wait - this is the original code that failed. Why does it work? Actually, it depends on the return type. If get_width returns usize (copied, no borrow), the shared borrow of self is released before self.buffer is mutably borrowed. If it returned &usize, the borrow would persist. The key insight: the compiler extends borrows only as long as the returned reference lives.

The key insight

The compiler is smarter than it might seem. It tracks borrows at the field level for direct access. The problem comes specifically from method calls, which borrow the whole struct via &self/&mut self. The fixes all share one strategy: give the compiler enough information to see that the borrows are disjoint.

When to use it

Anytime you get "cannot borrow X as mutable because it is also borrowed as immutable" and you know the borrows are on different fields. The fix is usually one of:

If none of these work and you genuinely have aliased mutable access, the borrow checker is right to reject it. But most "split borrow" situations are false positives that one of these patterns resolves.

See it in practice: Building a Chat Server #2: Rooms and Users uses this pattern for reading rooms while writing to users.