Why this matters
Programs work by computing values and keeping them around under names. In most languages a variable is simply a box you can overwrite at will. Rust makes a different default choice: names are immutable unless you say otherwise. That one decision removes a whole class of "who changed this?" bugs and makes code easier to reason about, and it is your first taste of how Rust uses the compiler to enforce intentions. This lesson covers let, mut, shadowing and constants, and what they look like in memory.
let: binding a name to a value
fn main() {
let apples = 5;
let price = 0.25;
let total = apples as f64 * price;
println!("{apples} apples cost {total}");
}
5 apples cost 1.25
let creates a variable: it reserves space for a value, puts the value there, and binds a name to it. Rust programmers often say "binding" rather than "variable", because, as you will see, the name and the value are less tightly coupled than in some languages.
Every variable has a type, fixed at compile time. You did not write any types above; the compiler inferred them: 5 is an integer, so apples gets the default integer type i32 (a 32-bit signed integer); 0.25 is a float, f64. You can write the type yourself with a type annotation:
fn main() {
let apples: i32 = 5;
let price: f64 = 0.25;
let initial: char = 'R';
println!("{apples} {price} {initial}");
}
5 0.25 R
Inference never guesses at run time: by the time the program runs, every variable has one concrete type. (apples as f64 converts the integer to a float, because Rust never mixes number types silently. The next lessons cover number types and conversions in detail.)
Where variables live
Local variables live in the function's stack frame, a block of memory reserved when the function starts and released when it returns:
main's stack frame
+-----------+-------------------------+
| apples | 5 (4 bytes, i32) |
| price | 0.25 (8 bytes, f64) |
| total | 1.25 (8 bytes, f64) |
+-----------+-------------------------+
The names exist only for you and the compiler; in the machine code, apples is just "the four bytes at a certain offset in the frame". Knowing a value's type tells the compiler how many bytes to reserve. Module 4 returns to the stack in detail, together with the heap.
Immutable by default
Try to change a variable:
fn main() {
let score = 10;
println!("score: {score}");
score = 15;
println!("score: {score}");
}
error[E0384]: cannot assign twice to immutable variable `score`
--> main.rs:4:5
|
2 | let score = 10;
| ----- first assignment to `score`
3 | println!("score: {score}");
4 | score = 15;
| ^^^^^^^^^^ cannot assign twice to immutable variable
|
help: consider making this binding mutable
|
2 | let mut score = 10;
| +++
E0384: "cannot assign twice to immutable variable". The labels walk you through it: the first assignment happened at the let, the second is not allowed, and the help offers the fix.
Why make immutability the default? When you read let limit = 100; in a long function, you know limit is 100 everywhere below, without reading every line in between. Changing state is where bugs live, so Rust asks you to mark it.
mut: opting in to change
fn main() {
let mut score = 10;
println!("score: {score}");
score = 15;
score = score + 1;
score += 1;
println!("score: {score}");
}
score: 10
score: 17
mut makes the binding mutable: you may assign new values of the same type. score += 1 is shorthand for score = score + 1; there are also -=, *=, /= and %=. There is no ++ in Rust.
mut changes the value, never the type. score = "high"; would be E0308 mismatched types: the slot in the stack frame is four bytes reserved for an i32, and a piece of text does not go there.
Shadowing: a new variable with an old name
You can declare a new variable with the same name as an existing one:
fn main() {
let input = "42";
let input: i32 = input.parse().unwrap();
let input = input * 2;
println!("{input}");
}
84
Each let input creates a new variable that shadows (hides) the previous one from that point on. Nothing is modified: the old value is still there, just no longer reachable by that name. Here the first input is text, the second is a number parsed from it, and the third is twice that. (.parse().unwrap() turns text into a number; it is explained in the lesson on reading input.)
Shadowing versus mut:
mut | shadowing | |
|---|---|---|
| changes the existing value | yes | no, creates a new variable |
| can change the type | no | yes |
| result is immutable afterwards | no | yes, unless the new let says mut |
Shadowing is idiomatic for transforming a value through steps where the intermediate forms are not interesting by themselves: raw text, then trimmed text, then a parsed number. It avoids inventing names like input_str, input_trimmed, input_num.
Shadowing respects blocks. A { ... } block opens a new scope; a let inside it lasts only until its closing brace:
fn main() {
let x = 1;
{
let x = x + 10;
println!("inner x = {x}");
}
println!("outer x = {x}");
}
inner x = 11
outer x = 1
Declaring now, initializing later
A variable can be declared without a value, as long as it is assigned before use:
fn main() {
let message;
message = "assigned once";
println!("{message}");
}
assigned once
This is still an immutable variable: it is assigned exactly once. The compiler tracks this on every path through the code. Using it before assignment is an error:
fn main() {
let total: i32;
println!("{total}");
}
error[E0381]: used binding `total` isn't initialized
--> main.rs:3:15
|
2 | let total: i32;
| ----- binding declared here but left uninitialized
3 | println!("{total}");
| ^^^^^^^ `total` used here but it isn't initialized
|
help: consider assigning a value
|
2 | let total: i32 = 42;
| ++++
E0381. In C, reading an uninitialized variable silently gives you whatever bytes happened to be in memory. Rust refuses to compile it.
Constants
const MAX_PLAYERS: u32 = 4;
const SECONDS_PER_HOUR: u32 = 60 * 60;
fn main() {
println!("{MAX_PLAYERS} players, {SECONDS_PER_HOUR} s per hour");
}
4 players, 3600 s per hour
A const differs from an immutable let:
- The type annotation is required.
- The value must be computable at compile time: literals, arithmetic, other constants, and calls to special
const fnfunctions. Nothing that depends on input. - It may be declared at the top level of a file, outside any function, and used everywhere below it in that scope.
- It has no fixed memory location; the compiler pastes the value in wherever it is used.
- By convention, names are
SCREAMING_SNAKE_CASE. Variables and functions usesnake_case.
Use constants for fixed facts of your program: limits, conversion factors, magic numbers that deserve a name.
Unused and underscore names
If you create a variable and never read it, you get a warning, because that often signals a bug (you computed something and then used the wrong name). A name starting with _ means "unused on purpose" and silences the warning. A lone _ is a special pattern that does not bind at all.
Common mistakes
Assigning to an immutable variable gives E0384, as shown. Add mut if the value really needs to change, or use shadowing if you are transforming it into something new.
Changing the type through mut:
fn main() {
let mut count = 0;
count = "zero";
}
error[E0308]: mismatched types
--> main.rs:3:13
|
2 | let mut count = 0;
| - expected due to this value
3 | count = "zero";
| ^^^^^^ expected integer, found `&str`
Expecting shadowing to modify the outer variable. A let inside a block does not change the variable outside it, as the block example showed. If you wanted the outer value updated, you wanted mut and plain assignment (x = x + 10;), not a new let.
Using a variable before giving it a value: E0381.
Recap
let name = value;binds a name to a value; the type is inferred or written aslet name: T.- Variables are immutable by default (E0384 on reassignment);
let mutallows new values of the same type. - Shadowing (
let name = ...again) creates a new variable, possibly of a new type, and respects block scope. - Variables must be initialized before use (E0381).
const NAME: T = expr;is a compile-time constant with a mandatory type.- Locals live in the function's stack frame; their type decides their size.
Going further: try let x = 5; let x = x; and let mut x = 5; let x = x; and think about what the second line does to mutability. The Rust Book, chapter 3.1, covers the same ground.