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 addmutwhen the compiler actually complains that you need it. This keeps your code easier to reason about. - Prefer shadowing over
mutwhen you’re transforming a value into something conceptually related but of a different type or representation (like a string into its length). - Use
constfor values that are truly fixed forever (limits, magic numbers, configuration ceilings) and name them inSCREAMING_SNAKE_CASEby 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
mutas 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
constfor a shop’s tax rate (e.g.0.08as anf64) and amutvariable 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 theprintln!inside the block.
Summary
- Variables declared with
letare immutable by default; addmutto allow reassignment. - Immutability is enforced entirely at compile time and costs nothing at runtime.
mutlets you change a binding’s value, but never its type.- Shadowing (a second
letwith the same name) creates a completely new binding, and can change type — it is not the same thing as mutation. constdeclares a compile-time constant that always needs an explicit type and can never be markedmut.- Variables are dropped when their enclosing scope (
{ }) ends, and inner-scope variables are inaccessible outside that scope.
