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
| Feature | Syntax | Notes |
|---|---|---|
| Immutable variable | let x = 5; | Default; cannot reassign |
| Mutable variable | let mut x = 5; | Can reassign same type |
| Shadowing | let x = x + 1; | New binding; can change type |
| Constant | const N: u32 = 10; | Compile-time, global scope OK |
| Static | static S: &str = "hi"; | Fixed address, lives forever |