Variables, Mutability & Constants

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Rust 1.99.0 and the 2024 edition, checked on 2026-10-06. It covers how values get names: local variables introduced with let, and global values declared with const and static. Ownership of the values behind those names is the subject of Ownership & Moves.

let bindings are immutable by default

let introduces a variable binding. Unlike most mainstream languages, Rust makes bindings immutable unless you say otherwise. The compiler rejects a second assignment with error E0384:

fn main() {
    let copies = 3;
    copies = 4; // error[E0384]: cannot assign twice to immutable variable `copies`
    println!("{copies}");
}

Add mut to allow the binding to change:

fn main() {
    let mut copies = 3;
    copies += 1;
    println!("copies = {copies}"); // copies = 4
}

Immutability by default is a design choice with two benefits. It documents intent: a reader knows that a non-mut binding never changes after initialisation. And it gives the compiler (and the borrow checker) more to work with: a value that cannot change can be shared freely. Mutability is a property of the binding, not of the type: the same String can be moved from an immutable binding into a mutable one.

fn main() {
    let title = String::from("Dune");
    let mut editable = title; // move into a mutable binding
    editable.push_str(" Messiah");
    println!("{editable}");
}

Type annotations and inference

Rust is statically typed, but most local types are inferred from how the value is used. Annotate with : Type when inference has nothing to go on — typically for parse and collect, whose return type is generic:

fn main() {
    let year = 1965;                              // i32, the default integer type
    let pages: u16 = 412;                         // explicit
    let rating = "4".parse::<u8>().unwrap_or(0);  // turbofish instead of an annotation
    let titles: Vec<&str> = "Dune,Emma".split(',').collect();
    println!("{year} {pages} {rating} {titles:?}");
}

Inference is local to a function body: function signatures, const and static items must always be annotated. If inference fails you get error E0282 ("type annotations needed").

Shadowing versus mutation

Declaring a new variable with the same name shadows the previous one. Shadowing creates a new binding — possibly of a different type — while mutation changes the value of the existing binding:

fn main() {
    let input = "  42  ";
    let input = input.trim();          // shadow: still &str
    let input: u32 = input.parse().unwrap_or_default(); // shadow again: now a number
    {
        let input = input * 2;         // shadows only inside this block
        println!("inner: {input}");    // inner: 84
    }
    println!("outer: {input}");        // outer: 42
}

Use shadowing for successive transformations of one logical value (parse, then validate, then normalise) without inventing names such as input_str and input_num. Use mut when a value genuinely changes over time, such as a counter or an accumulator. Shadowing also lets you "freeze" a mutable value once it is built:

fn main() {
    let mut shelf = Vec::new();
    for title in "Dune Emma Middlemarch".split(' ') {
        shelf.push(title);
    }
    let shelf = shelf; // freeze: from here on `shelf` is immutable
    // shelf.push("Ulysses"); // would no longer compile: E0596, cannot borrow as mutable
    println!("{shelf:?}");
}

Declare first, initialise later

A binding can be declared without a value and assigned exactly once later, as long as the compiler can prove it is initialised on every path before use. This is handy when each branch computes the value differently, though an if or match expression is usually clearer:

fn main() {
    let loan_days: u16;
    let member_kind = "student";
    if member_kind == "student" {
        loan_days = 28;
        println!("student loan period applies");
    } else {
        loan_days = 21;
    }
    println!("{loan_days}");

    // The same, as an expression (preferred):
    let loan_days = if member_kind == "student" { 28 } else { 21 };
    println!("{loan_days}");
}

Reading a variable that might be uninitialised is error E0381:

fn main() {
    let due: u16;
    if std::env::args().count() > 1 {
        due = 7;
    }
    println!("{due}"); // error[E0381]: used binding `due` is possibly-uninitialized
}

Unused variables and the underscore convention

The compiler warns about bindings that are never read (unused_variables) and about mut that is never needed (unused_mut). Prefix a name with _ to say "intentionally unused" while still keeping the value alive until the end of the scope; use _ alone to discard the value immediately:

struct Noisy(&'static str);

impl Drop for Noisy {
    fn drop(&mut self) {
        println!("drop {}", self.0);
    }
}

fn main() {
    let _kept = Noisy("kept");   // a binding: lives until the end of main
    let _ = Noisy("discarded"); // `_` is a pattern, not a binding: dropped at once
    let unused_count = 3;        // warning: unused variable (rename to `_unused_count` to silence it)
    println!("end of main");
}
// prints: drop discarded / end of main / drop kept

The difference matters for values whose Drop has side effects, such as lock guards (Clippy denies let _ = mutex.lock() for that reason) and temporary files: let _ = x(); drops the value at the end of the statement, while let _guard = x(); keeps it until the end of the block. See Drop, RAII & Drop Check.

Constants: const

A const item is a named value computed at compile time. Constants are always immutable, must have an explicit type, can be declared in any scope (including at module level), and use SCREAMING_SNAKE_CASE. The initialiser is any constant expression: literals, arithmetic, calls to const fn, and since 1.79, more const features such as inline const blocks.

const LOAN_DAYS: u16 = 21;
const GRACE_DAYS: u16 = 3;
const MAX_LATE: u16 = LOAN_DAYS + GRACE_DAYS; // evaluated by the compiler
const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
const SHELF_CODES: [&str; 3] = ["FIC", "SCI", "HIS"];

fn main() {
    println!("{MAX_LATE} days, {SECONDS_PER_DAY} s/day, {SHELF_CODES:?}");
}

A const has no fixed address: every use is effectively replaced by its value (inlined). Taking a reference to a constant creates a fresh temporary each time, and a const with interior mutability (such as const C: Cell<u8>) gives each use its own copy — Clippy’s declare_interior_mutable_const lint warns about that.

Global variables: static

A static item is a single value with a fixed memory address for the whole run of the program — a true global. It has the 'static lifetime, so references to it can be held anywhere. Statics must be Sync (safe to share between threads), because any thread can reach them.

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

static APP_NAME: &str = "shelf";
static LOANS_CREATED: AtomicU64 = AtomicU64::new(0);   // interior mutability, thread-safe
static RECENT: Mutex<Vec<String>> = Mutex::new(Vec::new());

fn record_loan(title: &str) {
    LOANS_CREATED.fetch_add(1, Ordering::Relaxed);
    RECENT.lock().unwrap().push(title.to_owned());
}

fn main() {
    record_loan("Dune");
    record_loan("Emma");
    println!("{APP_NAME}: {} loans, {:?}", LOANS_CREATED.load(Ordering::Relaxed), RECENT.lock().unwrap());
}
const static

Memory location

none — inlined at each use

exactly one, fixed address

Lifetime of references

a temporary

'static

Interior mutability

a new copy per use

shared by everyone (must be Sync)

Initialiser

constant expression

constant expression; for runtime initialisation use LazyLock (1.80)

Typical use

magic numbers, configuration constants, array sizes

counters, registries, lazily built tables

When the value can only be built at run time — a compiled regular expression, a configuration parsed from the environment — wrap it in std::sync::LazyLock (stabilised in 1.80, replacing the lazy_static! macro and the once_cell crate). See Interior Mutability & Lazy Initialization.

static mut and the 2024 edition

A static mut is a mutable global without synchronisation. Every access is unsafe, because two threads (or a re-entrant call) could touch it at the same time, which is a data race and undefined behaviour. In the 2024 edition, the static_mut_refs lint is deny-by-default: creating a reference to a static mut (&COUNTER, or calling a &self method on it) is rejected, because even a short-lived reference asserts that no one else is writing.

static mut COUNTER: u32 = 0;

fn main() {
    unsafe {
        COUNTER += 1; // a plain read-modify-write is allowed in an unsafe block...
    }
    let r = unsafe { &COUNTER }; // error: creating a shared reference to mutable static
    println!("{r}");
}

The error is reported by the lint (rendered as error: creating a shared reference to mutable static) rather than with an E0xxx code. The fixes, in order of preference:

  1. Use a safe type in a plain static: an atomic (AtomicU32), Mutex/RwLock, OnceLock or LazyLock.

  2. Pass state explicitly instead of using a global.

  3. If static mut is truly required (FFI, embedded), access it through raw pointers with &raw const COUNTER or &raw mut COUNTER (1.82) and document the invariant in a // SAFETY: comment — see Unsafe Rust.

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

static COUNTER: AtomicU32 = AtomicU32::new(0);

fn main() {
    COUNTER.fetch_add(1, Ordering::Relaxed); // safe, thread-safe, no `unsafe` needed
    println!("{}", COUNTER.load(Ordering::Relaxed));
}

Inline const blocks

Since Rust 1.79, const { ... } is an expression evaluated at compile time wherever it appears. It replaces helper constants that only exist to force compile-time evaluation, works in generic code, and can be used for compile-time assertions:

fn empty_shelves<const N: usize>() -> [Vec<String>; N] {
    // `Vec::new()` is not `Copy`, but a const block can be repeated in an array expression.
    [const { Vec::new() }; N]
}

fn main() {
    const { assert!(size_of::<u64>() == 8) }; // fails the *build* if false
    let shelves: [Vec<String>; 4] = empty_shelves();
    println!("{} shelves", shelves.len());
}

Common errors

Error Cause and fix

E0384

assigning twice to an immutable binding: add mut, or shadow with a new let

E0381

using a binding that is not initialised on every path: initialise it, or restructure as an if/match expression

E0596

borrowing an immutable binding as mutable (&mut x, or calling a &mut self method)

E0133

using a static mut (or another unsafe operation) outside an unsafe block

static_mut_refs (2024: deny)

taking a reference to a static mut: switch to an atomic, a lock or &raw pointers

E0015

calling a non-const fn in a const or static initialiser: use LazyLock, or make the function const fn

Where the books are outdated

Yoon’s Beginning Axum (chapter 4) stores a JWT secret in a lazy_static! global. Since Rust 1.80 the standard library’s LazyLock does the same job without a macro or dependency, and secrets should come from configuration passed through application state rather than a global at all (see Axum: Configuration). Older code that takes &STATIC_MUT compiles with a warning in the 2021 edition but is rejected in 2024.