Variables & Mutability
Variables Are Immutable by Default
Section titled “Variables Are Immutable by Default”When you declare a variable with let, it is immutable — you cannot assign to it a second time. This is not a limitation; it is a feature. Immutability by default means the compiler can catch unintended mutations and reason about your code more precisely.
// @expect-error E0384fn main() { let x = 5; x = 6; println!("x = {}", x);}Attempting to reassign an immutable binding is a compile-time error, not a warning:
error[E0384]: cannot assign twice to immutable variable `x` --> src/main.rs:4:5 |3 | let x = 5; | - first assignment to `x`4 | x = 6; | ^^^^^ cannot assign twice to immutable variable |help: consider making this binding mutable |3 | let mut x = 5; | +++To allow mutation, add mut after let:
fn main() { let mut y = 10; y = 20; // allowed because y is mut println!("y = {}", y);}Shadowing
Section titled “Shadowing”Shadowing is different from mutation. You re-declare a variable with the same name using a new let binding. The new binding shadows the old one for the rest of the scope. You can even change the type when you shadow.
fn main() { let z = 3; let z = z * 2; // shadows the previous z let z = z + 1; // shadows again println!("z = {}", z); // 7
// Shadowing can change the type let word = "hello"; let word = word.len(); // shadow with a usize println!("len = {}", word);}Shadowing is not the same as mut. With mut, you re-assign to the same variable — and that new value must be the same type as the original, because mut only ever changes the value stored in one fixed binding. With shadowing, you create a new variable that happens to have the same name, so its type is free to be completely different, as word (a &str) becoming word (a usize) above shows. After the scope ends, the outer binding comes back into view.
What the Compiler Says: mut Cannot Change Type
Section titled “What the Compiler Says: mut Cannot Change Type”Because mut reuses the same binding, giving it a value of a different type is a type error, not a shadowing opportunity:
// @expect-error E0308fn main() { let mut x: i32 = 5; x = "a"; println!("x = {}", x);}error[E0308]: mismatched types --> src/main.rs:4:9 |3 | let mut x: i32 = 5; | --- expected due to this type4 | x = "a"; | ^^^ expected `i32`, found `&str`
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0308`.This is the same E0308: mismatched types you will see throughout Rust whenever a value’s type does not match what the context expects — an annotation, a parameter, a return type. mut only lifts the “assigned twice” restriction from E0384; it does not lift the type check.
Constants
Section titled “Constants”const declares a compile-time constant. Unlike let, constants:
- require an explicit type annotation
- must be set to a constant expression (not a runtime value)
- are valid for the entire duration of the program in their scope
- use
SCREAMING_SNAKE_CASEby convention
const MAX_POINTS: u32 = 100_000;const GRAVITY: f64 = 9.81;
fn main() { println!("MAX_POINTS = {}", MAX_POINTS); println!("GRAVITY = {}", GRAVITY);}The underscore in 100_000 is a numeric separator — it is purely cosmetic and ignored by the compiler. It makes large numbers easier to read.
static
Section titled “static”static also declares a value that lives for the whole program, but it means something different from const: a static has one fixed memory address for the entire run of the program, while a const has no guaranteed address at all — the compiler is free to inline its value at every use site, as if you had pasted the literal in by hand.
static APP_NAME: &str = "rust-deep-dive";const MAX_RETRIES: u32 = 5;
fn main() { println!("{} allows {} retries", APP_NAME, MAX_RETRIES);}let | const | static | |
|---|---|---|---|
| Type annotation | optional (inferred) | required | required |
| Value | any expression, evaluated at runtime | constant expression, evaluated at compile time | constant expression, evaluated at compile time |
| Lifetime | ends when its scope ends | inlined at every use site — no single address | one fixed memory address for the whole program |
| Mutable? | with mut | never | static mut exists but requires unsafe to touch — reach for an atomic or a Mutex instead |
flowchart TD L["let x = 5;"] -->|"scope ends"| D["dropped"] C["const MAX: u32 = 100;"] -->|"used"| I["inlined at each call site (no fixed address)"] S["static NAME: &str = ...;"] -->|"used"| A["same address for the whole program"]
In practice: reach for let for almost everything, const for a fixed value you want named (a limit, a default), and static only when you specifically need one shared address — most commonly for FFI or a global you plan to wrap in an atomic type.
fn main() { let x = 5; // x = 6; // error: cannot assign twice to immutable variable
let mut y = 10; y = 20; println!("y = {}", y);
// Shadowing: re-bind the same name let z = 3; let z = z * 2; let z = z + 1; println!("z = {}", z);
// Constants are always immutable and require a type annotation const MAX_POINTS: u32 = 100_000; println!("MAX_POINTS = {}", MAX_POINTS);}Compiling…
Mental Model
Section titled “Mental Model”Think of let as naming a value, not a memory cell you can freely overwrite. mut upgrades that name to also allow overwriting — but only with a value of the exact same type, because the compiler already fixed the storage size and layout for that binding. Shadowing sidesteps the whole question: it does not reuse storage at all, it introduces a brand-new name that happens to print the same on screen, which is exactly why it is allowed to change type.
Trade-offs
Section titled “Trade-offs”Immutable-by-default means most bindings in a Rust codebase carry a compiler-enforced guarantee: nothing downstream can have changed this value. That makes it dramatically easier to reason about a function in isolation — no need to trace every call site to know whether the value read on line 40 could differ from what it was on line 10. The cost is friction: you must add mut deliberately, and forgetting it produces a compile error rather than a runtime surprise, which some newcomers experience as the language “getting in the way” before they internalize that the alternative is a much harder-to-find class of bug.
Common Misconceptions
Section titled “Common Misconceptions”- “Shadowing is just a shorthand for
mut.” They solve different problems.mutmutates one binding’s value in place (same type, same storage). Shadowing creates an entirely new binding — it can change type, and the old binding is still there (just no longer nameable) until its scope ends. - “
constandstaticare interchangeable ways to write a global constant.”consthas no guaranteed memory address — the compiler can and does inline it everywhere it is used.staticguarantees exactly one address for the whole program. Reach forconstunless you specifically need that one shared address. - “An immutable variable’s underlying memory can never change.” Immutability is about the binding, not necessarily the bytes — a
Stringbehind an immutableletstill owns a heap allocation the compiler could theoretically reuse across shadowed rebinds; whatletwithoutmutactually guarantees is that you cannot write to it through that name.
Reading AI-generated code
Section titled “Reading AI-generated code”AI assistants frequently add mut “just in case” — to a loop counter that is only ever read, or a value that is only reassigned once inside a branch that could just as easily be a fresh let. The compiler will not stop you (an unused mut is a warning, not an error), so this kind of clutter survives code review unless someone is looking for it.
🐛 Spot the bug
fn main() { let mut total = 0; for price in [10, 20, 30] { println!("adding {price}"); } total = 60; println!("total = {total}");}Show the answer
total is declared mut but the for loop never actually updates it — it is reassigned exactly once afterward, which a plain let mut total = 0; followed by total = 60; does not need at all (and the loop body does not even use total). Compiling this produces warning: variable does not need to be mutable pointing at the let mut total line with help: remove this mut — the real fix here is almost always to actually accumulate inside the loop (total += price;) rather than to silence the warning by deleting mut.