Variables and Mutability
Why variables are immutable by default in Rust, how to opt into mutability, the difference between shadowing and mutation, and constants.
Immutable by Default
In most languages, variables are mutable unless you explicitly mark them constant (const in JS/C++, final in Java). Rust inverts this default: variables are immutable unless you explicitly opt in to mutability.
fn main() {
let x = 5;
println!("x = {x}");
x = 6; // compile error
}error[E0384]: cannot assign twice to immutable variable `x`
This is not a restriction for its own sake. Immutability is a contract: when you pass an immutable binding to a function or share it across code, you have a compiler-enforced guarantee that nothing can change it. This eliminates an entire class of bugs where state changes unexpectedly.
Making Variables Mutable
Add mut to opt in:
fn main() {
let mut x = 5;
println!("x = {x}"); // x = 5
x = 6;
println!("x = {x}"); // x = 6
}mut is explicit. When you see it, you know the variable can change. When you don't see it, you know it can't. This makes code easier to reason about — especially in large codebases.
Guideline: Start with immutable bindings. Add mut only when you have a reason to mutate. The compiler will tell you if you declared mut but never mutated (a warning: variable does not need to be mutable).
Shadowing
Rust allows you to declare a new variable with the same name as an existing one. The new binding shadows the old one — the old value is gone (within the current scope).
fn main() {
let x = 5;
let x = x + 1; // shadows the first x
{
let x = x * 2; // shadows within this inner scope
println!("inner x = {x}"); // inner x = 12
}
println!("outer x = {x}"); // outer x = 6
}Shadowing is different from mutation in two ways:
- You can change the type. Shadowing creates a new binding; mutation changes the value of an existing binding of a fixed type.
// Shadowing — type changes from &str to usize
let spaces = " ";
let spaces = spaces.len(); // now spaces is 3 (usize)
// Mutation — type must stay the same
let mut spaces = " ";
spaces = spaces.len(); // compile error: mismatched types- The
letkeyword is required. You can't accidentally shadow — you have to writeletagain.
Shadowing is useful for transformations: parse a string, shadow it with the parsed integer. The variable name stays meaningful without needing a different name for each stage (input_str, input_parsed, etc.).
Constants
Constants are always immutable — mut is not allowed. They must have an explicit type annotation and can only be set to a constant expression (not a runtime value).
const MAX_CONNECTIONS: u32 = 1_000;
const TIMEOUT_SECS: f64 = 30.0;Key differences from let:
let | const | |
|---|---|---|
| Mutable | With mut | Never |
| Type annotation | Optional (inferred) | Required |
| Scope | Block/function | Any scope, including global |
| Value | Any expression | Constant expression only |
| Convention | snake_case | SCREAMING_SNAKE_CASE |
Constants are evaluated at compile time and inlined wherever they're used. They're ideal for configuration values, magic numbers, and anything that should be globally accessible and never change.
const SECONDS_IN_DAY: u64 = 60 * 60 * 24; // evaluated at compile time
fn main() {
println!("Seconds in a day: {SECONDS_IN_DAY}");
}Static Variables
static is similar to const but represents a fixed memory location (rather than being inlined). They live for the entire duration of the program.
static GREETING: &str = "Hello";
fn main() {
println!("{GREETING}, world!");
}static variables can be mutable (static mut), but accessing them requires unsafe — because mutable global state is a data race waiting to happen. In practice, use thread-safe types like Mutex or RwLock instead of static mut.
Type Inference
Rust has strong static typing, but you rarely write types explicitly for local variables because the compiler infers them:
let x = 5; // inferred: i32
let y = 3.14; // inferred: f64
let active = true; // inferred: bool
let name = "Alice"; // inferred: &strThe compiler infers the type from the value assigned and how the variable is subsequently used. If it can't determine the type unambiguously, it will ask you to annotate it:
let numbers = Vec::new(); // error: type annotations needed
let numbers: Vec<i32> = Vec::new(); // explicit annotation: okType inference is not the same as dynamic typing. The types are fully known at compile time — you just don't always have to write them.
Printing Variables
println! uses format strings. The {} placeholder calls the Display trait; {:?} calls Debug (useful for types that don't implement Display).
let x = 42;
let name = "Rust";
println!("{name} version {x}"); // positional by name
println!("{} version {}", name, x); // positional by order
println!("debug: {:?}", (x, name)); // debug format: (42, "Rust")
println!("pretty: {:#?}", (x, name)); // pretty-printed debugRust 1.58+ supports {variable_name} directly in the format string (as shown above), which is cleaner than positional arguments for simple cases.
Summary
- Variables are immutable by default — add
mutto opt in - Shadowing lets you re-declare with the same name; the type can change
- Constants (
const) are always immutable, must be typed, live in any scope - Statics (
static) are fixed memory locations, mutable only withunsafe - Type inference eliminates most explicit annotations for local variables
Next: Data Types — Rust's scalar types (integers, floats, booleans, characters) and compound types (tuples and arrays).