Variables, Mutability & Shadowing
A variable is a name for a value your program uses, such as a player's score or a train number. In Rust you create variables with let, and you will quickly notice something unusual: once a value is stored, you cannot change it unless you say so.
In this lesson you will learn how to declare variables, why they are immutable by default, how to make them mutable, how constants differ from variables, and a Rust feature called shadowing that lets you reuse a name.
Declaring variables with let
let binds a value to a name. Rust works out the type from the value, so you usually do not have to write it:
fn main() {
let train = "Shatabdi Express";
let coach = 'C';
let seats = 78;
let fare: f64 = 1155.0;
println!("{train}, coach {coach}: {seats} seats, fare Rs. {fare:.2}");
}Shatabdi Express, coach C: 78 seats, fare Rs. 1155.00
Rust guessed that train is text, coach is a single character and seats is a whole number. For fare we wrote the type ourselves with : f64 (a decimal number); this is called a type annotation. Inside println!, {fare:.2} prints the value with exactly two digits after the decimal point.
Variable names use snake_case: lowercase words joined by underscores, such as total_marks. The compiler warns you if you write totalMarks.
Immutable by default
Try to change a variable after creating it:
fn main() {
let wickets = 3;
println!("Wickets: {wickets}");
wickets = 4;
println!("Wickets: {wickets}");
}error[E0384]: cannot assign twice to immutable variable `wickets`
--> src/main.rs:4:5
|
2 | let wickets = 3;
| ------- first assignment to `wickets`
3 | println!("Wickets: {wickets}");
4 | wickets = 4;
| ^^^^^^^^^^^ cannot assign twice to immutable variableThe compiler also suggests the fix: "consider making this binding mutable". Why is immutable the default? In a big program, a value that can change anywhere is hard to reason about. When a variable cannot change, you only need to look at the line where it was created to know its value. Immutability also makes it safe to share data between threads, which you will see in Fearless Concurrency.
Making a variable mutable with mut
When a value really needs to change, add mut after let:
fn main() {
let mut runs = 0;
runs += 4;
runs += 6;
runs += 1;
println!("Runs after three balls: {runs}");
}Runs after three balls: 11
runs += 4 is short for runs = runs + 4. The word mut is a signal to every reader: "watch this value, it changes". Use it only when needed; the compiler warns you if you write mut on a variable that never changes.
A variable can also be declared first and given its value later, as long as it is assigned exactly once before use. The compiler checks every path:
fn main() {
let percentage = 82;
let grade: char;
if percentage >= 75 {
grade = 'A';
} else {
grade = 'B';
}
println!("Grade: {grade}");
}Grade: A
Constants and statics
A constant is a value fixed when the program is compiled. Declare it with const, write its name in SCREAMING_SNAKE_CASE, and always give its type:
const MAX_STUDENTS: u32 = 60;
const GST_PERCENT: f64 = 18.0;
fn main() {
let price = 1299.0;
let gst = price * GST_PERCENT / 100.0;
println!("Seats per batch: {}", MAX_STUDENTS);
println!("GST on Rs. {price:.2} is Rs. {gst:.2}");
}Seats per batch: 60 GST on Rs. 1299.00 is Rs. 233.82
let | let mut | const | |
|---|---|---|---|
| Can change | No | Yes | No |
| Type annotation | Optional | Optional | Required |
| Value known | At run time | At run time | At compile time |
| Where allowed | Inside functions | Inside functions | Anywhere, including outside functions |
Constants can be used anywhere in the file, and the compiler copies the value into each place it is used. Rust also has static items, written like static APP_NAME: &str = "Marks Report";. A static lives at one fixed memory address for the whole program; you will rarely need one, and mutable statics require unsafe code (see Unsafe Rust).
Shadowing
You can declare a new variable with the same name as an earlier one. The new variable shadows the old one: from that line on, the name refers to the new value.
fn main() {
let marks = "86"; // text from a form
let marks: u32 = marks.trim().parse().unwrap(); // now a number
let marks = marks + 5; // add grace marks
println!("Final marks: {marks}");
{
let marks = marks * 2;
println!("Inside the block: {marks}");
}
println!("After the block: {marks}");
}Final marks: 91 Inside the block: 182 After the block: 91
The first marks is text. .parse() converts it to a number, and .unwrap() takes the number out of the result (you will learn the safe way to handle failure in Option & Result). Each let creates a brand-new variable, so the type can change from text to u32. Inside the inner block the shadow lasts only until the closing }, so after the block marks is 91 again.
Shadowing is different from mut. With mut you change the value in the same variable, and the type must stay the same:
fn main() {
let mut phone = "98765 43210";
phone = phone.len();
}error[E0308]: mismatched types --> src/main.rs:3:13 | 2 | let mut phone = "98765 43210"; | ------------- expected due to this value 3 | phone = phone.len(); | ^^^^^^^^^^^ expected `&str`, found `usize`
phone holds text (type &str), and .len() returns a number (usize), so the assignment is rejected. Writing let phone_len = phone.len(); or shadowing with let phone = phone.len(); both work. Shadowing is useful for small transformations of one value; a new, clearer name is often better when the meaning changes.
Common mistakes
- Adding
muteverywhere. It hides which values really change. Start withoutmutand add it only when the compiler asks. - Expecting shadowing to change the outer variable. A shadow inside a block disappears at the end of the block; the outer value is untouched.
- Leaving variables unused. The compiler warns about them. If a value is unused on purpose, start its name with an underscore, such as
_reserved. - Forgetting the type on a
const.const LIMIT = 10;is an error; writeconst LIMIT: u32 = 10;.
Key points
letcreates a variable; Rust infers its type unless you add an annotation like: f64.- Variables are immutable by default; add
mutwhen a value must change. constvalues are fixed at compile time, need a type and use SCREAMING_SNAKE_CASE names.- Shadowing re-uses a name with a new
let, can change the type and lasts until the end of the block. mutchanges a value in place and can never change its type.
Exercise
Write a program for a cricket over. Start with let mut score = 120; and add the runs from six balls: 1, 4, 0, 6, 2 and 1. Then shadow score with a string that says "Score: <number>" using format!, and print it. Also declare a constant OVERS_PER_INNINGS of type u32 with the value 20 and print it.
Show solution
const OVERS_PER_INNINGS: u32 = 20;
fn main() {
let mut score = 120;
score += 1;
score += 4;
score += 0;
score += 6;
score += 2;
score += 1;
let score = format!("Score: {score}");
println!("{score}");
println!("Overs per innings: {}", OVERS_PER_INNINGS);
}Score: 134 Overs per innings: 20
format! works like println! but returns the text as a String instead of printing it. The new score shadows the old number, so its type changes from an integer to a String.
Quick Quiz
1. What happens if you assign a new value to a variable declared with plain let?
Variables are immutable by default, so assigning twice is a compile error (E0384).
2. Which declaration is correct for a constant?
Constants must have a type annotation and conventionally use SCREAMING_SNAKE_CASE names.
3. What does this print? let x = 5; { let x = x * 3; } println!("{x}");
The inner x shadows the outer one only inside the block; after the block, x is 5 again.
4. Why can shadowing change a variable's type but mut cannot?
Each let makes a new variable with its own type; assigning to a mut variable must keep its type.
Learn Rust with a mentor
Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
