Skip to main content
Rust · lesson 3 of 30
Rust beginner Lesson 3 of 30

Variables in Rust

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

By SkillByExample · Updated

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
}
$ rustc variables.rs && ./variables
x = 5

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);
}
$ rustc variables_mut.rs && ./variables_mut
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

    println!("integer={integer}, float={float}, boolean={boolean}, text={text}");
    println!("parsed={parsed}, big={big}");
}
$ rustc variables_inference.rs && ./variables_inference
integer=42, float=3.14, boolean=true, text=hello
parsed=100, big=1000000

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);
}
$ rustc variables_shadow1.rs && ./variables_shadow1
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);
}
$ rustc variables_shadow2.rs && ./variables_shadow2
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);
}
$ rustc variables_shadow3.rs && ./variables_shadow3
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);
}
$ rustc variables_const.rs && ./variables_const
max points: 100000
pi: 3.141592653589793

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);
}
$ rustc variables_const2.rs && ./variables_const2
86400 seconds in a day

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);
}
$ rustc variables_static.rs && ./variables_static
Hello, world!

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

    println!("decimal={decimal}, hex={hex}, octal={octal}, binary={binary}, byte={byte}, float={float}");
}
$ rustc variables_literals.rs && ./variables_literals
decimal=1000000, hex=255, octal=63, binary=240, byte=65, float=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
}
$ rustc variables_scope.rs && ./variables_scope
outer=10, inner=20
outer=10

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);
}
$ rustc variables_destructure.rs && ./variables_destructure
1 2 3
x=3, y=-1.5
10 30

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

Practice

  1. Predict the output of the shadowing example above, then uncomment the mut-style reassignment x = x + 1; (without let) in a copy of it — what compile error do you get, and why does shadowing avoid it?

    Solution

    error[E0384]: cannot assign twice to immutable variable. x was never declared mut, so a bare reassignment is rejected. Shadowing avoids this because let x = x + 1; introduces a brand-new binding rather than mutating the old one — the compiler never objects to declaring a new variable.

  2. Change let parsed: i64 = "100".parse().unwrap(); to parse "abc" instead. What happens at runtime, and how would you make the failure recoverable instead of crashing?

    Solution

    "abc".parse::<i64>() returns Err(ParseIntError { kind: InvalidDigit }), and .unwrap() panics with called \Result::unwrap()` on an `Err` value: ParseIntError { … }. Replace .unwrap()with amatch, if let Err(e) = …, or .unwrap_or(0)` to handle the error without crashing the program.

  3. Write a small program with a const at module scope and a let binding inside main that both hold the value 10. Try calling a non-const fn inside the const initializer — what does the compiler say?

    Solution

    error[E0015]: cannot call non-const function \double` in constants—constinitializers must be evaluable entirely at compile time, so onlyconst fns (or literals/const-expressions) are allowed. A let` binding has no such restriction because it runs at runtime.

  4. In the destructuring example, replace let (first, _, third) = (10, 20, 30); with let (first, second, third) = (10, 20, 30); but never use second. What does rustc report, and how does _ avoid it?

    Solution

    warning: unused variable: \second`(with a suggestion to prefix itsecond). Naming a binding — notsecond, exactly ` — tells the compiler you’re deliberately discarding that value, so it emits no warning at all, unlike an underscore-prefixed name which still binds (and drops) the value but silences the specific “unused” lint.

  5. Add a static mut COUNTER: u32 = 0; and try to increment it in main with COUNTER += 1; outside an unsafe block. What error does the compiler give, and why is mutable global state special-cased this way?

    Solution

    error[E0133]: use of mutable static is unsafe and requires unsafe function or block — accessing static mut requires unsafe { COUNTER += 1; }. Rust special-cases it because a mutable static is shared, unsynchronized global state: two threads writing to it concurrently is a data race, which Rust’s safety guarantees normally rule out at compile time — unsafe is your explicit acknowledgement that you’re taking on that responsibility yourself.

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.