Understanding Rust's approach to variable bindings
One of the first things that surprises developers coming from languages like JavaScript or Python is that Rust makes variables immutable by default. This design choice isn't arbitrary—it's a deliberate safeguard that prevents entire categories of bugs before they happen. In this guide, we'll explore Rust variables and mutability: best practices explained, breaking down how immutability works, when to reach for mutable bindings, and how shadowing and constants fit into the picture.
Understanding these fundamentals will change how you think about state management in your applications. Whether you're building a data processing pipeline, a web service, or a systems tool, getting mutability right from the start pays dividends in code clarity and correctness.
Let creates immutable bindings
The let keyword in Rust creates an immutable binding by default. Once you assign a value to a variable with let, that binding cannot be changed. This is the opposite of what most developers encounter in mainstream languages.
Here's what this looks like in practice:
fn main() {
let count = 5;
// count = 6; // This won't compile—error: cannot assign twice to immutable variable
println!("Count is: {}", count);
}The compiler will reject any attempt to reassign count. This might feel restrictive at first, but it's actually a feature. Immutability makes code easier to reason about. When you see a binding declared with let, you know its value won't change unexpectedly somewhere else in your function.
In a real team environment—say, a billing service where you're calculating transaction totals—this guarantees that once you've set a price, no logic later in the function can silently modify it. The compiler forces you to be explicit about any state changes.
Consider this scenario: you're working on an e-commerce platform and calculating order totals.
fn calculate_order_summary(items: Vec, tax_rate: f64) {
let subtotal = items.iter().sum::();
let tax = subtotal * tax_rate;
let total = subtotal + tax;
// If we try to do this:
// total = total + 10.0; // Compile error
println!("Subtotal: {}", subtotal);
println!("Tax: {}", tax);
println!("Total: {}", total);
}Each binding—subtotal, tax, total—is set once and trusted not to change. This immutability is enforced by the type system, not by convention or documentation.
Let mut for mutable values
Of course, there are times when you genuinely need to modify a value. That's what let mut is for. When you declare a variable as mutable, you're explicitly telling the compiler and your teammates: "This value will change."
fn main() {
let mut counter = 0;
counter = 1; // OK
counter = 2; // OK
println!("Counter: {}", counter);
}The mut keyword is intentional and visible. This makes mutable state easy to spot during code review. On a pull request, when you see let mut, you know to pay closer attention to how that variable evolves through the function.
A practical example from ride-hailing systems: tracking a vehicle's location as it moves through a route.
struct Location {
latitude: f64,
longitude: f64,
}
fn simulate_trip(mut location: Location, waypoints: Vec) {
for waypoint in waypoints {
location.latitude = waypoint.latitude;
location.longitude = waypoint.longitude;
println!("Vehicle now at ({}, {})", location.latitude, location.longitude);
}
}Here, location must be mutable because we update it as the vehicle moves. The mut keyword makes this requirement explicit in the function signature.
Mutability is also crucial in loops that accumulate results:
fn process_user_actions(actions: Vec) -> Vec {
let mut processed = Vec::new();
for action in actions {
let normalized = action.to_uppercase();
processed.push(normalized);
}
processed
}Without let mut on processed, you couldn't call push(). The mutability is necessary and visible in the code.
Shadowing creates a new binding
Shadowing is a Rust feature that lets you reuse a variable name by declaring a new binding. This is different from reassignment—you're creating a new variable that happens to have the same name as the previous one.
fn main() {
let x = 5;
println!("x is: {}", x);
let x = x + 1; // Shadowing: new binding, same name
println!("x is: {}", x);
let x = x * 2; // Shadowing again
println!("x is: {}", x);
}Each let x creates a new binding. The previous one is no longer accessible (we say it's "shadowed"). This is valid even though x is immutable each time—we're not reassigning, we're creating fresh bindings.
Shadowing becomes especially powerful when you're transforming data through a pipeline. Consider parsing and transforming API input:
fn parse_user_data(raw_input: String) {
let input = raw_input.trim(); // Shadow with trimmed version
let input = input.to_lowercase(); // Shadow with lowercased version
let input: &str = &input[0..input.len().min(50)]; // Shadow with truncated slice
println!("Processed input: {}", input);
}Each step creates a new binding, making the transformation visible and intentional. Developers reading this code understand that at each stage, we're applying a specific operation.
Shadowing also lets you change a variable's type, which is useful when you're converting between representations:
fn main() {
let num = "42"; // &str
let num: i32 = num.parse().expect("not a number"); // i32
let num = num as f64; // f64
println!("{}", num); // Prints: 42
}This pattern is cleaner than inventing separate variable names like num_str, num_int, num_float. Shadowing keeps the logical identity of the value clear while showing each transformation.
Const for compile-time constants
Constants are different from variables. They're computed and fixed at compile time, and they have global scope. Use const for values that truly never change and that you want to share across your entire program.
const MAX_CONNECTIONS: u32 = 100;
const DEFAULT_TIMEOUT_MS: u64 = 5000;
fn main() {
println!("Max connections: {}", MAX_CONNECTIONS);
}Constants must be explicitly typed (unlike let, which can often infer types). They're inlined at compile time, meaning wherever you reference MAX_CONNECTIONS, the compiler replaces it with 100 directly.
In a SaaS platform, constants are perfect for configuration that doesn't change during runtime:
const API_VERSION: &str = "v2";
const MAX_FILE_SIZE_MB: u64 = 50;
const DEFAULT_PAGE_SIZE: usize = 20;
fn validate_upload(file_size: u64) -> Result<(), String> {
if file_size > MAX_FILE_SIZE_MB * 1024 * 1024 {
Err(format!("File exceeds {} MB limit", MAX_FILE_SIZE_MB))
} else {
Ok(())
}
}Notice that constants use SCREAMING_SNAKE_CASE by convention. This makes them stand out in code.
One important distinction: constants differ from static variables. A static is also immutable by default, but it has a fixed memory address and lives for the entire program duration. For most configuration, const is the right choice. Use static when you genuinely need a global memory location (rare in application code).
Common mistakes
Overusing mut when shadowing would be clearer: Developers fresh to Rust sometimes write let mut value = 0; value = value + 1; value = value + 2; when shadowing would be more idiomatic: let value = 0; let value = value + 1; let value = value + 2;. The second approach makes each transformation explicit without needing to track a mutable binding.
Forgetting that mut applies to the binding, not the data: let mut x = 5; makes the binding mutable, but doesn't change ownership or borrowing rules. You can only hold one mutable reference at a time, regardless of how many mut bindings you create.
Using const for values that change at runtime: If you need configuration that changes between runs, use let or let mut, not const. Constants must be known at compile time.
Shadowing to hide bugs: While shadowing is valid, using it to accidentally mask type mismatches is a trap. For example, shadowing a numeric type with a string because you misread the input format. Always be intentional about type changes.
Key takeaways
- Immutability by default (
let) is the safe path; it prevents accidental mutations and makes code intent clearer. - Use
let mutwhen you genuinely need to modify a value, and the explicit keyword signals that intention to reviewers. - Shadowing with
letis idiomatic for transforming values through a pipeline while keeping each binding immutable. - Reach for
constfor compile-time configuration and global constants, not for runtime configuration. - Mutability decisions made early in design prevent whole classes of concurrency and state-management bugs later.






Comments
Loading comments…