Rust treats your code like a helicopter parent treats their kid at a playground. "Don't run!" "You'll fall!" "Use both hands!" It's overprotective by default, values can't change unless you explicitly mark them as mut. That resistance (big word for a simple three-letter keyword) might feel annoying at first, but it's meant to slow you down just enough to make your intent clear, and to hold you accountable for any abuse the compiler throws at you.
the core principle
In Rust, every variable binding is immutable unless explicitly marked mut. This is the opposite of most languages:
let x = 5; // immutable - cannot be changed
x = 6; // ERROR: cannot assign twice to immutable variable `x`
let mut y = 5; // mutable - can be changed
y = 6; // works
benefits of immutability
1. aggressive optimizations
When the compiler knows with certainty that a value cannot change, it can make assumptions and transformations that would be illegal or unsafe with mutable data. This has real performance implications.
One of which is keeping immutable values in CPU registers instead of the RAM, Rust uses LLVM as the backend engine, the basic Register Allocator implementation can be found at codebrowser
CPU Registers vs RAM
Registers are the fastest storage locations in your CPU:
- Tiny (usually 64 bits on modern systems)
- Extremely fast access (sub-nanosecond)
- Limited quantity (typically 16 general-purpose registers on x86-64)
Memory (RAM) is much slower:
- Vast (gigabytes)
- Slower access (10-100+ nanoseconds)
- Effectively unlimited for practical purposes
This ability to hold values for rapid access in the CPU Register is enabled by the immutability guarantee that Rust enforces.
2. eliminating a large class of race conditions (concurrency)
Multiple threads can safely read the same data simultaneously because Rust's borrow checker ensures no mutable references exist during those reads, eliminating race conditions.
let data = vec![1, 2, 3, 4, 5];
// multiple threads can read simultaneously
// each thread gets an immutable reference (&Vec<i32>)
std::thread::scope(|s| {
s.spawn(|| println!("Thread 1: {:?}", data));
s.spawn(|| println!("Thread 2: {:?}", data));
});
Note: data doesn't need to be immutable (no mut) for this to work, it just needs to not be mutated while being read. Even let mut data = vec![...] would work fine here, because the threads only read.
Trying to write from multiple threads requires synchronization:
let mut data = vec![1, 2, 3, 4, 5];
std::thread::scope(|s| {
s.spawn(|| data.push(6)); // tries to get mutable reference
s.spawn(|| data.push(7)); // another mutable reference
});
// ERROR: cannot borrow `data` as mutable more than once at a time
The key: it's not about the mut keyword on the binding, it's about whether the closures need mutable access. Multiple readers are fine; multiple writers need synchronization.
3. the immutability guarantee
Once you bind a value, you know it won't change unexpectedly:
let user_id = fetch_user_id();
expensive_operation(&user_id);
// user_id is GUARANTEED to still be the same value
process_user(user_id);
deep dive: what "immutable" actually means
immutability is about the binding, not the data
When a variable is immutable, it means the binding itself cannot be changed, you cannot reassign the variable or directly mutate the data through that binding. However, the actual data in memory might still be modifiable through other means (like through a mutable reference, or via interior mutability patterns like Cell or RefCell).
// example A
let x = String::from("hello");
// x is immutable - you cannot reassign x or mutate through x
x = String::from("world"); // ERROR: cannot mutate immutable variable `x`
x.push_str(" world"); // ERROR: cannot borrow 'x' as mutable
// but the data can be moved to a mutable binding
let mut y = x; // ownership transferred (mut)
y.push_str(" world"); // now we can mutate the String
println!("{}", y); // "hello world"
Another thing is that moving the data to an immutable owner keeps the guarantee:
// example B
let x = String::from("hello");
// x is immutable - you cannot reassign x or mutate through x
x = String::from("world"); // ERROR: cannot mutate immutable variable `x`
x.push_str(" world"); // ERROR: cannot borrow 'x' as mutable
let z = x; // ownership transferred (no mut)
z = String::from("get mutated"); // ERROR: cannot mutate immutable variable `z`
z.push_str(" fool"); // ERROR: cannot borrow `z` as mutable
The key insight: Immutability means "I cannot change this through THIS binding," not "this data can never change."
immutability & ownership interaction
Multiple immutable references (&T) can exist at any given time since none of them can modify the data, this is also true in concurrent systems where multiple threads read from the same source simultaneously:
let data = vec![1, 2, 3];
let ref1 = &data; // immutable borrow
let ref2 = &data; // multiple immutable borrows are fine
let ref3 = &data;
println!("{:?} {:?} {:?}", ref1, ref2, ref3);
// all can coexist because none can modify data
But when it comes to mutable references (&mut T), only one mutable reference is allowed at any given time:
let mut data = vec![1, 2, 3];
let ref_mut = &mut data; // mutable borrow
let ref2 = &data; // ERROR: cannot borrow `data` as immutable because it is also borrowed as mutable
let ref3 = &mut data; // ERROR: cannot borrow `data` as mutable more than once at a time
ref_mut.push(4);
// ref_mut goes out of scope here
println!("{:?}", data); // now we can use data again
The key insight: you can either have one mutable reference or any number of immutable references, but never both at the same time. This prevents data races at compile time.
practical patterns to design around immutability
One thing every new Rust developer needs to accept is that the less you fight the borrow checker, the saner your life will be for the rest of your (short-lived) software career (unless, of course, the AI bubble bursts and suddenly everyone needs software engineers again). This section covers idiomatic Rust patterns that embrace immutability instead of working against it.
pattern 1: shadowing for transformations
When you need to transform a value through multiple steps, use shadowing to create new immutable bindings instead of mutation.
// mutation for transformation
let mut x = 5; // mut variable
x = x + 1; // same variable, mutated
x = x * 2; // same variable, mutated again
// shadowing - each step is immutable
let x = 5; // immut variable
let x = x + 1; // new variable created
let x = x * 2; // new variable created again
println!("{}", x); // 12 - final immutable value
Each intermediate value is immutable, making the code easier to reason about. You can even change types between transformations:
let spaces = " ";
let spaces = spaces.len(); // now it's a number
Shadowing is particularly useful when you want to apply a series of transformations without keeping the intermediate states mutable.
pattern 2: transform instead of mutate
Instead of mutating data in place, create transformed copies using iterators and functional methods.
// β imperative style - mutation required
let mut numbers = vec![1, 2, 3, 4, 5];
for i in 0..numbers.len() {
numbers[i] *= 2;
}
// β functional style - no mutation needed
let numbers = vec![1, 2, 3, 4, 5];
let doubled: Vec<i32> = numbers.iter().map(|x| x * 2).collect();
The benefits include: Original data preserved, easily parallelizable, more declarative, no index bounds errors.
pattern 3: rebuild when cheap, mutate when necessary
For small or inexpensive data structures, prefer immutability. For large structures where allocation is costly, use mutation strategically.
// cheap to rebuild - prefer immutability
let name = "John";
let greeting = format!("Hello, {}!", name); // new string
// expensive to rebuild - use mutation
let mut large_vec = Vec::with_capacity(1_000_000);
for i in 0..1_000_000 {
large_vec.push(i); // mutate in place for performance
}
Choose the approach that matches your performance needs.
pattern 4: temporary mutability
Build data structures with mutation in a limited scope, then expose them as immutable to the rest of your code.
// data is immutable
let data = {
let mut temp = Vec::new();
temp.push(1);
temp.push(2);
temp.push(3);
temp // move out as immutable | eqv to: let data = temp;
};
// data is now immutable outside the block
data.push(4); // ERROR: cannot borrow `data` as mutable
process(data); // use immutably
This pattern is everywhere in Rust, construct with mutation, use with immutability. It gives you the performance of in-place construction with the safety of immutable data.
End of log. Thanks for reading, see you in the next entry!
Top comments (0)