Guide contents

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

Rust
fn main() {
    let language = "Rust";
    let year = 2015;
    println!("{language} 1.0 was released in {year}");
}
Output
Rust 1.0 was released in 2015

let 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.

Rust✗ does not compile · E0384
fn main() {
    let stock = 10;
    stock = 9;
    println!("{stock}");
}
Output
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

Rust
fn main() {
    let mut stock = 10;
    println!("Stock before sale: {stock}");
    stock = 9;
    println!("Stock after sale: {stock}");
}
Output
Stock before sale: 10
Stock after sale: 9

Using 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.

Rust
fn main() {
    let mut balance = 100;
    balance += 50;
    balance -= 30;
    balance *= 2;
    balance /= 4;
    balance %= 7;
    println!("{balance}");
}
Output
4

5. There is no ++ or --

Rust deliberately has no increment or decrement operators. Write += 1 instead.

Rust
fn main() {
    let mut visitors = 0;
    visitors += 1;
    visitors += 1;
    visitors -= 1;
    println!("Visitors: {visitors}");
}
Output
Visitors: 1

6. 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.

Rust
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})");
}
Output
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.

Rust
fn main() {
    let count = 42;      // i32
    let ratio = 0.75;    // f64
    let active = false;  // bool
    let initial = 'R';   // char
    println!("{count} {ratio} {active} {initial}");
}
Output
42 0.75 false R

Once a variable has a type, it keeps it. Assigning a value of a different type is an error:

Rust✗ does not compile · E0308
fn main() {
    let mut count = 42;
    count = 3.5;
    println!("{count}");
}
Output
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 number

8. 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.

Rust
fn main() {
    let shipping_cost;
    let order_total = 80;
    if order_total >= 50 {
        shipping_cost = 0;
    } else {
        shipping_cost = 5;
    }
    println!("Shipping: {shipping_cost}");
}
Output
Shipping: 0

Control 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.

Rust✗ does not compile · E0381
fn main() {
    let discount: i32;
    let price = 100;
    println!("{}", price - discount);
}
Output
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.

Rust
fn main() {
    let price = 100;
    let price = price + 20;
    let price = price * 2;
    println!("Final price: {price}");
}
Output
Final price: 240

Each 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.

Rust
fn main() {
    let username = "  admin  ";
    let username = username.trim();
    let username = username.len();
    println!("Username length: {username}");
}
Output
Username length: 5

trim() 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.

Rust✗ does not compile · E0308
fn main() {
    let mut username = "  admin  ";
    username = username.len();
    println!("{username}");
}
Output
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.

Rust
fn main() {
    let outer = 1;
    {
        let inner = 2;
        println!("inside: outer={outer}, inner={inner}");
    }
    println!("outside: outer={outer}");
}
Output
inside: outer=1, inner=2
outside: outer=1

Using inner after the closing brace does not compile:

Rust✗ does not compile · E0425
fn main() {
    {
        let inner = 2;
    }
    println!("{inner}");
}
Output
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.

Rust
fn main() {
    let level = "INFO";
    {
        let level = "DEBUG";
        println!("inside block: {level}");
    }
    println!("after block: {level}");
}
Output
inside block: DEBUG
after block: INFO

15. Constants with const

A const is a value fixed at compile time. Constants:

  • must have an explicit type,
  • use SCREAMING_SNAKE_CASE names,
  • can be declared outside any function, and are visible to everything in the file.
Rust
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);
}
Output
Max attempts: 5
Tax on 50.0: 10

16. 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.

Rust
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");
}
Output
A day has 86400 seconds
Sessions expire after 1800 seconds

u64 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

Rust✗ does not compile · E0070
const MAX_USERS: i32 = 100;

fn main() {
    MAX_USERS = 200;
    println!("{MAX_USERS}");
}
Output
error[E0070]: invalid left-hand side of assignment
 --> src/main.rs:4:15
  |
4 |     MAX_USERS = 200;
  |     --------- ^
  |     |
  |     cannot assign to this expression

18. 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 const for plain values (numbers, limits, names) almost always;
  • use static when you need a single shared location, which matters later for shared state across threads.
Rust
static APP_NAME: &str = "inventory-service";
static APP_VERSION: &str = "2.4.1";

fn main() {
    println!("{APP_NAME} v{APP_VERSION}");
}
Output
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.

Rust
fn main() {
    let _reserved_for_later = 10;
    let _ = 99; // `_` alone means "ignore this value completely"
    println!("compiles without warnings");
}
Output
compiles without warnings

20. 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
Rust
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");
}
Output
5 full pages, 7 items on the last page

21. Realistic example: a shopping cart total

This combines const, mut accumulation, and shadowing to produce the final amount.

Rust
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}");
}
Output
Subtotal: 46.75
Tax:      3.74
Shipping: 4.99
Total:    55.48

22. 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.

Rust
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)");
}
Output
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

  • let declares an immutable variable. let mut declares 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.
  • const is a compile-time constant with an explicit type. static is a single global location.
  • Prefix unused variables with _. Use snake_case for variables and SCREAMING_SNAKE_CASE for constants.

Next: Scalar Types, where you learn every number type and how Rust handles overflow.