Fix E0388: Cannot Assign to Immutable Borrowed Content

Rust intermediate Linux macOS Windows WebAssembly

1. Symptoms

When your Rust code triggers error E0388, the compiler aborts compilation and emits a message similar to the following:

error[E0388]: cannot assign to data in a borrowed reference
  --> src/main.rs:12:5
   |
12 |     val.field = 42;
   |     ^^^^^^^^^^^^^ cannot assign
   |
help: consider borrowing here: `&val.field`

In more specific contexts, the message may read:

error[E0388]: cannot assign to an immutable borrowed content

You may encounter this error in several scenarios:

  • Assigning to a struct field accessed through a shared reference &T
  • Modifying a field on a value behind a &mut reference, where that field itself is not mutable
  • Attempting to set a value on a borrowed slice or array element
  • Trying to mutate a field within a RefCell<T> borrow

The error is always a result of the borrow checker detecting that you are attempting to write through a path that only grants read-only (shared) access to memory.


2. Root Cause

The Rust borrow checker enforces a fundamental rule: you cannot mutate data through a shared (immutable) borrow. A shared borrow &T grants read-only access. Attempting to assign through &T violates this contract and is flagged at compile time, which is exactly what E0388 reports.

The root causes can be broken down into three primary categories:

2.1 Shared Reference to a Struct with a Mutable Field

When you hold a &mut T that points to a struct, and that struct contains interior mutability types or fields borrowed as shared elsewhere, writing through one path can conflict with reads through another.

use std::cell::RefCell;

struct Inner {
    value: RefCell<i32>,
}

struct Outer {
    data: Inner,
}

fn demo(rx: &RefCell<i32>) {
    // rx is a shared borrow of a RefCell
    rx.borrow_mut(); // mutable borrow of the inner RefCell
}

2.2 Shared Borrow Path to a Struct Field

The most common cause is taking a shared reference &struct_var and then trying to assign to one of its fields:

struct Point {
    x: i32,
    y: i32,
}

fn move_point(pt: &Point) {
    pt.x = 100; // E0388: pt is &Point, not &mut Point
}

Here, pt is borrowed as shared (&Point), so modifying pt.x is forbidden.

2.3 Conflicting Borrows in Complex Structures

When you have nested references or cells where one borrow path is shared while another expects mutable access, E0388 surfaces because the compiler cannot guarantee aliasing safety:

use std::cell::Ref;

fn conflict(cell: &RefCell<String>, reader: &Ref<String>) {
    // reader holds a shared borrow of the same cell
    // trying to mutate through cell's mutable reference conflicts
}

The borrow checker traces all aliasing paths. If any path reaches the target data as shared, then no path can perform a mutation.


3. Step-by-Step Fix

Fix 1: Change the Parameter Type to a Mutable Reference

The most direct fix is to change the function signature to accept a mutable reference &mut T instead of a shared reference &T.

Before:

struct Point {
    x: i32,
    y: i32,
}

fn move_point(pt: &Point) {
    pt.x = 100;
    pt.y = 200;
}

fn main() {
    let mut p = Point { x: 0, y: 0 };
    move_point(&p);
}

After:

struct Point {
    x: i32,
    y: i32,
}

fn move_point(pt: &mut Point) {
    pt.x = 100;
    pt.y = 200;
}

fn main() {
    let mut p = Point { x: 0, y: 0 };
    move_point(&mut p);
}

Changing &Point to &mut Point grants the function write access to the struct’s fields. The caller must also change to &mut p when invoking the function.


Fix 2: Use Interior Mutability Types (Cell, RefCell)

When shared access is required but mutation from within that access is needed, use a cell type from std::cell. RefCell<T> moves borrow-checking from compile time to runtime. It allows mutation through shared references, but panics at runtime if borrows conflict.

Before:

use std::cell::RefCell;

struct Counter {
    count: RefCell<i32>,
}

fn increment(counter: &Counter) {
    *counter.count.borrow_mut() += 1;
}

fn main() {
    let c = Counter {
        count: RefCell::new(0),
    };
    increment(&c);
}

The code above works because RefCell allows mutation through a shared reference. However, if your original struct field is not wrapped in a cell, you need to wrap it:

Before:

struct Data {
    value: i32, // plain field, not mutable through &T
}

fn update(data: &Data) {
    data.value = 42; // E0388
}

After:

use std::cell::RefCell;

struct Data {
    value: RefCell<i32>,
}

fn update(data: &Data) {
    data.value.borrow_mut().set(42);
}

fn main() {
    let d = Data {
        value: RefCell::new(0),
    };
    update(&d);
    println!("value = {}", d.value.borrow());
}

RefCell is the idiomatic solution when you need shared access with mutation rights, particularly in scenarios involving shared ownership or callback patterns.


Fix 3: Restructure Ownership with Mutex<T> or RwLock<T>

For thread-safe mutation, wrap the data in std::sync::Mutex<T> or std::sync::RwLock<T>. These types allow mutation through shared references by managing internal synchronization.

Before:

use std::sync::Mutex;

struct SharedState {
    counter: Mutex<i32>,
}

fn add(state: &SharedState) {
    // E0388 if called with &SharedState and counter is held as shared
    let mut lock = state.counter.lock().unwrap();
    *lock += 1;
}

fn main() {
    let s = SharedState {
        counter: Mutex::new(0),
    };
    add(&s);
}

After:

use std::sync::Mutex;

struct SharedState {
    counter: Mutex<i32>,
}

fn add(state: &SharedState) {
    let mut lock = state.counter.lock().unwrap();
    *lock += 1;
}

fn main() {
    let s = SharedState {
        counter: Mutex::new(0),
    };
    add(&s);
}

Mutex<T> serializes access internally. As long as the lock is acquired before mutation, no conflict occurs regardless of how many shared references to s exist.


Fix 4: Clone Data and Mutate the Copy

If the data is cheap to clone, you can clone the borrowed value, mutate the clone, and then reassign:

Before:

#[derive(Debug)]
struct Config {
    timeout: u64,
}

fn update_timeout(cfg: &Config) {
    cfg.timeout = 3600; // E0388
}

fn main() {
    let mut c = Config { timeout: 100 };
    update_timeout(&c);
}

After:

#[derive(Debug, Clone)]
struct Config {
    timeout: u64,
}

fn update_timeout(mut cfg: Config) -> Config {
    cfg.timeout = 3600;
    cfg
}

fn main() {
    let c = Config { timeout: 100 };
    let updated = update_timeout(c);
    println!("timeout: {}", updated.timeout);
}

By taking ownership of Config via cfg: Config instead of a reference, the function can mutate freely. The caller gets back a new owned Config.


4. Verification

After applying one of the fixes above, verify the error is resolved by compiling the project:

cargo build

If the build succeeds, the error is fixed. Run the relevant tests to ensure the change does not introduce logic errors:

cargo test

For the RefCell fix specifically, verify that runtime borrow conflicts are not triggered by running test scenarios where overlapping borrows could occur:

use std::cell::RefCell;

struct Wrapper {
    inner: RefCell<Vec<i32>>,
}

impl Wrapper {
    fn push(&self, val: i32) {
        // Borrow mutably, push, then drop the borrow
        self.inner.borrow_mut().push(val);
    }

    fn read(&self) -> usize {
        // Borrow shared, read, then drop the borrow
        self.inner.borrow().len()
    }
}

fn main() {
    let w = Wrapper {
        inner: RefCell::new(vec![]),
    };
    w.push(10);
    w.push(20);
    println!("length: {}", w.read()); // prints "length: 2"
}

Compile and run this example to confirm that RefCell properly handles the alternating shared and mutable borrows.


5. Common Pitfalls

Confusing &mut T with mutability of the pointed-to value. Even with &mut T, if the underlying data structure marks a field as non-mutable via a type that does not implement Unpin or uses interior mutability, you still cannot mutate directly. The &mut T grants exclusive access, but that access is still subject to Rust’s field-level mutability rules.

Nested borrows through shared references. When you have a struct behind a shared reference, and that struct contains RefCell<T>, you might assume you can mutate the RefCell content freely. This is true for RefCell but not for types like Vec<T> or arrays unless the field itself is wrapped in a cell. Attempting to push to a Vec behind a shared reference triggers E0388 because Vec::push requires &mut self.

Aliasing Ref and RefMut simultaneously. RefCell allows either one mutable borrow OR multiple shared borrows at a time, but never both simultaneously. If you hold a Ref<T> and attempt a borrow_mut(), the program panics at runtime rather than producing a compiler error. This does not produce E0388, but it is a related pitfall when addressing immutability conflicts.

Using Cell<T> for non-Copy types. Cell<T> only works for types that implement Copy. If you need interior mutability for non-copy types, you must use RefCell<T> instead. Using Cell for non-copy types produces a different compiler error: E0507.

Overlooking the actual borrow path. Sometimes the shared borrow is indirect. For example, a function may receive &T from a parent struct field that is itself a shared borrow of a larger structure. Tracing the full borrow chain is essential to understand where the shared borrow originates and how to restructure the access pattern.


  • E0507cannot move out of borrowed content: This error occurs when attempting to move a value out of a borrowed location, which would invalidate the borrow.
  • E0594cannot assign to data in an interior mutability type: ⚠️ Unverified. This may appear in contexts where the mutation path traverses non-Cell interior mutability.
  • E0596cannot borrow data mutably as immutable: Appears when a mutable borrow is required for an operation but only a shared borrow is available, typically with atomics or special types.
  • E0502cannot borrow x as mutable because it is also borrowed as immutable: The borrow checker detects a conflicting borrow pattern where both shared and mutable borrows of the same data exist simultaneously.
  • E0599no field fieldon type&T`: Appears when the borrow path resolves to a type that does not have the expected field, often due to incorrect reference levels in the chain.