Variables & Mutability in Rust
Rust takes an opinionated stance on variables: they are immutable by default. This is not a limitation — it is one of Rust's most powerful safety features. When a value cannot change, the compiler can reason about your code more precisely, eliminate entire classes of bugs, and even generate better-optimised machine code.
Creating Variables with let
Every variable in Rust is introduced with the let keyword. Without any further
annotation the binding is immutable — you get one chance to assign a value and the
compiler will reject any later attempt to overwrite it.
fn main() {
let x = 5;
println!("x is: {}", x);
}x is: 5
What Happens When You Mutate an Immutable Variable
If you attempt to assign a new value to an immutable binding, Rust refuses to compile. Crucially, the compiler does not just say "no" — it tells you exactly what went wrong and how to fix it.
fn main() {
let x = 5;
x = 6; // ERROR: cannot assign twice to immutable variable
println!("x is: {}", x);
}error[E0384]: cannot assign twice to immutable variable `x` --> src/main.rs:3:5 | 2 | let x = 5; | - | | | first assignment to `x` | help: consider making this binding mutable: `mut x` 3 | x = 6; | ^^^^^ cannot assign twice to immutable variable
mut to the binding. Rust's error messages are designed to be actionable, not just diagnostic.Making a Variable Mutable with mut
When you genuinely need a variable whose value will change over time, add mut
after the let keyword. This makes the intent visible at the declaration site.
fn main() {
let mut counter = 0;
println!("counter starts at: {}", counter);
counter = counter + 1;
println!("counter is now: {}", counter);
counter += 1;
println!("counter is now: {}", counter);
}counter starts at: 0 counter is now: 1 counter is now: 2
mut sparingly and purposefully. If a variable never changes after its initial assignment, leave off mut — it is free documentation that says "this value is stable."Type Inference
Rust has a powerful type inference engine. In most situations you do not need to write the type at all — the compiler deduces it from the value you assign or how the variable is later used.
fn main() {
let age = 30; // inferred as i32
let price = 9.99; // inferred as f64
let active = true; // inferred as bool
let initial = 'R'; // inferred as char
println!("{} {} {} {}", age, price, active, initial);
}30 9.99 true R
Explicit Type Annotations
You can — and sometimes must — write the type explicitly using a colon after the
variable name. Annotations are required when the compiler cannot infer the type, when
you want a different type than the default (e.g. u8 instead of i32), or simply
when clarity matters more than brevity.
fn main() {
let age: u8 = 30;
let temperature: f32 = 36.6;
let score: i64 = 1_000_000;
let letter: char = 'Z';
let flag: bool = false;
println!("age={} temp={} score={} letter={} flag={}",
age, temperature, score, letter, flag);
}age=30 temp=36.6 score=1000000 letter=Z flag=false
1_000_000 and 1000000 are identical to the compiler.Immutability vs const
Rust has two different concepts that beginners often conflate: immutable let bindings
and const declarations. They serve different purposes.
Feature | let (immutable) | const |
|---|---|---|
Keyword | let | const |
Type annotation | Optional (inferred) | Required |
Computed at | Runtime | Compile time |
Allowed value | Any expression | Constant expressions only |
Scope | Block / function | Any scope including global |
Naming convention | snake_case | SCREAMING_SNAKE_CASE |
Can be mut | Yes (let mut) | No — always immutable |
const MAX_CONNECTIONS: u32 = 100;
const APP_NAME: &str = "LetCodes";
fn main() {
let user_count = get_user_count();
println!("{} has {} users (max {})", APP_NAME, user_count, MAX_CONNECTIONS);
}
fn get_user_count() -> u32 {
42
}LetCodes has 42 users (max 100)
Underscore Prefix for Unused Variables
Rust warns you when you declare a variable and never use it — this catches typos and forgotten logic. When a variable is intentionally unused, prefix its name with an underscore to silence the warning.
fn main() {
let _placeholder = "I know I don't use this yet";
let result = compute();
println!("result: {}", result);
}
fn compute() -> i32 {
let _intermediate = 99; // silences the unused-variable warning
42
}_ on its own is a special "discard" pattern — it does not even bind the value. _name binds but suppresses the warning; _ discards entirely.Variable Shadowing
Rust allows you to declare a new variable with the same name as an existing one.
The new binding shadows the old one — from that point on the name refers to the new
value. Shadowing is different from mutation: each let creates a brand-new binding
that can even have a different type.
fn main() {
let x = 5;
println!("x = {}", x); // 5
let x = x + 1; // shadows the previous x
println!("x = {}", x); // 6
let x = x * 2; // shadows again
println!("x = {}", x); // 12
// Shadowing can change the type entirely
let value = " hello ";
let value = value.trim(); // still &str but trimmed
let value = value.len(); // now usize
println!("trimmed length: {}", value);
}x = 5 x = 6 x = 12 trimmed length: 5
trimmed_value or value_len.Destructuring Multiple Bindings
Rust supports destructuring in let statements, binding several names at once from
a tuple or struct.
fn main() {
let (x, y, z) = (1, 2, 3);
println!("x={} y={} z={}", x, y, z);
let (name, age): (&str, u8) = ("Alice", 28);
println!("{} is {} years old", name, age);
// Ignore parts you do not need with _
let (first, _, third) = ("a", "b", "c");
println!("first={} third={}", first, third);
}x=1 y=2 z=3 Alice is 28 years old first=a third=c
Why Immutability by Default is a Feature
Prevents accidental mutation — mutations must be declared at the binding site, making bugs from unexpected state changes impossible
Enables fearless concurrency — immutable data can be shared across threads without locks
Self-documenting code — the absence of
muttells the reader this value never changesBetter optimisations — the compiler makes stronger assumptions about immutable data
Compiler as collaborator — when you forget
mut, the error message tells you exactly how to fix it
Common Patterns at a Glance
fn main() {
// Immutable binding — the most common case
let greeting = "Hello, Rust!";
// Mutable counter
let mut count = 0;
count += 1;
// Explicit type when the default is wrong
let small: u8 = 255;
// Destructuring
let (a, b) = (10, 20);
// Intentionally unused during development
let _todo = "implement later";
// Shadowing for transformation
let raw = " 42 ";
let raw = raw.trim();
let number: i32 = raw.parse().expect("not a number");
println!("{}", greeting);
println!("count={} small={} a={} b={} number={}",
count, small, a, b, number);
}Hello, Rust! count=1 small=255 a=10 b=20 number=42