Every binding introduced with let is immutable unless explicitly marked
mut. This is the opposite default from most mainstream languages (Java,
Python, JavaScript, C), where variables are mutable unless specially
declared const/final.
The practical effect is that immutability becomes the norm you reach for,
and mutability becomes something you opt into deliberately at each
binding site — a small, local signal to a reader that this particular
variable is expected to change, which makes the ones that don't stand out
as safe to reason about without tracking their value over time.
This default also interacts with the borrow checker directly: a shared
reference (&T) cannot be used to mutate through it (unless the type
opts into interior mutability — Cell, RefCell, Mutex, atomics — see
Interior mutability), precisely
because immutability-by-default is the baseline the whole borrowing model
is built on top of — mutability is the special case that needs an
explicit &mut to unlock, not the other way around. This is a large part
of why data races are ruled out at compile time: outside of those
interior-mutability types, you cannot have two
simultaneous mutable accesses to the same data without the compiler
seeing an explicit &mut for it.
Basic usage example
let x = 5;
// x = 6; // would fail to compile: x is immutable by default
let mut y = 5;
y = 6; // <- `mut` explicitly opts this binding into reassignment
println!("{x} {y}");
Best practices & deeper information
Scenario
Creating a new object
Constructing a fully-formed Order in one expression, rather than
creating a default and mutating fields into place afterward, means the
binding never needs mut at all.
struct Order {
id: u64,
total_cents: u64,
status: &'static str,
}
// AVOID: default-then-mutate needs `mut` and leaves a window where `order` is incompletely formed
// let mut order = Order { id: 0, total_cents: 0, status: "" };
// order.id = 42;
// order.total_cents = 1999;
// order.status = "pending";
// PREFER: build the finished value in one immutable binding
let order = Order { id: 42, total_cents: 1999, status: "pending" }; // <- no `mut` needed: fully formed up front
Why this way: constructing a value complete at its point of creation
means there's no intermediate state where the struct exists but is only
half-initialized — the
Rust Book
frames immutable-by-default as nudging code toward exactly this shape,
reserving mut for values that genuinely change over their lifetime.
Scenario
Designing a public API
A public config type that hands back a new, independent value from an
"update" — instead of exposing mutable setters — means no caller has to
worry about a value changing out from under them after they've stored it.
#[derive(Clone)]
struct RetryPolicy {
max_attempts: u32,
backoff_ms: u32,
}
impl RetryPolicy {
fn with_max_attempts(&self, max_attempts: u32) -> Self { // <- PREFER: returns a new, independent value
Self { max_attempts, ..self.clone() }
}
}
// AVOID: a public `set_max_attempts(&mut self, ...)` lets any holder mutate a policy others may rely on
let default_policy = RetryPolicy { max_attempts: 3, backoff_ms: 100 };
let aggressive = default_policy.with_max_attempts(10); // default_policy is untouched
Why this way: an API built around producing new immutable values
instead of mutating shared ones means no caller has to track whether a
RetryPolicy they're holding might change later — the
API Guidelines
favor predictable, non-surprising behavior, and an immutable-by-default
value type is the simplest way to guarantee it.
Explanation
Embedded support: Full
The immutable-by-default rule is enforced identically on every target —
no std/allocator dependency, nothing changes under #![no_std]. Where
it earns its keep especially in embedded code is around hardware
configuration values: a peripheral config (baud rate, clock prescaler,
ADC gain, GPIO mode) is typically built completely and then handed once
to a HAL init/new call that consumes it and programs the actual
hardware registers from it. Once that config has been "committed" to
silicon, there's no code path where mutating the original binding would
have any further effect — the hardware doesn't watch a config struct for
changes, it was read once at init time. A binding that's immutable by
default communicates that directly: if the value can never be reassigned
after construction, nobody reading the surrounding code needs to search
for a place it might quietly drift out of sync with the hardware it
configured. This heads off a real, common bug class in embedded C, where
a config struct is a mutable global that other code can innocently poke
after init, silently desyncing software's idea of the configuration from
what's actually latched into the peripheral's registers — Rust's default
makes that class of accidental "config drift" a compile error the moment
a stray assignment to a non-mut binding is attempted.
Basic usage example
struct UartConfig { baud_rate: u32, stop_bits: u8 }
let config = UartConfig { baud_rate: 115_200, stop_bits: 1 }; // <- no `mut`: never touched again
// config.baud_rate = 9600; // would fail to compile
let serial = Serial::init(config); // consumes the fully-formed config, programs the hardware once
Best practices & deeper information
Scenario
Creating a new object
Build the UART config completely before handing it to init, rather than
starting from a default and mutating fields into place.
struct UartConfig { baud_rate: u32, stop_bits: u8, parity: bool }
// AVOID: default-then-mutate needs `mut` and leaves a window where `config`
// doesn't yet match what will actually be programmed into the peripheral
// let mut config = UartConfig { baud_rate: 9600, stop_bits: 1, parity: false };
// config.baud_rate = 115_200;
// config.parity = true;
// PREFER: build the finished config in one immutable binding
let config = UartConfig { baud_rate: 115_200, stop_bits: 1, parity: true }; // <- no `mut` needed
let serial = Serial::init(config); // <- consumes the config exactly once; nothing can drift after this
Why this way: a config value that's complete the moment it's created
has no window where its fields don't yet match what's about to be
programmed into the peripheral — the same reasoning the
Rust Book
gives for immutable-by-default applies with extra weight here, since the
"half-built" state it avoids would otherwise be a config that looks valid
in software but doesn't yet match the hardware it's about to configure.
Scenario
Designing a public API
The clock-configuration "freeze" pattern common to Cortex-M HAL crates: a
builder is assembled, then consumed by a call that commits it to hardware
and hands back an immutable value with no way to mutate it afterward.
struct ClockConfig { sysclk_hz: u32, pclk1_hz: u32 }
impl ClockConfig {
fn sysclk(mut self, hz: u32) -> Self { self.sysclk_hz = hz; self } // <- returns a new value, not &mut self
fn freeze(self) -> Clocks { // <- consumes self: commits the config to the actual clock hardware
Clocks { sysclk_hz: self.sysclk_hz, pclk1_hz: self.pclk1_hz }
}
}
struct Clocks { sysclk_hz: u32, pclk1_hz: u32 } // <- no setters: a frozen Clocks can't drift from the hardware it describes
let clocks = ClockConfig { sysclk_hz: 8_000_000, pclk1_hz: 8_000_000 }
.sysclk(48_000_000)
.freeze(); // <- committed; reconfiguring means building a new ClockConfig, not mutating this one
Why this way: exposing Clocks as an immutable value with no setters
means every later reader can trust clocks.sysclk_hz actually matches
what the hardware is running at, instead of having to double-check
whether some other code path mutated it after freeze() ran — the same
"predictable, non-surprising API" reasoning the
API Guidelines
give for returning new values instead of exposing mutable setters, applied
here to a value a physical clock peripheral has already been configured
from.