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}");
}
Source: Error E0384 and The Book: Variables and Mutability
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:?}");
}
Source: Rust by Example: Freezing
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}");
}
Source: Rust by Example: Declare first
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
}
Source: Rust by Example: Declare first
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());
}
Source: The Rust Reference: Static items
const |
static |
|
|---|---|---|
Memory location |
none — inlined at each use |
exactly one, fixed address |
Lifetime of references |
a temporary |
|
Interior mutability |
a new copy per use |
shared by everyone (must be |
Initialiser |
constant expression |
constant expression; for runtime initialisation use |
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:
-
Use a safe type in a plain
static: an atomic (AtomicU32),Mutex/RwLock,OnceLockorLazyLock. -
Pass state explicitly instead of using a global.
-
If
static mutis truly required (FFI, embedded), access it through raw pointers with&raw const COUNTERor&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));
}
Source: std::sync::atomic
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());
}
Source: The Rust Reference: const blocks
Common errors
| Error | Cause and fix |
|---|---|
assigning twice to an immutable binding: add |
|
using a binding that is not initialised on every path:
initialise it, or restructure as an |
|
borrowing an immutable binding as mutable ( |
|
using a |
|
|
taking a reference to a |
calling a non- |
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.
References
-
Rust by Example: Variable Bindings (mutability, scope and shadowing, declare first, freezing)