Lesson 5 of 94
Variables & Mutability
In Rust, variables are immutable unless you write mut. This lesson covers everything about declaring variables, with runnable examples from the simplest let to realistic calculations.
| Concept | Rust |
|---|---|
| Immutable variable | let x = 5; (type inferred) |
| Mutable variable | let mut x = 5; |
| Constant | const MAX: i32 = 10; |
| Shadowing | let x = x + 1; |
1. Declaring a variable with let
fn main() {
let language = "Rust";
let year = 2015;
println!("{language} 1.0 was released in {year}");
}Rust 1.0 was released in 2015let introduces a variable. The compiler works out the type from the value.
2. Variables are immutable by default
Assigning a new value to a plain let variable is a compile error.
fn main() {
let stock = 10;
stock = 9;
println!("{stock}");
}error[E0384]: cannot assign twice to immutable variable `stock`
--> src/main.rs:3:5
|
2 | let stock = 10;
| ----- first assignment to `stock`
3 | stock = 9;
| ^^^^^^^^^ cannot assign twice to immutable variable
|
help: consider making this binding mutable
|
2 | let mut stock = 10;
| +++Immutability by default makes code easier to read: when you see let x, you know x never changes, however long the function is.
3. Making a variable mutable with mut
fn main() {
let mut stock = 10;
println!("Stock before sale: {stock}");
stock = 9;
println!("Stock after sale: {stock}");
}Stock before sale: 10
Stock after sale: 9Using mut is normal and not a code smell. Use it whenever a value really needs to change, and leave it off everywhere else. The compiler warns you if you write mut on a variable you never change.
4. Compound assignment operators
Rust has +=, -=, *=, /=, and %=. The variable must be mut.
fn main() {
let mut balance = 100;
balance += 50;
balance -= 30;
balance *= 2;
balance /= 4;
balance %= 7;
println!("{balance}");
}45. There is no ++ or --
Rust deliberately has no increment or decrement operators. Write += 1 instead.
fn main() {
let mut visitors = 0;
visitors += 1;
visitors += 1;
visitors -= 1;
println!("Visitors: {visitors}");
}Visitors: 16. Type annotations
Write : Type after the name to state the type explicitly. The next lesson, Scalar Types, covers every type; for now, i32 is a 32-bit integer, f64 is a 64-bit float, and bool is a boolean.
fn main() {
let quantity: i32 = 3;
let unit_price: f64 = 4.99;
let in_stock: bool = true;
println!("{quantity} x {unit_price} (in stock: {in_stock})");
}3 x 4.99 (in stock: true)Notice the order: Rust writes name: Type.
7. Type inference
Without an annotation, whole numbers default to i32 and decimal numbers default to f64. The type is still fixed at compile time: inference only saves typing.
fn main() {
let count = 42; // i32
let ratio = 0.75; // f64
let active = false; // bool
let initial = 'R'; // char
println!("{count} {ratio} {active} {initial}");
}42 0.75 false ROnce a variable has a type, it keeps it. Assigning a value of a different type is an error:
fn main() {
let mut count = 42;
count = 3.5;
println!("{count}");
}error[E0308]: mismatched types
--> src/main.rs:3:13
|
2 | let mut count = 42;
| -- expected due to this value
3 | count = 3.5;
| ^^^ expected integer, found floating-point number8. Declare now, initialize later
You can declare a variable and give it a value later, as long as it is assigned exactly once before use. It does not need mut for that first assignment.
fn main() {
let shipping_cost;
let order_total = 80;
if order_total >= 50 {
shipping_cost = 0;
} else {
shipping_cost = 5;
}
println!("Shipping: {shipping_cost}");
}Shipping: 0Control Flow covers if in detail.
9. Using a variable before it has a value is an error
Rust rejects reading an uninitialized variable everywhere, including fields and array elements, so there is no such thing as a "default value" you forgot to set.
fn main() {
let discount: i32;
let price = 100;
println!("{}", price - discount);
}error[E0381]: used binding `discount` isn't initialized
--> src/main.rs:4:28
|
2 | let discount: i32;
| -------- binding declared here but left uninitialized
3 | let price = 100;
4 | println!("{}", price - discount);
| ^^^^^^^^ `discount` used here but it isn't initialized
|
help: consider assigning a value
|
2 | let discount: i32 = 42;
| ++++10. Shadowing: redeclaring a name
You can declare a new variable with the same name as an existing one. The new variable shadows the old one from that point on.
fn main() {
let price = 100;
let price = price + 20;
let price = price * 2;
println!("Final price: {price}");
}Final price: 240Each let price creates a brand-new immutable variable.
11. Shadowing can change the type
Because shadowing creates a new variable, the new variable can have a different type. This is common when converting a value step by step.
fn main() {
let username = " admin ";
let username = username.trim();
let username = username.len();
println!("Username length: {username}");
}Username length: 5trim() removes surrounding spaces and len() returns the length. Both are covered in Strings.
12. mut cannot change the type
A mut variable can get a new value, but never a new type. That is the key difference from shadowing.
fn main() {
let mut username = " admin ";
username = username.len();
println!("{username}");
}error[E0308]: mismatched types
--> src/main.rs:3:16
|
2 | let mut username = " admin ";
| ----------- expected due to this value
3 | username = username.len();
| ^^^^^^^^^^^^^^ expected `&str`, found `usize`Rule of thumb: use shadowing to transform a value into a new form, and mut to update a value in place, for example in a loop.
13. Block scope
A pair of braces { } creates a new scope. Variables declared inside disappear at the closing brace.
fn main() {
let outer = 1;
{
let inner = 2;
println!("inside: outer={outer}, inner={inner}");
}
println!("outside: outer={outer}");
}inside: outer=1, inner=2
outside: outer=1Using inner after the closing brace does not compile:
fn main() {
{
let inner = 2;
}
println!("{inner}");
}error[E0425]: cannot find value `inner` in this scope
--> src/main.rs:5:16
|
5 | println!("{inner}");
| ^^^^^
|
help: the binding `inner` is available in a different scope in the same function
--> src/main.rs:3:13
|
3 | let inner = 2;
| ^^^^^14. Shadowing inside a block is temporary
A variable shadowed inside a block is only shadowed until the block ends. After that, the outer variable is visible again, unchanged.
fn main() {
let level = "INFO";
{
let level = "DEBUG";
println!("inside block: {level}");
}
println!("after block: {level}");
}inside block: DEBUG
after block: INFO15. Constants with const
A const is a value fixed at compile time. Constants:
- must have an explicit type,
- use
SCREAMING_SNAKE_CASEnames, - can be declared outside any function, and are visible to everything in the file.
const MAX_LOGIN_ATTEMPTS: i32 = 5;
const TAX_RATE: f64 = 0.2;
fn main() {
println!("Max attempts: {MAX_LOGIN_ATTEMPTS}");
println!("Tax on 50.0: {}", 50.0 * TAX_RATE);
}Max attempts: 5
Tax on 50.0: 1016. Constants can be computed at compile time
A constant can be any expression the compiler can evaluate, including arithmetic on other constants. The result is calculated once, during compilation.
const SECONDS_PER_MINUTE: u64 = 60;
const SECONDS_PER_HOUR: u64 = 60 * SECONDS_PER_MINUTE;
const SECONDS_PER_DAY: u64 = 24 * SECONDS_PER_HOUR;
const SESSION_TIMEOUT_SECS: u64 = 30 * SECONDS_PER_MINUTE;
fn main() {
println!("A day has {SECONDS_PER_DAY} seconds");
println!("Sessions expire after {SESSION_TIMEOUT_SECS} seconds");
}A day has 86400 seconds
Sessions expire after 1800 secondsu64 is an unsigned 64-bit integer; see Scalar Types. Underscores in numbers such as 1_000_000 are allowed for readability.
17. const values cannot be changed
const MAX_USERS: i32 = 100;
fn main() {
MAX_USERS = 200;
println!("{MAX_USERS}");
}error[E0070]: invalid left-hand side of assignment
--> src/main.rs:4:15
|
4 | MAX_USERS = 200;
| --------- ^
| |
| cannot assign to this expression18. static variables
A static is similar to a const, but it has one fixed memory address for the whole run of the program. A const is copied into each place it is used. In practice:
- use
constfor plain values (numbers, limits, names) almost always; - use
staticwhen you need a single shared location, which matters later for shared state across threads.
static APP_NAME: &str = "inventory-service";
static APP_VERSION: &str = "2.4.1";
fn main() {
println!("{APP_NAME} v{APP_VERSION}");
}inventory-service v2.4.1&str is the type of text such as "inventory-service". The & is explained in References & Borrowing.
19. Unused variables and the _ prefix
The compiler warns about variables you never use. Prefix the name with _ to say "unused on purpose", which silences the warning.
fn main() {
let _reserved_for_later = 10;
let _ = 99; // `_` alone means "ignore this value completely"
println!("compiles without warnings");
}compiles without warnings20. Naming conventions
The compiler enforces naming style with warnings, so every Rust codebase looks the same.
| Kind | Rust style |
|---|---|
| Variables, functions | order_total |
| Constants, statics | MAX_RETRIES |
| Types (structs, enums, traits) | OrderStatus |
const DEFAULT_PAGE_SIZE: i32 = 25;
fn main() {
let total_items = 132;
let full_pages = total_items / DEFAULT_PAGE_SIZE;
let items_on_last_page = total_items % DEFAULT_PAGE_SIZE;
println!("{full_pages} full pages, {items_on_last_page} items on the last page");
}5 full pages, 7 items on the last page21. Realistic example: a shopping cart total
This combines const, mut accumulation, and shadowing to produce the final amount.
const TAX_RATE: f64 = 0.08;
const FREE_SHIPPING_THRESHOLD: f64 = 50.0;
const SHIPPING_FEE: f64 = 4.99;
fn main() {
let mut subtotal = 0.0;
subtotal += 2.0 * 12.50; // two notebooks
subtotal += 1.0 * 18.00; // one backpack
subtotal += 3.0 * 1.25; // three pens
let shipping;
if subtotal >= FREE_SHIPPING_THRESHOLD {
shipping = 0.0;
} else {
shipping = SHIPPING_FEE;
}
let tax = subtotal * TAX_RATE;
let total = subtotal + tax + shipping;
let total = (total * 100.0).round() / 100.0; // round to cents
println!("Subtotal: {subtotal:.2}");
println!("Tax: {tax:.2}");
println!("Shipping: {shipping:.2}");
println!("Total: {total:.2}");
}Subtotal: 46.75
Tax: 3.74
Shipping: 4.99
Total: 55.4822. Realistic example: normalizing configuration values
Configuration usually arrives in one unit and is used in another. Shadowing keeps one clear name at each step, without names such as timeoutSecondsRaw and timeoutMillis.
const DEFAULT_TIMEOUT_SECS: u64 = 30;
const MAX_TIMEOUT_SECS: u64 = 120;
fn main() {
let requested_timeout = 300; // pretend this came from a config file
let timeout = requested_timeout;
let timeout = if timeout == 0 { DEFAULT_TIMEOUT_SECS } else { timeout };
let timeout = if timeout > MAX_TIMEOUT_SECS { MAX_TIMEOUT_SECS } else { timeout };
let timeout_ms = timeout * 1000;
println!("Requested {requested_timeout}s, using {timeout}s ({timeout_ms} ms)");
}Requested 300s, using 120s (120000 ms)The line let timeout = if timeout == 0 { ... } else { ... }; uses if as a value. Control Flow explains how this works.
Summary
letdeclares an immutable variable.let mutdeclares a mutable one.- Types are inferred, and you can annotate them with
name: Type. A variable's type never changes. - Shadowing (
let x = ...again) creates a new variable, which may have a new type. - A block
{ }is a scope. Variables disappear at its closing brace. constis a compile-time constant with an explicit type.staticis a single global location.- Prefix unused variables with
_. Usesnake_casefor variables andSCREAMING_SNAKE_CASEfor constants.
Next: Scalar Types, where you learn every number type and how Rust handles overflow.
