Variables and Mutability

Every variable you create in Rust is immutable by default — once you bind a value to a name, you cannot change that value unless you explicitly say so. This is one of the first things that feels unusual if you’re coming from Python, JavaScript, or even C, where variables are mutable unless you go out of your way to mark them const or final. Rust flips the default because immutability is a huge part of how the language prevents entire categories of bugs at compile time, before your program ever runs. Understanding exactly what “immutable” means in Rust — and how it differs from shadowing and constants — is foundational to everything else in the language, including ownership and borrowing.

Overview: How Variables Work in Rust

In most languages, a variable is like a labeled box you can always reach into and swap the contents of. In Rust, think of let as gluing a label onto a value and then, by default, welding the box shut. You can still look inside (read the value) any time you want, but you cannot swap out what’s inside unless you specifically built the box with a hinge — that hinge is the mut keyword.

Why would a language default to this? Three practical reasons. First, predictability: when you read let total = calculate_total(&items); and never see mut, you know with total certainty that total will hold that same value everywhere below it in scope — no need to trace through the rest of the function hunting for a reassignment. Second, safety: a huge class of bugs in other languages comes from a value changing somewhere you didn’t expect, especially when multiple parts of a program (or multiple threads) can see the same data. Rust’s compiler can rule that out entirely for anything not marked mut. Third, it composes with the borrow checker: Rust’s rule that you can have either one mutable reference or many immutable references to a value (but never both at once) only works cleanly because immutability is the starting point, not an opt-in.

Crucially, this is a compile-time concept, not a runtime one. There’s no hidden flag checked as your program executes; the compiler simply refuses to produce a binary if you try to reassign a variable that wasn’t declared mut. This means immutability costs you nothing at runtime — it’s pure compile-time bookkeeping, similar to type checking.

It’s also important to separate three ideas that beginners often blur together: mutability (changing the value stored in an existing binding via mut), shadowing (creating a brand-new binding that happens to reuse the same name, via a second let), and constants (values that are fixed for the entire program and inlined by the compiler, declared with const). Each behaves differently, and mixing them up is one of the most common early mistakes — covered in detail below.

Syntax

let name = value;         // immutable binding
let mut name = value;     // mutable binding
const NAME: Type = value; // compile-time constant (type annotation required)
let name: Type = value;   // immutable binding with an explicit type
Form Meaning
let Creates a new, immutable variable binding. The value cannot be reassigned.
let mut Creates a new, mutable variable binding. The value can be reassigned later, but the type cannot change.
const Declares a constant. Always requires an explicit type, must be initialized with a value known at compile time, is conventionally named in SCREAMING_SNAKE_CASE, and can never be made mutable.
: Type An optional (sometimes required) type annotation. Rust can usually infer the type from the value on the right, so annotations are often left off for local variables.

Examples

Example 1: Immutable vs. mutable bindings

fn main() {
    let x = 5;
    println!("The value of x is: {}", x);

    let mut y = 10;
    println!("The value of y is: {}", y);
    y = 15;
    println!("The value of y is now: {}", y);
}

Output:

The value of x is: 5
The value of y is: 10
The value of y is now: 15

x is bound once and never reassigned, so it doesn’t need mut. y is declared with mut, which tells the compiler “this binding is allowed to point to a different value of the same type later” — and indeed we reassign it from 10 to 15 a few lines down. If you removed mut from y‘s declaration, the line y = 15; would fail to compile.

Example 2: Shadowing

fn main() {
    let spaces = "   ";
    println!("spaces as str: '{}'", spaces);
    let spaces = spaces.len();
    println!("spaces as number: {}", spaces);

    let x = 5;
    let x = x + 1;
    let x = x * 2;
    println!("The value of x is: {}", x);
}

Output:

spaces as str: '   '
spaces as number: 3
The value of x is: 12

Here let spaces = ... appears twice. This is not mutation — each let creates a completely new binding that happens to reuse the name spaces, and the new binding shadows (hides) the old one for the rest of the scope. Notice the type even changes, from &str to usize — something mut would never allow, since mut only lets you change the value, never the type, of an existing binding. The x example shows the same trick used to transform a value through a short pipeline of steps without needing mut at all.

Example 3: Constants and scope

const MAX_POINTS: u32 = 100_000;

fn main() {
    let mut score = 0;
    score += 2500;
    println!("Current score: {} out of max {}", score, MAX_POINTS);

    {
        let bonus = 50;
        score += bonus;
        println!("Score after bonus (inner scope): {}", score);
    }

    println!("Final score: {}", score);
}

Output:

Current score: 2500 out of max 100000
Score after bonus (inner scope): 2550
Final score: 2550

MAX_POINTS is a constant: it’s fixed for the life of the program, declared outside main so it’s available anywhere in the file, and the compiler inlines its value everywhere it’s used rather than storing it in memory at runtime. The inner { ... } block creates a new scope; bonus only exists inside those braces and is dropped the moment the block ends, but the effect it had on score (which was declared in the outer scope) persists after the block closes.

How It Works Step by Step

When the compiler encounters let x = 5;, it records a binding named x in the current scope with the value 5 and marks it immutable. From that point until x goes out of scope, any attempt to write to x (an assignment like x = 6;, or passing &mut x somewhere) is flagged as a compile error during the borrow-checking pass — this happens purely by reading your source code, no program execution involved.

When you add mut, the compiler instead marks the binding as writable, so reassignments type-check as long as the new value has the same type as the original. Each let statement, mutable or not, always introduces a brand-new binding in the compiler’s symbol table; shadowing works because the compiler simply lets a later binding with the same name take priority over an earlier one within the same or a nested scope, without ever touching or invalidating the earlier one’s memory.

Scope is delimited by curly braces. When execution (conceptually) reaches the closing } of a block, every variable declared inside that block is dropped in reverse order of declaration. That’s why bonus in Example 3 can’t be used after its inner block ends — its binding no longer exists — while score, declared in the outer scope, survives.

Common Mistakes

Mistake 1: Reassigning without mut

fn main() {
    let x = 5;
    x = 6; // error[E0384]: cannot assign twice to immutable variable `x`
    println!("x = {}", x);
}

The compiler rejects this outright because x was never declared mut. The fix is to add mut if you genuinely need to reassign it:

fn main() {
    let mut x = 5;
    x = 6;
    println!("x = {}", x);
}

Output:

x = 6

Mistake 2: Trying to use mut to change a variable’s type

fn main() {
    let mut spaces = "   ";
    spaces = spaces.len(); // error[E0308]: mismatched types, expected `&str`, found `usize`
    println!("{}", spaces);
}

Adding mut only permits reassigning a new value of the same type — it does not let a binding change type mid-program. When you genuinely need to transform a value into a different type under the same conceptual name, use shadowing with a fresh let instead, exactly as shown in Example 2:

fn main() {
    let spaces = "   ";
    let spaces = spaces.len();
    println!("{}", spaces);
}

Output:

3

Mistake 3: Initializing a const with a runtime value

fn main() {
    let seconds = 5;
    const MAX_SCORE: u32 = seconds * 2; // error: attempt to use a non-constant value in a constant
    println!("{}", MAX_SCORE);
}

Constants must be computable entirely at compile time from literals and other constants — they can never depend on a let-bound variable, function call result, or anything only known while the program is running. If you need a value derived from runtime data, use a regular (possibly mut) variable instead of const.

Best Practices

  • Leave variables immutable (plain let) by default; only add mut when the compiler actually complains that you need it. This keeps your code easier to reason about.
  • Prefer shadowing over mut when you’re transforming a value into something conceptually related but of a different type or representation (like a string into its length).
  • Use const for values that are truly fixed forever (limits, magic numbers, configuration ceilings) and name them in SCREAMING_SNAKE_CASE by convention.
  • Keep mutable bindings’ scope as small as possible — declare them right before the loop or block that needs to mutate them, not far above it.
  • Don’t reach for mut as a way to avoid thinking about ownership; often a cleaner design (returning a new value instead of mutating in place) is both safer and just as fast, since Rust optimizes value returns heavily.
  • Let type inference do its job — only annotate a variable’s type when the compiler can’t infer it or when the annotation genuinely improves readability.

Practice Exercises

  • Write a program that declares an immutable variable holding a temperature in Celsius, then uses shadowing to create a new binding holding the Fahrenheit conversion, and prints both.
  • Declare a const for a shop’s tax rate (e.g. 0.08 as an f64) and a mut variable for a running cart total. Add three item prices to the total one at a time, then print the total with tax applied.
  • Write a program with a variable declared inside an inner { } block, and try printing it after the block ends. Confirm you get a compiler error, then fix the program so it compiles by moving the println! inside the block.

Summary

  • Variables declared with let are immutable by default; add mut to allow reassignment.
  • Immutability is enforced entirely at compile time and costs nothing at runtime.
  • mut lets you change a binding’s value, but never its type.
  • Shadowing (a second let with the same name) creates a completely new binding, and can change type — it is not the same thing as mutation.
  • const declares a compile-time constant that always needs an explicit type and can never be marked mut.
  • Variables are dropped when their enclosing scope ({ }) ends, and inner-scope variables are inaccessible outside that scope.