One Assignment, One Move: Watching a Binding Die
You met the rule in The Three Ownership Rules: assigning a non-Copy value transfers ownership. Now look at what the compiler does when you break it, because the diagnostics are the real teacher:
fn main() {
let name = String::from("Ferris");
let alias = name; // MOVE: ownership transfers to alias
println!("{}", name); // error[E0382]: borrow of moved value: `name`
}
The full error is worth reading once carefully:
error[E0382]: borrow of moved value: `name`
--> src/main.rs:4:20
|
2 | let alias = name;
| ----- move occurs because `name` has type `String`, which does not implement the `Copy` trait
3 | println!("{}", name);
| ^^^^ value borrowed here after move
Both spans are shown — the move and the invalid use — because the fix lives between them. The compiler is not guessing; it tracked the value's owner changing hands on line 2.
Which types move? Everything that owns a heap allocation or an external resource: String, Vec<T>, HashMap, Box<T>, File, and any struct containing them. i32, bool, char, f64, and tuples/arrays/structs made entirely of such fields are Copy and duplicate instead. Note the structural rule: one non-Copy field makes the whole struct non-Copy — a struct Pair { id: u64, label: String } moves, because its String does.
After the move, the old binding is not "empty" or "invalid memory" — it is out of the program entirely; the compiler refuses to let you touch it, the way it refuses an out-of-scope variable. It also closes the moved-from scope: alias's scope end is now responsible for the drop, and name's scope end will drop nothing.
Moving also happens inside collections:
let v = vec![String::from("a"), String::from("b")];
let first = v[0]; // error[E0507]: cannot move out of index of `Vec<String>`
Indexing cannot move an element out, because the Vec must stay valid and would have a hole — you borrow it (&v[0]) or remove it (v.into_iter(), v.remove(0)) instead. Moving out of struct fields is allowed (they have names the compiler can track); moving out of indexed collection elements is not. That asymmetry resurfaces in error messages for the rest of your Rust life, so recognizing E0507 as "collection integrity" saves debugging time.
A moved binding is not permanently poisoned — it is simply dead, and you can bury it by shadowing with a fresh value:
let name = String::from("Ferris");
let name = String::from("Crab"); // legal: a NEW `name`, the dead one never drops
println!("{}", name); // prints Crab
No destructor runs for the moved-out value (the new binding owns something else entirely), which is exactly what rule 3 predicts: the value got dropped by whoever now owns it, and the old slot was left alone.
Two library functions exist to move in situations where the syntax will not let you. std::mem::replace(&mut slot, replacement) swaps ownership out of a place that must stay valid, and the idiomatic case is an Option field you want to consume:
struct Pipeline { buffer: Option<Vec<u8>> }
impl Pipeline {
fn flush(&mut self) {
if let Some(mut data) = self.buffer.take() { // move the Vec OUT, leave None behind
write(&data);
self.buffer = Some(data); // return it, still allocated
}
}
}
.take() is replace(&mut self.buffer, None) in disguise — the standard way to move out of a field you still own. std::mem::swap(&mut a, &mut b) does the same job for two live bindings, exchanging ownership without either value being copied or dropped.
Patterns move too, which is why destructuring binds by value by default:
let order = Order { id: 7, note: String::from("gift") };
let Order { id, note } = order; // id: i64 copied, note: String MOVED
println!("{}", note); // fine
// println!("{:?}", order); // E0382 — the struct was consumed by the pattern
Bind &order in the pattern (or write ref note) when you want to take the fields apart without taking them.
Function Calls Move Arguments — and Return Them Back
A function parameter is a new binding, so passing a non-Copy value moves it into the callee:
fn display(greeting: String) { // greeting now OWNS the argument
println!("{}", greeting);
} // greeting drops here — caller's binding is dead
fn main() {
let msg = String::from("hi");
display(msg); // move into the parameter
println!("{}", msg); // error[E0382]: borrow of moved value: `msg`
}
This signature says "this function consumes the string" — semantically honest for a function that owns the data (a write-all function, a builder taking parts). But most functions only read the value, and the standard signature takes a borrow instead:
fn length(s: &String) -> usize { s.len() } // borrow: caller keeps ownership
fn length(s: &str) -> usize { s.len() } // even better — accepts &String and &str
fn main() {
let msg = String::from("hi");
println!("{}", length(&msg)); // &msg: temporary shared borrow, no move
println!("{}", msg); // still yours
}
The &str variant matters in practice: String derefs to str, so &String, &str, and string literals all coerce to &str — the signature that accepts all three is what real APIs use.
The third pattern is returning ownership without copying:
fn build(prefix: &str) -> String {
let mut s = String::from(prefix);
s.push_str("!");
s // ownership moves OUT to the caller
}
fn main() {
let greeting = build("hello"); // callee's local died; greeting owns the result
}
The s at the end is not a copy — it is a move out of the function into the caller's binding. This is why idiomatic Rust can be move-heavy without performance guilt: no data travels on the stack during any of these transitions; only the ownership metadata (the pointer/length/capacity words) changes hands.
One iteration trap closes the topic: for moves the collection unless you iterate by reference:
let names = vec![String::from("ada"), String::from("lin")];
for n in names { } // MOVES the Vec — names unusable after the loop
for n in &names { } // borrows: iterate &Vec<String> — names still yours
for n in names.into_iter() { } // explicit move-iteration: consumes the Vec
for n in names desugars into an IntoIterator::into_iter(names) call that takes the collection by value — for Vec, that is a move. Three spellings, three ownership outcomes; interviewers sometimes ask for exactly this table.
Three signatures cover nearly every real API, and the choice is a design statement:
fn label(s: &str) -> String // read text, produce new owned data
fn push_all(dst: &mut Vec<String>, src: &[String]) // mutate through a borrow
fn consume(v: Vec<String>) -> Summary // take ownership, return an owned result
&[String] and &str are the shapes that compose: callers can pass slices of their own vectors, arrays, or Vecs without cloning, and the callee cannot accidentally retain or destroy the data. When a function needs the value after the call — a builder accumulating parts, a channel sender — by-value is honest, and the compiler's E0382 at the call site is telling you the signature promised exactly that.
Closures and threads are where moves become visible in the standard library:
let config = load_config();
let run = move || apply(&config); // config MOVED into the closure
std::thread::spawn(run); // thread may outlive this scope → 'static required
The move keyword forces capture-by-value, which is what thread::spawn demands: the thread cannot borrow from a stack frame that may return first. The captured data is moved once, at closure creation, and dropped when the last handle to the closure dies. The same rule explains why an async task holding an owned String can be sent across threads while one holding a &str cannot — owning data is what makes a value portable.
If you genuinely need the value both in a spawned job and afterwards, the honest options are, in order: clone it (measure before apologizing), share it behind Arc when reads suffice, or restructure so only one side needs it. What you cannot do is borrow across the thread boundary, and the compiler enforces it twice over: error[E0373]: closure may outlive the current function, but it borrows ... when a closure captures a local by reference, and "str does not live long enough" when a borrowed value tries to escape its scope.
Every Way a Value Moves — and How to Read the Error When It Shouldn't
Moves come from a small, enumerable set of contexts. Learn the list and half of your early borrow-checker fights disappear before they start:
| Move site | Example | Notes |
|---|---|---|
let binding |
let a = b; |
the canonical move |
| Reassignment | a = b; |
old value of a dropped, ownership from b transferred |
| Function argument | f(b); |
moves into the parameter unless it takes &T |
| Return value | return b; |
moves ownership out to the caller |
for iteration |
for x in v |
moves the collection (use &v to borrow) |
| Macro argument | vec![b], format!("{}", b) |
mostly borrows or copies; vec! with a pre-built value moves |
| Closure capture | `move | |
| Struct literal | S { f: b } |
moves b into the field |
Against that, the error messages themselves. The canonical one:
error[E0382]: use of moved value: `items`
--> src/main.rs:7:14
|
3 | let first = items;
| ----- move occurs because `items` has type `Vec<String>`
7 | println!("{:?}", items);
| ^^^^^ value used here after move
Read it as a recipe: the first span shows where ownership left; the second shows where you needed it back. There is a third family, E0385 (reassignment of a moved variable), for items = new_items; after a move — assignment to a binding whose value was already moved is also rejected.
Now the fixes, in the order a reviewer would suggest them. First, borrow instead of move — if the function does not need to own, take &T (or &mut T for mutation); this costs nothing and is the default answer. Second, restructure so the moved value is no longer needed — move later, compute from other data first, or return the value instead of keeping a copy. Third, clone — explicit, visible, and costs a heap allocation, so reserve it for genuinely needed duplication:
let label = config.label.clone(); // explicit deep copy; the .clone() is a flag
use(config); // config still usable
Why moves are cheap and clones are not — worth having ready in an interview: moving a String or Vec copies 24 bytes of stack metadata (pointer, length, capacity) regardless of whether the heap holds 1 KB or 10 GB, and the old binding is invalidated so no destructor runs on the moved-from copy. Cloning allocates and copies the entire heap buffer. "Move freely, clone deliberately" is not performance superstition — it is the arithmetic of what each operation touches.
The macro row of the table deserves precision, because the three most common macros behave differently:
let s = format!("{}-{}", &a, &b); // BORROWS a and b — both usable after
let v = vec![a, b]; // MOVES a and b into the vector
let b = Box::new(c); // MOVES c into the heap box
let r = format!("{}", c); // c still yours
format! builds its output from borrowed arguments (it passes &a into format_args!), while vec! takes elements by value — so "a macro might move" is really "look at what the macro expands to." Expanding with cargo expand (or reading the definition) settles the question in seconds.
Arrays add an edition trap that catches people mid-migration:
let arr = [String::from("a"), String::from("b")];
for s in arr { } // edition 2021: MOVES both elements
// edition 2015/2018: yielded &String instead
Before 2021, IntoIterator for [T; N] by value did not exist and arr.into_iter() silently yielded references; since 2021 it yields owned values, matching Vec. If you see code that survives on the old behavior after an edition bump, the compiler's array_into_iter lint points at each call site and tells you which spelling to write — &arr for borrowing, arr for moving.
The habit this topic should leave you with: every by-value appearance in a signature is a deliberate claim of ownership, and when you write s: String you are saying the caller will not need it again.
Quiz
1. You pass a String to `fn log(s: String)` and then use the String in the caller. What are your options?
2. What does `for n in names` do to a `Vec<String>` named names, and how do you iterate without taking ownership?
3. Why is moving a multi-gigabyte Vec<String> cheap while v.clone() is expensive?
4. The compiler reports E0382 on your code. What are the two spans in the error showing, and what is the first fix to try?
Flashcards
Question
List the contexts that move a non-Copy value.
Click to reveal answer
Answer
let binding (let a = b), reassignment (a = b), function argument (unless the parameter is &T/&mut T), return value, for-loop iteration (into_iter — use &v to borrow), struct/tuple construction, move-closures (move || ...), and most macro inputs that take values by move.
Question
What does E0382 tell you, and how do you read it?
Click to reveal answer
Answer
'borrow of moved value' — it prints TWO spans: where the move occurred (with the value's type and why it is not Copy) and where the stale binding was used. The fix is found between the spans: borrow instead of move, restructure, or clone. Related: E0385 = reassignment of a moved variable.
Question
Take-ownership vs borrow vs return-ownership signatures — when do you use each?
Click to reveal answer
Answer
Take ownership (s: String) when the function consumes/retains the value (build, write-all). Borrow (&str, &T) when it only reads — the most common, and &str accepts String, &str, and literals via deref coercion. Return ownership (-> String) when a function computes a new owned value — the caller receives the move with zero copying.
Question
Why can't you move an element out of a Vec by indexing, but you can out of a struct field?
Click to reveal answer
Answer
Indexing moves out of the middle of contiguous owned storage, leaving a hole that would invalidate the collection's length invariant (E0507 'cannot move out of index'). A struct's fields are named, separately-tracked slots, so the compiler can mark one field moved-out and still allow access to the others (partial move).
Question
Move vs clone cost: what actually gets copied?
Click to reveal answer
Answer
Move: the stack metadata only — for String/Vec that's pointer+length+capacity (24 bytes on 64-bit) — and the old binding is invalidated, so exactly one drop remains. clone(): a new heap allocation plus a full element-wise copy of the data. Moves are O(1) in heap size; clones are O(n).
Revision Notes
Key Takeaways
- 1. Function parameters move by value; switching the parameter to &T (or &str) turns the call into a borrow and is the first fix for E0382
- 2. for x in v moves the collection via into_iter — iterate &v to keep it, v.into_iter() to deliberately consume it
- 3. Returning a value from a function is a move, not a copy — this is how idiomatic Rust produces owned results with zero heap traffic
- 4. E0382's two spans (move site, use site) are a complete debugging recipe: the fix goes between them
- 5. Moves are O(1) stack-metadata transfers; clones allocate and copy the heap — that arithmetic, not dogma, is why the culture is 'clone only when explicit and needed'
Interview Tips
- • "What happens when you pass a String to a function?" — ownership moves into the parameter and the value drops at the callee's scope end unless returned; the fix for read-only use is &str, and you should mention deref coercion makes &str the universal signature
- • "for x in v vs for x in &v?" — into_iter moves (Vec unusable after), & borrows yielding references; add .iter().copied() for Copy element types to get values without ownership
- • "Why is Rust's move model not a performance problem?" — moves copy only pointer/length/capacity (24 bytes) regardless of heap size; no data moves, no GC runs, and clone is the only way to pay for duplication — visibly
- • "How do you return a large collection from a constructor?" — just return it: the return is a move, not a copy; no clone, no output buffer — ownership transfers to the caller for free
Cheat Sheet
Move Semantics in Practice
Moves happen at: let · reassignment · fn args (by-value params) · return · for-loops (into_iter) · struct/tuple construction · move-closures · by-value macro inputs.
Don't move when you only read: take &T (prefer &str for text — accepts &String, &str, literals).
E0382 recipe:
error[E0382]: use of moved value: `x`
--> move occurred here (type shown: non-Copy reason)
--> value used here after move
Fix ladder: 1) borrow (&T / &mut T) → 2) restructure (move later / return the value) → 3) clone (explicit cost).
for-loop ownership:
| Form | Effect |
|---|---|
for n in v |
MOVES v (E0382 after loop) |
for n in &v |
borrows; n: &T |
for n in v.into_iter() |
explicit consume; moves |
Cost: move = 24 bytes of stack metadata regardless of heap size (O(1)); clone = new allocation + full copy (O(n)). Move freely; clone deliberately.
Related errors: E0507 cannot move out of index · E0385 reassignment of moved variable · E0382 use of moved value.