Skip to content
ExponaBLOG
RUST PROGRAMMING · DAY 003 / 100

Rust variables and mutability: best practices explained

Immutable by default.

Expona Admin7 min read

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:

rust
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.

rust
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."

rust
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.

rust
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:

rust
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.

rust
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:

rust
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:

rust
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.

rust
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:

rust
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 mut when you genuinely need to modify a value, and the explicit keyword signals that intention to reviewers.
  • Shadowing with let is idiomatic for transforming values through a pipeline while keeping each binding immutable.
  • Reach for const for 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles