Skip to content
beginner Phase 1 · Toolchain, Cargo, and Language Syntax

Bindings, Mutability, and Shadowing

Declare immutable lets, opt into mut, and reuse names through shadowing.

40m
0 problems
Topic Progress 0%

let Is a Binding, Not a Box: Why Immutability Is the Default

Rust calls its variables bindings because let associates a name with a value rather than reserving a mutable cell. The default is deliberate:

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

The compiler's message names the binding, the first assignment, and the reassignment — the borrow-checker family of diagnostics starts here. Mutation requires opting in at the binding site:

let mut count = 5;
count = 6;       // fine — one storage location, reassigned

Why default to immutability? Every let you can trust never changes is one fewer aliasing question when reasoning about a function, and shared references (&T) may only point at immutable data, so pervasive mut would poison APIs downstream. The practical habit is to write let first and let the compiler's E0384 error tell you where mutation was actually intended — you will end up with far fewer muts than languages where mutability is ambient.

Types are inferred from the first use and pinned to the binding:

let price = 99;            // i32 by default, from the literal
let price: u64 = 99;       // annotated — required for constants and ambiguous cases
let mut total = 0;         // i32; later total += price compiles only if price is i32 too

Two initialization rules matter. A binding must be initialized before first read — let x: i32; println!("{}", x); gives error[E0381]: used binding isn't initialized. But deferred initialization is allowed when the compiler can prove assignment on every path:

let label: String;
if expensive { label = String::from("heavy"); }
else         { label = String::from("light"); }

Type annotations become mandatory in exactly these deferred cases, since nothing earlier pins the type. A related compiler rule: a binding may be assigned only once after deferred initialization (cannot assign twice to immutable variable even when the variable was declared without a value).

One more piece of vocabulary: Rust has no "variables are expressions" foot-gun like C's comma operator, but let itself is a statement — you cannot write let x = (let y = 1); or use let as the value of an if branch. Everything you bind is a value the current scope owns, which is the seed of the ownership model in The Three Ownership Rules.

Here is the error in full, because it is the first diagnostic the borrow-checker family shows you and the format never changes:

error[E0384]: cannot assign twice to immutable variable `count`
 --> src/main.rs:4:5
  |
2 |     let count = 5;
  |         ----- first assignment to `count`
4 |     count = 6;
  |     ^^^^^^^^^ second assignment to `count`
  |
help: make this binding mutable
  |
2 |     let mut count = 5;
  |         +++

Read the shape of it: span, first assignment, offending assignment, and a concrete fix. E0382 (use of moved value), E0499 (two mutable borrows), and E0502 (borrow while mutably borrowed) all arrive in this same anatomy, so learning to read this one teaches you to read the rest.

Two edges of let are worth knowing before you write much code. Underscore prefixes suppress the unused_variables lint without discarding anything: let _config = load(); still holds the value until the end of the scope, while let _ = load(); drops the value immediately — the difference matters when the value owns a lock guard, a file handle, or a heap buffer you need alive for a few more lines. And mutability can be removed by shadowing: after let mut x = 1; let x = x + 1;, the new x is immutable unless you write let mut x again.

The interview question this topic always produces is "why would you design a language with immutable-by-default variables?" The answer is that &T can alias freely precisely because nothing behind it changes; mutability is what forces exclusivity, and paying for it only where you ask for it keeps the borrow checker's job small.

Shadowing Rebinds a Name Instead of Mutating a Value

Shadowing is the act of introducing a second binding with the same name; the new one hides the old for the rest of the scope, and the old value is dropped if nothing else owns it:

let space = String::from("hello");        // String (owned, heap)
let space = space.len();                   // usize — NEW binding, new type
println!("{}", space);                     // 5

If this looks like ordinary reassignment, the difference is decisive: mut cannot change a binding's type, and it mutates the existing storage in place. Shadowing creates fresh storage:

let mut s = String::from("hello");
s = 5;                       // error: mismatched types — mut forbids type change

let s = String::from("hello");
let s = s.len();             // compiles — each `let` is a new `s`

Three idioms make shadowing earn its place. First, transforming a value in steps while keeping one readable name:

let input = "  42  ";
let input = input.trim();
let input: u32 = input.parse().expect("digits");

Second, reusing a name in a loop — each iteration's binding is a distinct scope, so the "same" let runs repeatedly without accumulating state:

for line in lines {
    let line = line.trim();       // fresh each iteration
    process(line);
}

Third, starting with a placeholder and refining it (an Option you unwrap once, a parsed value you normalize). Note what shadowing is not: reassigning through mut keeps one owner and one allocation — s = String::new() on a mut s frees the old heap buffer immediately. A shadowed binding instead lives until its scope ends, so a loop that shadows a String a million times holds memory until the loop finishes. Reach for mut when you are overwriting in a hot loop; reach for shadowing when the new value has a different meaning or type.

Shadowing also composes with visibility rules: an inner scope's let shadows an outer one until the block ends, and let-else patterns and match arms each introduce their own bindings — the same name in different arms is always a fresh binding, not a shared one. This scoping behavior is exactly why let x = 5; inside if cannot be read after the closing brace.

Watch what happens when shadowing meets mut, because the mut belongs to the binding, not to the name:

let mut n = 1;
n += 1;          // fine — n is mut

let n = n + 1;   // new binding, and it is IMMUTABLE
n += 1;          // error[E0384] again

To keep mutating you must re-declare let mut n. Nothing about the name n carries mutability forward; each let is a fresh decision about storage, type, and permissions.

Shadowing also explains constructs that otherwise look magical. The loop variable in for line in lines is a binding introduced by the desugared match, fresh on every iteration — that is why mutating or rebinding it does not affect the next pass. if let Some(x) = opt, while let, and every match arm follow the same rule: each arm binds its own names in its own scope, so Some(v) in arm one and Err(v) in arm two are unrelated bindings that merely share a spelling. Nested patterns behave identically — in if let Ok(User { id, .. }) = db.find(k), id is a new binding owned by the if let block.

A closure captures whichever binding its name resolves to when it is created, so shadowing inside a closure body creates a third binding rather than mutating either outer one:

let value = 1;
let get = || { let value = value + 1; value };   // inner `value` shadows outer
println!("{}", get());   // 2
println!("{}", value);   // 1 — the outer binding is untouched

The rule that ties the whole topic together: shadowing renames the storage under a name; it never edits the value that was there.

const and static: Compile-Time Values vs Fixed Addresses

let bindings are runtime constructs tied to a scope. Two keywords lift values out of that world, and conflating them is a common interview mistake.

const is a compile-time constant, inlined at every use, with a mandatory type annotation:

const MAX_CONNECTIONS: usize = 32;
const GREETING: &str = "hello";    // note: type is &str, not String — no allocation allowed

fn main() {
    println!("{}", MAX_CONNECTIONS);
}

Constants may be declared in any scope, must be initializable with a constant expression (literals, arithmetic, const fn calls — no String::from), and exist for the whole compilation, not the process. There is no memory location to point at: the value is substituted where used, the way #define worked in C but with type checking.

static declares a global with a fixed memory address that lives for the entire program run ('static lifetime):

static APP_NAME: &str = "orders-service";
static mut COUNTER: u64 = 0;        // reading or writing requires unsafe

The address is the point: &APP_NAME is a real pointer into a .data/.rodata segment, whereas &MAX_CONNECTIONS is just a pointer to wherever the constant was materialized. Reading a non-mut static with Sync data is safe from any thread; touching static mut needs an unsafe block and a concurrency argument, which is why the ecosystem replaces it with atomics (AtomicU64) or locks — mutable global state is the classic data-race source, and Rust makes you opt into it visibly.

const static let mut
Where the value lives inlined at use one fixed address current scope's storage
Type annotation required required inferred or optional
Mutable? no only via unsafe (static mut) yes
Lifetime compilation whole program run until scope end (drop)

Rules that trip people: const names are SCREAMING_CASE by convention (clippy warns otherwise); a const inside a function still has compilation-wide scope, not block scope; associated constants live in impl blocks (impl Point { const DIMS: usize = 2; }); and static items are never "initialized at startup" the way C globals are — their initializer must itself be computable at compile time. When you want a lazily-initialized global with runtime computation, the answer is neither keyword — that is OnceLock/LazyLock from the standard library, covered when concurrency arrives.

Neither keyword can compute its value at run time, so the standard library supplies the missing case — a global initialized once, lazily, on first access:

use std::sync::LazyLock;

static CONFIG: LazyLock<Config> = LazyLock::new(|| Config::load("app.toml"));

fn handle() {
    let cfg = &CONFIG;      // first deref runs Config::load exactly once
}

For a global that must change, atomics replace static mut without any unsafe in your code:

use std::sync::atomic::{AtomicU64, Ordering};

static REQUESTS: AtomicU64 = AtomicU64::new(0);

REQUESTS.fetch_add(1, Ordering::Relaxed);

Both exist because static mut hands you a data race if two threads touch it — the compiler cannot prove otherwise, so it pushes the burden into an unsafe block where it cannot be audited cheaply. Globals with an address are safe only when their mutation has its own synchronization story.

const reaches further than simple arithmetic in one direction: const fn functions may be evaluated at compile time and then used in constant contexts, and const generics let a type carry a constant as a parameter — fn zeros<const N: usize>() -> [u8; N] returns an array whose length is part of its type. That is the same "computable at compile time" rule the whole topic rests on: let is a runtime decision scoped to a block, const is decided by the compiler, and static is a named location that outlives every function that reads it.

Quiz

1. What distinguishes shadowing from mutation with `mut`?

Question 1 options

2. Why does `const MAX: u32 = 100;` need an explicit type while `let max = 100;` does not?

Question 2 options

3. What is the key difference between `const` and `static`?

Question 3 options

4. You write `let x: i32; if cond { x = 1; } else { x = 2; } println!("{}", x);`. Why does it compile, and when would it not?

Question 4 options

Flashcards

Question

What does `let` create in Rust, and what error do you get for reassigning one without `mut`?

Answer

A binding: a name associated with a value, immutable by default. Reassigning one yields error[E0384] 'cannot assign twice to immutable variable'. Mutation requires `let mut`, which opts that one storage location into reassignment — the default keeps reasoning about values local and lets &T point at any data.

Question

How does shadowing differ from `let mut` reassignment, and when is each preferable?

Answer

Shadowing introduces a NEW binding hiding the old one — the type may change and the old value is dropped at scope end. mut reassigns the same storage in place, type fixed, old value dropped immediately. Prefer mut for overwrite-in-a-loop; prefer shadowing when transforming a value or changing its type while keeping a readable name.

Question

Why must const and static have explicit type annotations?

Answer

They are context-free: a const is inlined at arbitrary use sites and a static lives at a fixed address for the whole program — no use site provides enough context for inference, so `const MAX: usize = 32;` and `static NAME: &str = "x";` must spell out the type.

Question

const vs static: storage, mutability, and lifetime in one answer.

Answer

const — no storage, inlined at each use; not mutable; lifetime of the compilation. static — one fixed address in a global segment; immutable unless `static mut` (which requires unsafe); lives for the whole program run with 'static lifetime. Both need compile-time initializers.

Question

When is deferred initialization (`let x: T;` with no initializer) legal?

Answer

When every path to first use assigns the binding — definite-assignment analysis. A type annotation is required (nothing earlier infers it), and assigning on only some branches yields E0381 'used binding isn't initialized'. This is how you bind once from an if/else or match without cloning.

Revision Notes

Key Takeaways

  • 1. let bindings are immutable by default; `mut` opts one storage location into reassignment and never changes the binding's type
  • 2. Shadowing is a new binding hiding the old — type changes are allowed, and the old value is dropped only at scope end (memory difference vs mut in loops)
  • 3. const values are inlined at compile time and require explicit types; statics occupy one fixed address for the program run and may only be mutated via unsafe static mut
  • 4. Deferred initialization (`let x: T;`) compiles only when definite assignment proves every path assigns; the annotation is mandatory
  • 5. Prefer deleting `mut` and letting E0384 guide you — immutability by default is what makes shared references safe to hand out

Interview Tips

  • • "Why are variables immutable by default?" — mutation is an opt-in that limits what you can borrow (&T can alias anything; &mut needs exclusivity), and immutability removes whole bug classes from local reasoning; mention you add mut only where the compiler's E0384 proves you need it
  • • "Shadowing vs reassignment?" — new binding vs same storage; type change allowed vs forbidden; old value dropped at scope end vs immediately; give the trim-parse idiom as the canonical use
  • • "const vs static?" — inlining vs one address, compilation lifetime vs program lifetime, both needing annotations; add that static mut requires unsafe and modern code uses atomics or OnceLock instead
  • • "Can a const's value be computed at runtime?" — no: initializers must be constant expressions (literals, arithmetic, const fn); runtime-computed globals are lazy statics via OnceLock/LazyLock

Cheat Sheet

Bindings, Mutability, Shadowing

Decision table:

Need Use Notes
Value that never changes let x = v; default; E0384 on reassign
Reassign in place let mut x = v; type fixed at first assignment
New value, maybe new type let x = transform(x); shadowing; old value drops at scope end
Compile-time constant const MAX: usize = 32; type required, inlined at use
Global with an address static NAME: &str = "x"; type required, 'static lifetime
Mutable global atomics/OnceLock, not static mut static mut needs unsafe + a concurrency proof

Errors to recognize:

  • E0384 cannot assign twice to immutable variable
  • E0381 used binding isn't initialized (some path skips assignment)
  • mismatched types on let mut reassign (type cannot change)
  • E0063/E0282-family: missing annotation on deferred init or ambiguous const

Shadowing loop idiom:

for line in lines {
    let line = line.trim();   // fresh binding each iteration
    process(line);
}

Continue Learning