Updated Jun 29, 2026

02. Variables and Mutability

📋 Jump to Takeaways

🎁 What if every variable you created was immutable by default, you literally couldn't change it after assignment? Why would a language force that constraint on you?

Rust makes immutability the default because mutable state is the root of most bugs in concurrent programs. You opt in to mutation explicitly, making your intent clear to both the compiler and anyone reading your code.

Immutable Bindings with let

When you declare a variable with let, it cannot be reassigned:

fn main() {
    let x = 5;
    println!("x is: {}", x);
    // x = 10;  // ERROR: cannot assign twice to immutable variable
}

This isn't a limitation, it's a guarantee. When you see let x = 5, you know x is 5 for its entire lifetime. No hunting through code to find where it changed.

Mutable Bindings with let mut

When you need a variable to change, add mut:

fn main() {
    let mut counter = 0;
    println!("counter: {}", counter); // counter: 0

    counter += 1;
    println!("counter: {}", counter); // counter: 1

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

The mut keyword is a signal. It tells readers: "this value will change." Every mutation point in your program is now visible at the declaration site.

Shadowing

You can redeclare a variable with the same name using a new let. This is called shadowing, the new binding hides the previous one:

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

    let x = x + 10;
    println!("x: {}", x); // x: 15

    let x = x * 2;
    println!("x: {}", x); // x: 30
}

Each let x creates a brand new variable. The old one still exists in memory but is no longer accessible by that name.

Shadowing differs from mut because you can change the type:

fn main() {
    let spaces = "   ";         // &str
    let spaces = spaces.len();  // usize — different type, same name
    println!("spaces: {}", spaces); // spaces: 3
}

With mut, changing the type would be a compile error. Shadowing lets you transform a value and reuse a meaningful name.

Type Inference

Rust's compiler infers types from context. You don't always need annotations:

fn main() {
    let x = 42;        // inferred as i32
    let y = 3.14;      // inferred as f64
    let active = true; // inferred as bool
    let name = "Rust"; // inferred as &str

    println!("{} {} {} {}", x, y, active, name);
    // Output: 42 3.14 true Rust
}

The compiler chooses i32 for integers and f64 for floats when there's no other context. Inference keeps code concise without sacrificing type safety.

Explicit Type Annotations

When inference isn't enough or you want a specific type, annotate with a colon:

fn main() {
    let small: i8 = 127;          // 8-bit signed
    let big: i64 = 9_000_000_000; // 64-bit signed
    let precise: f32 = 3.14;      // 32-bit float (not the default f64)
    let byte: u8 = 255;           // 8-bit unsigned

    println!("small: {}", small);     // small: 127
    println!("big: {}", big);         // big: 9000000000
    println!("precise: {}", precise); // precise: 3.14
    println!("byte: {}", byte);       // byte: 255
}

Annotations are required when the compiler can't determine the type, for example, when parsing a string into a number.

const vs let

const values are compile-time constants. They differ from let in important ways:

const MAX_SCORE: u32 = 100;
const PI: f64 = 3.14159265358979;

fn main() {
    let current_score: u32 = 85;

    println!("Score: {} / {}", current_score, MAX_SCORE);
    // Output: Score: 85 / 100

    println!("Pi: {}", PI);
    // Output: Pi: 3.14159265358979
}

Key differences:

  • const requires a type annotation, always
  • const must be a compile-time computable value, no function calls
  • const can be declared in any scope, including global
  • const is inlined everywhere it's used, no memory address

Use const for values that are truly fixed for the entire program. Use let for everything else.

Key Takeaways

  • let creates immutable bindings, reassignment is a compile error
  • let mut explicitly opts into mutation
  • Shadowing (let x = ... again) creates a new variable and can change the type
  • Rust infers types but you can annotate with : Type
  • The compiler defaults to i32 for integers and f64 for floats
  • const is compile-time, requires a type annotation, and can live globally

🎁 You know how to name and mutate values. But what kinds of values can you hold? Next up: the type system itself, every integer size, why char is 4 bytes, and the difference between a String you own and a &str you borrow.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy