Assignment in Rust
Understanding assignment expressions, move and copy semantics, pattern destructuring, and how assignment differs from let statements in Rust.
Assignment as an Expression
In Rust, x = 5 is an assignment expression. It produces a value — specifically the unit type (), which carries no information — and that value is typically discarded. The important work of an assignment is the side effect: it stores the right-hand value into the memory location named on the left.
Every assignment expression requires the left-hand side to be a place expression. A place expression represents a location in memory that can hold a value: a mutable variable, a field of a mutable struct, a mutable array element, or something behind a mutable reference. The right-hand side is any expression that produces a value of the correct type.
Assignments are allowed only inside mutable places. If a variable was declared with let (not let mut), you cannot write an assignment that changes it. This is one of the first guardrails new Rust programmers encounter.
let mut x = 10;
x = 20; // assignment expression — returns (), but updates x
This example looks trivial, but it exposes a design choice that sets Rust apart from languages like C. In C, x = 20 evaluates to 20 itself, which lets you chain assignments or embed them inside conditions. In Rust, the expression evaluates to (), so chaining a = b = c would try to assign () into b, which is a type error. That deliberate restriction prevents an entire class of bugs where = is typed when == was meant.
Assignment is not initialization:
Assignment only updates an existing binding. Creating a new variable always requires let. Reusing the same name via let x = ... does not mutate the old variable — it creates a new one that shadows the original.
How let Statements and Assignment Expressions Differ
A frequent source of confusion is that let x = 5 and x = 5 look similar and can both end with a semicolon. The difference lies in what each one does to the variable landscape.
letis a statement that introduces a fresh binding. It can appear in any block and creates a new variable, optionally initialising it from the right-hand side. You can use any irrefutable pattern on the left:let (a, b) = ...,let Point { x, y } = ....- Assignment
=is an expression that updates an existing location. It never creates a new variable. If the variable doesn’t already exist, the compiler will report an error.
| Operation | Creates a variable? | Requires existing mutable binding? | Pattern allowed? |
|---|---|---|---|
let x = expr; | Yes | No | Yes |
x = expr; | No | Yes | Yes |
Both forms allow patterns, but the role of the pattern shifts. With let, the pattern defines the structure of the new binding. With assignment, the pattern is destructured into existing places — each part of the pattern must refer to something already declared and mutable.
let mut a = 0;
let mut b = 0;
(a, b) = (1, 2); // assignment destructures into existing mutable variables
// `a` is now 1, `b` is 2
The same left-hand pattern written with let would create two new variables and shadow any previous a and b. Knowing which one you’re using determines whether you’re mutating state or building a new layer of bindings.
Shadowing is not assignment:
When you write let x = x + 1; you are not changing the original x in place. You are creating a new variable that shadows the old one. The old value still exists until it goes out of scope — it is simply unreachable by name. This matters for borrows: a reference to the original x remains valid across a shadowing let, whereas an assignment x = x + 1 would break a shared borrow (unless the type is Copy).
Move Semantics in Assignment
When the right-hand side of an assignment is a value that does not implement the Copy trait, the assignment moves that value. A move transfers ownership: after the move, the original variable can no longer be used. The compiler will reject any subsequent access to the moved-from variable.
let s1 = String::from("hello");
let mut s2 = String::from("world");
s2 = s1; // s1 is moved into s2
println!("{}", s1); // error[E0382]: borrow of moved value: `s1`
This behaviour is not arbitrary. The String holds heap-allocated data, and only one owner should be responsible for freeing it. Assignment transfers that responsibility, so the old owner is invalidated. The same move happens when passing a value to a function or returning it — those are just assignment to a parameter or a temporary behind the scenes.
Beginner mental model: think of assignment to a non-Copy type as handing over an object. Once you hand it over, your hands are empty. You cannot hand the same object to someone else later.
Using a moved value is a compile error:
Attempting to read or write a variable after it has been moved will stop compilation. This is not a runtime bug; the compiler catches it and points you to the exact line. If you need to keep the original usable, either clone it (.clone()) or restructure your code to avoid the move.
Copy Types Make Assignment Trivial
For types that implement the Copy trait — all the integer and floating-point primitives, bool, char, small tuples of Copy types, and immutable references (&T) — assignment performs a bitwise copy. Both the source and destination remain usable, because the compiler knows no ownership logic is needed.
let x: i32 = 42;
let mut y = 0;
y = x;
println!("x = {}, y = {}", x, y); // Both are fine — i32 is Copy
Immutable references are Copy, so assigning one reference to another does not move anything:
let value = 10;
let r1 = &value;
let mut r2 = &value;
r2 = r1; // Copies the reference — r1 is still valid
println!("{}", r1); // OK
Mutable references (&mut T) are not Copy, because only one mutable reference to a location can exist at a time. Assigning a mutable reference moves it, and the source becomes unusable.
let mut n = 5;
let r1 = &mut n;
let mut r2 = &mut n; // error[E0499]: cannot borrow `n` as mutable more than once
// Even without r2, this fails: r1 is the active mutable borrow.
A later reborrow would allow temporarily handing off a mutable reference and getting it back, but reborrowing is an implicit coercion that happens at function call sites and let rebindings, not a special behaviour of the = operator itself. What matters for this section is the assignment rule: &mut T moves, &T copies.
Pattern Destructuring in Assignment
Assignment expressions accept an irrefutable pattern on the left. This lets you destructure tuples, structs, arrays, and slices in a single step — as long as every piece of the pattern corresponds to a mutable place.
let mut point = (3, 4);
(point.0, point.1) = (point.1, point.0); // swap through destructuring
The left-hand pattern (point.0, point.1) is not creating new variables; it uses field access expressions that evaluate to mutable places. After the assignment, point is (4, 3). This swap works without any temporary variables because Rust evaluates the right-hand side fully before writing to the left.
Struct destructuring works the same way:
struct Point { x: i32, y: i32 }
let mut p = Point { x: 1, y: 2 };
Point { x, y } = p; // error — p is not mutable
// Correct version:
let mut p = Point { x: 1, y: 2 };
Point { x: ref mut a, y: ref mut b } = p; // This would be unusual — instead, assign to fields directly
More commonly, you destructure into existing mutable bindings you already control:
let mut x = 0;
let mut y = 0;
(x, y) = (10, 20);
If the pattern fails to match at runtime, the assignment would panic — but irrefutable patterns are guaranteed to always match, so the compiler only permits patterns that are structurally certain to succeed.
Destructuring assignment is exhaustive:
Because the pattern must be irrefutable, the compiler guarantees that the assignment won't fail at runtime. When you destructure a tuple of known size, every position is covered — there are no partial updates and no silent dropped values.
Compound Assignment Operators
Rust provides +=, -=, *=, /=, %=, &=, |=, ^=, <<=, and >>=. Each of these is an expression that returns (), like a plain assignment. They desugar into a method call to the corresponding trait in the std::ops module — for example, x += 1 becomes x.add_assign(1).
let mut count = 0;
count += 1; // AddAssign
count *= 2; // MulAssign
The left-hand side must still be a mutable place, and the type of the right-hand side must be appropriate for the trait implementation. These operators work with primitive numbers and any custom type that implements the relevant trait.
A subtle point: the compound assignment moves the left-hand value into the trait method, which then mutates the place and returns the value back. This means the left-hand place is borrowed while the method runs, so you cannot have another borrow active at the same time.
let mut v = vec![1, 2, 3];
v[0] += 1; // OK — v is mutably borrowed, then the element is updated
If you attempt to use v immutably while a += is in progress, you'll get a borrow checker error.
Why Assignment Doesn’t Return a Value
In C and its descendants, assignment returns the assigned value, which enables patterns like while ((c = getchar()) != EOF). Rust deliberately chose to return (). The motivation is safety: mixing assignment and comparison in the same expression is a notorious source of bugs, especially when = is accidentally written instead of ==.
let mut x = 5;
// This will not compile: expected `bool`, found `()`
if x = 10 {
println!("x is 10");
}
The compiler catches the mistake immediately because () is not a boolean. To perform an assignment and then test the value, you must write two separate statements:
let mut n = 0;
loop {
func(n);
n = n.next;
if n == n_init { break; }
}
This is idiomatic Rust. It avoids the visual density of C-style in-expression assignment and makes control flow obvious. If you need an iterator-like pattern, encapsulate the stepping logic in an Iterator implementation and use a for loop, which is both clearer and more composable.
No assignment in conditions:
Rust has no syntax for assignment inside an if or while condition. If you are porting C code that relies on this, refactor into a loop with an explicit condition check at the break point, or use an Option-returning function with while let.
Common Mistakes When Assigning in Rust
Several patterns trip up newcomers. Recognizing them early saves debugging time.
- Assigning to an immutable variable. The compiler will tell you the variable isn’t mutable. The fix is to add
mutwhen you declare it. If you intended to create a new variable instead, uselet. - Using a moved value after assignment. After
let y = x;wherexis aString,xis invalid. A later use ofxwill fail to compile. Either usex.clone()or rearrange ownership so the move doesn’t happen. - Expecting shadowing via assignment. Writing
x = x + 1inside a loop mutates the same variable. If that variable is captured by a closure or referenced, the borrow checker may object. Sometimeslet x = x + 1(shadowing) inside a loop is more appropriate because it creates a new binding each iteration, but note that the old value still exists until the end of the block. - Trying to swap mutable references with simple assignment. Because
&mut Tis notCopy,let r2 = r1;movesr1intor2and invalidatesr1. To get a temporary reborrow, uselet r2 = &mut *r1;— this reborrows the data behindr1and returns a new mutable reference with a shorter lifetime, keepingr1usable afterr2expires. - Assuming compound assignment works with immutable references. The left-hand side of
+=must be a mutable place. A reference obtained via&is immutable, so*ref += 1will only compile ifrefis a&mutreference.
Mutable reference moves bite:
let r2 = r1; where r1: &mut T moves the reference. After that line, r1 is unusable. If you need to keep r1 alive, reborrow explicitly or restructure to avoid moving the mutable reference. This is one of the most common sources of "cannot borrow x as mutable more than once" errors.
Real-World Patterns That Rely on Assignment
Assignment shows up in every Rust program, but some patterns are worth highlighting because they illustrate how assignment interacts with ownership and ergonomics.
Swapping values without a temporary variable uses destructuring assignment:
let mut a = 1;
let mut b = 2;
(a, b) = (b, a);
// a is 2, b is 1
Updating struct fields is straightforward with mutable access:
let mut config = Config { timeout: 30, retries: 3 };
config.timeout = 60;
Incrementing counters inside iterators:
let mut count = 0;
for item in collection {
count += 1;
// use item
}
Reassigning a variable in a loop to process a linked or cyclic structure:
let mut current = start;
while let Some(next) = current.next {
process(current);
current = next;
}
Each of these relies on the fact that assignment is an expression that returns (), so it can live as a statement terminated by a semicolon without polluting the expression with an unwanted value.
Assignment fits naturally into control flow:
Because assignment always produces (), you never accidentally treat an updated value as a condition. This design keeps loop logic explicit and readable, even in complex state-mutation scenarios.
Summary
Assignment in Rust is deceptively simple syntax — a single = — that unlocks a surprisingly deep set of ownership, borrowing, and pattern-matching rules. The most important insight is not how assignment itself works, but how it adapts to the type on its right-hand side: it moves if the type owns resources, copies if the type has promised it’s safe to duplicate, and refuses to compile if the target isn’t mutable.
Where a C programmer might embed assignment inside a loop condition for brevity, Rust requires you to separate mutation from branching. The payoff is that every assignment line states exactly one side effect, and the borrow checker can verify that no dangling references or double‑frees slip through.