Skip to main content
Rust beginner Lesson 3 of 30

Variables in Rust

Understand let, mut, shadowing, constants, and type inference in Rust.

Declaring Variables with let

In Rust, variables are declared with the let keyword. By default they are immutable — once bound to a value, you cannot reassign them. This is a deliberate design choice: immutability is the safe default, and you explicitly opt into mutability when you need it. This catches a large category of bugs where a value is accidentally changed somewhere it shouldn’t be.

fn main() {
    let x = 5;
    println!("x = {}", x);

    // x = 6; // ERROR: cannot assign twice to immutable variable
    // The compiler error message even suggests adding `mut` if you need to change x
}

Mutable Variables with mut

When a value genuinely needs to change — like a counter or an accumulator — add the mut keyword. Making mutability explicit in the variable declaration means anyone reading the code can immediately see which values are expected to change and which are fixed.

fn main() {
    let mut count = 0;

    count += 1;
    count += 1;
    println!("count = {}", count); // count = 2
}

Only use mut when the value genuinely needs to change. Unnecessary mutability is a code smell in Rust — it signals that a value might be changed anywhere in its scope, which makes reasoning about code harder.

Type Inference

Rust has a powerful type inference engine that eliminates most type annotations without sacrificing type safety. The compiler determines the type of a variable from how it is initialised and used, so you get the benefits of strong static typing without the verbosity of writing types everywhere. You can always add an explicit annotation for clarity.

fn main() {
    let integer = 42;          // inferred: i32
    let float   = 3.14;        // inferred: f64
    let boolean = true;        // inferred: bool
    let text    = "hello";     // inferred: &str

    // Sometimes inference needs help — annotate the variable
    let parsed: i64 = "100".parse().unwrap();

    // Or annotate directly on the literal with a suffix
    let big = 1_000_000u64;    // u64 suffix on the literal itself
}

You can always annotate explicitly: let x: i32 = 5;

Shadowing

Shadowing lets you redeclare a variable with the same name in the same scope. Each new let creates a fresh binding that hides the previous one for the rest of the scope. This is useful when you want to transform a value through several steps while keeping a clean, descriptive name throughout — rather than inventing new names like x_trimmed or x_parsed.

fn main() {
    let x = 5;
    let x = x + 1;      // new binding that shadows previous x
    let x = x * 2;      // shadows again

    println!("x = {}", x); // x = 12
}

Shadowing is more powerful than mut because it can change the type of the binding:

fn main() {
    let spaces = "   ";         // type: &str
    let spaces = spaces.len();  // type: usize — a completely different type, same name

    println!("spaces = {}", spaces); // spaces = 3
}

With mut this would be a compile error because you cannot change a variable’s type through reassignment. Shadowing is commonly used for sequential transformations:

fn main() {
    let input = "  42  ";
    let input = input.trim();              // shadow: still &str, but whitespace stripped
    let input: i32 = input.parse().unwrap(); // shadow: now an i32
    println!("{}", input + 1);             // 43
}

Constants

Constants are values that are truly fixed for the lifetime of the program. They are declared with const, must have an explicit type annotation, and must be initialised with a constant expression that the compiler can evaluate at compile time. They can be defined at any scope — including at the module or global level — making them the right choice for values like configuration limits, mathematical constants, or magic numbers.

const MAX_POINTS: u32 = 100_000;
const PI: f64 = 3.141_592_653_589_793;

fn main() {
    println!("max points: {}", MAX_POINTS);
    println!("pi: {}", PI);
}

Constants follow the naming convention SCREAMING_SNAKE_CASE. Unlike let bindings:

  • Cannot use mut
  • Must have an explicit type annotation
  • Must be initialised with a constant expression (no runtime function calls, unless const fn)
  • Live for the entire duration of the program
  • Can be placed in any scope including global/module scope

Constants can reference other constants — the compiler evaluates the whole expression at compile time:

const HOURS_IN_DAY: u32 = 24;
const SECONDS_IN_DAY: u32 = HOURS_IN_DAY * 60 * 60; // fully computed at compile time

fn main() {
    println!("{} seconds in a day", SECONDS_IN_DAY); // 86400
}

Static Variables

static variables are similar to const but have a fixed memory address for the lifetime of the program. Use static when you need a stable address (e.g., for FFI or global shared state) or when the value is too large to be inlined everywhere. Mutable statics require unsafe because they can cause data races.

static GREETING: &str = "Hello, world!"; // fixed address, lives for the whole program

fn main() {
    println!("{}", GREETING);
}

Prefer const over static unless you specifically need a stable address or interior mutability.

Numeric Literals

Rust allows underscores in numeric literals for readability, and supports several prefix formats. The underscore is purely cosmetic and can appear anywhere in the number.

fn main() {
    let decimal     = 1_000_000;    // 1000000 — underscores improve readability
    let hex         = 0xFF;         // 255
    let octal       = 0o77;         // 63
    let binary      = 0b1111_0000;  // 240
    let byte        = b'A';         // 65  (u8 only — byte literal)
    let float       = 1_234.567_8;  // 1234.5678
}

Variable Scope

Variables are scoped to the block they are declared in. When the block ends, the variable goes out of scope and is dropped — its memory is freed. This deterministic, block-based scoping is the foundation of Rust’s ownership system, which you will explore in the ownership tutorial.

fn main() {
    let outer = 10;

    {
        let inner = 20;
        println!("outer={}, inner={}", outer, inner); // both visible inside the block
    } // inner is dropped here

    // println!("{}", inner); // ERROR: inner is not in scope
    println!("outer={}", outer); // outer still lives in this scope
}

Destructuring

let can destructure tuples and structs directly into named bindings. This is a form of pattern matching and is one of the most readable ways to work with compound values.

fn main() {
    // Destructure a tuple into three separate bindings
    let (a, b, c) = (1, 2, 3);
    println!("{} {} {}", a, b, c);

    let point = (3.0_f64, -1.5_f64);
    let (x, y) = point;
    println!("x={}, y={}", x, y);

    // Use _ to ignore fields you don't need
    let (first, _, third) = (10, 20, 30);
    println!("{} {}", first, third);
}

Summary

FeatureSyntaxNotes
Immutable variablelet x = 5;Default; cannot reassign
Mutable variablelet mut x = 5;Can reassign same type
Shadowinglet x = x + 1;New binding; can change type
Constantconst N: u32 = 10;Compile-time, global scope OK
Staticstatic S: &str = "hi";Fixed address, lives forever

Frequently Asked Questions

Why are variables immutable by default in Rust?
Immutability by default prevents accidental mutation and makes code easier to reason about. You opt into mutability explicitly with mut.
What is shadowing in Rust?
Shadowing lets you reuse a variable name in the same scope by declaring a new let binding. Unlike mut, shadowing can change the type.
What is the difference between const and let?
const is truly constant, must have an explicit type, is evaluated at compile time, and can be placed in any scope including global. let creates a runtime binding scoped to its block.