Variables and Mutability in Rust

let vs let mut

By default, variables in Rust are immutable, meaning you cannot change their value after binding; you must explicitly declare a variable as mutable using the `let mut` keyword to allow reassignment.

The friction you feel is the point

You're porting a Python script to Rust. In Python, you write x = 5, then x = 6, and the interpreter shrugs. In Rust, you write let x = 5;, then x = 6;, and the compiler stops you dead. You haven't made a syntax error. You haven't missed a semicolon. You've hit Rust's default immutability.

This friction is intentional. Rust forces you to declare intent. When you read a function, you need to know immediately which values are stable facts and which values are moving targets. Default immutability makes that distinction explicit. You don't get it for free; you have to ask for mutability with let mut.

Immutability by default

Rust treats every variable binding as immutable unless you opt out. A let binding creates a name that points to a value, and the compiler guarantees that name will never point to a different value. If you try to reassign it, the compiler rejects the code.

let mut is the opt-out. It marks a binding as mutable. The compiler now allows reassignment. It also tracks the mutability through the scope. If you pass a mutable variable to a function, the compiler checks whether the function needs mutability. This tracking prevents accidental side effects and makes reasoning about state changes local to the binding.

Immutability isn't just about safety. It's about cognitive load. When a value is immutable, you never have to wonder if it changed three lines ago or in a callback. The value is what it is. let mut signals that the value evolves. That signal helps you focus on the parts of the code where state actually changes.

Minimal example: The compiler tracks bindings

Here's the basic pattern. The compiler checks mutability at compile time. It doesn't add runtime overhead for immutable variables. The check happens once, and the result is baked into the binary.

fn main() {
    // Immutable binding: the compiler guarantees `x` never changes.
    // Any attempt to reassign `x` will fail compilation.
    let x = 10;
    
    // Uncommenting this line triggers E0384:
    // cannot assign twice to immutable variable `x`
    // x = 11;

    // Mutable binding: you explicitly opt into change.
    // The compiler allows reassignment because of `mut`.
    let mut y = 10;
    y = 11;
    
    println!("x is {}, y is {}", x, y);
}

The error code E0384 is specific. It tells you exactly which variable is immutable and where you tried to change it. The compiler doesn't guess. It points to the line and stops. This precision saves time. You fix the binding, and you move on.

Default to immutable. Add mut only when the compiler forces your hand.

Realistic scenario: Accumulating state

In real code, you often need to accumulate a result. A loop counter, a running total, or a buffer you're filling. These cases require let mut. The variable needs to update in place.

/// Calculates the sum of squares for a range of numbers.
/// Returns the accumulated total.
fn sum_squares(limit: u32) -> u32 {
    // Mutable accumulator: we update `total` in every loop iteration.
    // Without `mut`, the compiler would reject the `+=` operator.
    let mut total = 0;

    for i in 0..limit {
        // Reassigning `total` is allowed because it is `mut`.
        // The compiler tracks this change through the loop.
        total += i * i;
    }

    // Return the final value.
    total
}

fn main() {
    // Immutable result: once calculated, the answer is fixed.
    // We don't need `mut` here because `result` never changes.
    let result = sum_squares(5);
    
    println!("Sum of squares up to 5: {}", result);
}

Notice the pattern. The accumulator is mutable. The result is immutable. This separation is idiomatic. You mutate only where necessary, then lock the value down. The compiler enforces this discipline. If you accidentally reassign result later, the compiler catches it.

Trust the borrow checker. It usually has a point.

Shadowing: A new variable, not a mutation

Rust has a feature called shadowing that often confuses newcomers. Shadowing lets you create a new variable with the same name as an existing one. The new variable hides the old one. This is not mutation. The old variable still exists in memory until it goes out of scope, but you can no longer access it by name.

Shadowing is useful for two reasons. First, it allows you to change the type of a value. let mut keeps the type fixed. Shadowing can switch from String to usize, or from Option<T> to T. Second, it lets you "lock in" a value after a transformation. You can compute a value, shadow it, and then the new binding is immutable.

fn main() {
    // First `s` is a String.
    let s = String::from("hello");
    
    // Shadow `s` with a new value.
    // The old `s` is inaccessible. The new `s` can be a different type.
    // Here we convert the String to its length, changing the type to usize.
    let s = s.len();
    
    println!("Length: {}", s);
    
    // Shadowing again to create a message.
    // The `usize` `s` is now hidden.
    let s = format!("The length is {}", s);
    
    println!("{}", s);
}

Shadowing is a fresh start. It's not a loophole for mutation. The compiler treats each shadowed binding as independent. You can shadow an immutable variable with a mutable one, or vice versa. The rules apply to each binding separately.

Shadowing isn't mutation. It's a clean break.

Pitfalls and compiler signals

New Rust code often overuses mut. Developers coming from Python or JavaScript tend to mark everything mutable out of habit. The compiler fights back with warnings. If you declare let mut x but never reassign x, the compiler emits an unused_mut warning. This is a signal to remove mut.

Removing unnecessary mut reduces noise. It tells readers that the value is stable. It also helps the compiler optimize. While the optimizer can often remove unused mutability, explicit immutability gives the compiler more information to work with.

Another pitfall is confusing rebinding with mutation through references. let mut x allows you to rebind x to a new value. It does not automatically make the data behind a reference mutable. If x holds a reference, you need &mut to mutate the data. let mut controls the binding, not the data.

fn main() {
    // `data` is mutable, so we can rebind it.
    let mut data = String::from("hello");
    
    // Rebinding is allowed.
    data = String::from("world");
    
    // `ref_data` is a mutable reference to `data`.
    // We can mutate the string through the reference.
    let ref_data = &mut data;
    ref_data.push_str("!");
    
    println!("{}", data);
}

The convention is clear. Keep mut minimal. If the compiler warns you, listen. It's not being pedantic. It's helping you write cleaner code.

Treat the unused_mut warning as a code smell. Remove the mut until the compiler complains.

Decision matrix

Choosing between let, let mut, and shadowing depends on your intent. Use the right tool for the job.

Use let for values that represent a constant fact within a scope, like a configuration flag, a calculated result, or a loop bound. Use let mut for accumulators, loop counters, or state that evolves over time, such as a buffer being filled or a parser tracking position. Use shadowing when you need to transform a value and change its type, or when you want to ensure a value cannot be modified after a specific step, like unwrapping an Option and locking the result.

Reach for let first. Add mut only when reassignment is necessary. Use shadowing for type changes or to finalize a value. This discipline keeps your code readable and your compiler happy.

Where to go next