02 / 035 min read

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:

  1. 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
  1. The let keyword is required. You can't accidentally shadow — you have to write let again.

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:

letconst
MutableWith mutNever
Type annotationOptional (inferred)Required
ScopeBlock/functionAny scope, including global
ValueAny expressionConstant expression only
Conventionsnake_caseSCREAMING_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: &str

The 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: ok

Type 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 debug

Rust 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 mut to 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 with unsafe
  • 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).