Learn Rust Series (#31) - Move Semantics Deep Dive

What will I learn
- You will learn exactly what a move is at the machine level, and why it is cheap no matter how big the data;
- precisely when a value moves and when it is copied in stead, reduced to one clear question;
- how moves flow through function arguments, return values, and plain assignments;
- what a partial move is, and why you cannot move a value out of a
Vecby index (and what to do instead); - how moves happen inside
matchand destructuring, so that "value moved here" stops surprising you.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Rust toolchain (via rustup, from rustup.rs);
- The previous thirty episodes, especially ownership and borrowing (episode 3) and
Copy(episode 25); - The ambition to learn systems programming from the ground up.
Difficulty
- Beginner
Curriculum (of the Learn Rust Series):
- Learn Rust Series (#1) - Introduction to Rust
- Learn Rust Series (#2) - Variables, Types, Functions
- Learn Rust Series (#3) - Ownership & Borrowing
- Learn Rust Series (#4) - Control Flow & Pattern Matching
- Learn Rust Series (#5) - Structs & Enums
- Learn Rust Series (#6) - Error Handling
- Learn Rust Series (#7) - Collections
- Learn Rust Series (#8) - Traits & Generics
- Learn Rust Series (#9) - Modules & Crates
- Learn Rust Series (#10) - Lifetimes
- Learn Rust Series (#11) - Closures & the Iterator Trait
- Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell
- Learn Rust Series (#13) - Concurrency: Threads, Channels, Arc & Mutex
- Learn Rust Series (#14) - Mini Project: A Command-Line To-Do App
- Learn Rust Series (#15) - Trait Objects & Dynamic Dispatch
- Learn Rust Series (#16) - Static vs Dynamic Dispatch
- Learn Rust Series (#17) - Associated Types vs Generic Parameters
- Learn Rust Series (#18) - Operator Overloading with std::ops
- Learn Rust Series (#19) - Deref, DerefMut & Deref Coercion
- Learn Rust Series (#20) - Drop & Deterministic Destruction (RAII)
- Learn Rust Series (#21) - From, Into, TryFrom & Idiomatic Conversions
- Learn Rust Series (#22) - Deriving Common Traits
- Learn Rust Series (#23) - The Orphan Rule & Trait Coherence
- Learn Rust Series (#24) - Blanket Implementations & the Newtype Pattern
- Learn Rust Series (#25) - Marker Traits: Sized, Send, Sync & Copy
- Learn Rust Series (#26) - Const Generics: Types That Depend on Values
- Learn Rust Series (#27) - Generic Associated Types & Lending Iterators
- Learn Rust Series (#28) - Sealed Traits & Designing Stable APIs
- Learn Rust Series (#29) - Typestate Programming: State Machines in the Type System
- Learn Rust Series (#30) - Mini Project: A Generic Units-of-Measure Library
- Learn Rust Series (#31) - Move Semantics Deep Dive (this post)
Learn Rust Series (#31) - Move Semantics Deep Dive
Welcome to Phase 3, which is all about truly mastering ownership and the smart pointers built on top of it. We start where it all begins: moves. You met them back in episode 3 and have lived with them ever since, but this episode makes them precise. Almost every confusing "value used after move" error comes from not having a crisp mental model of when a value moves, so let us build one that never lets you down. The good news is that the rule is simple and mechanical once you see it -- and once you see it, a whole category of errors turns from "the compiler is fighting me" into "ah yes, of course" ;-)
Having said that, this is a foundational episode on purpose. The next several posts lean on smart pointers -- types that let you share a value without moving it, or mutate it through a shared reference -- and none of that makes sense until moves are second nature. So we are going to slow down and look at the mechanics carefully, at the level of what the machine actually does with the bytes. Let me clear last episode's homework first, as always, and then we start moving numbers around.
Solutions to Episode 30 Exercises
Episode 30 was the units-of-measure capstone: we tagged a plain f64 with a phantom dimension type so that adding metres to seconds became a compile error. There were four small tasks, and here is full runnable code for each.
Exercise 1 asked you to add an Acceleration marker and a Div<Quantity<Time>> for Quantity<Velocity> impl, then compute an acceleration by dividing a velocity by a time:
use std::marker::PhantomData;
use std::ops::Div;
#[derive(Clone, Copy)] struct Time;
#[derive(Clone, Copy)] struct Velocity;
#[derive(Clone, Copy)] struct Acceleration;
#[derive(Debug, Clone, Copy)]
struct Quantity<Dim> { value: f64, _dim: PhantomData<Dim> }
impl<Dim> Quantity<Dim> {
fn new(v: f64) -> Quantity<Dim> { Quantity { value: v, _dim: PhantomData } }
}
impl Div<Quantity<Time>> for Quantity<Velocity> {
type Output = Quantity<Acceleration>;
fn div(self, t: Quantity<Time>) -> Quantity<Acceleration> {
Quantity::new(self.value / t.value)
}
}
fn main() {
let v: Quantity<Velocity> = Quantity::new(20.0);
let t: Quantity<Time> = Quantity::new(4.0);
let a = v / t; // Velocity / Time = Acceleration
println!("{} m/s^2", a.value); // 5 m/s^2
}
Exactly the same shape as the Length / Time = Velocity impl from the episode, one dimension further up the chain. The type checker works out that a is a Quantity<Acceleration> entirely from the input types.
Exercise 2 wanted the inverse: implement Mul<Quantity<Time>> for Quantity<Velocity> so that velocity times time gives a Quantity<Length> back:
use std::marker::PhantomData;
use std::ops::Mul;
#[derive(Clone, Copy)] struct Time;
#[derive(Clone, Copy)] struct Velocity;
#[derive(Clone, Copy)] struct Length;
#[derive(Debug, Clone, Copy)]
struct Quantity<Dim> { value: f64, _dim: PhantomData<Dim> }
impl<Dim> Quantity<Dim> {
fn new(v: f64) -> Quantity<Dim> { Quantity { value: v, _dim: PhantomData } }
}
impl Mul<Quantity<Time>> for Quantity<Velocity> {
type Output = Quantity<Length>;
fn mul(self, t: Quantity<Time>) -> Quantity<Length> {
Quantity::new(self.value * t.value)
}
}
fn main() {
let v: Quantity<Velocity> = Quantity::new(10.0);
let t: Quantity<Time> = Quantity::new(3.0);
let d = v * t; // Velocity * Time = Length
println!("{} m", d.value); // 30 m
}
Note this is a Mul between two Quantity values, different from the Mul<f64> scaling impl we wrote in the episode where the right-hand side was a bare number. The std::ops traits happily let the two operand types differ, which is what lets Velocity * Time produce a third dimension.
Exercise 3 asked for a Display impl on Quantity<Length> printing a m suffix, and optionally one for Time printing s:
use std::marker::PhantomData;
use std::fmt;
struct Length;
struct Time;
struct Quantity<Dim> { value: f64, _dim: PhantomData<Dim> }
impl<Dim> Quantity<Dim> {
fn new(v: f64) -> Quantity<Dim> { Quantity { value: v, _dim: PhantomData } }
}
impl fmt::Display for Quantity<Length> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{} m", self.value)
}
}
impl fmt::Display for Quantity<Time> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{} s", self.value)
}
}
fn main() {
let d: Quantity<Length> = Quantity::new(42.0);
let t: Quantity<Time> = Quantity::new(9.58);
println!("{d}"); // 42 m
println!("{t}"); // 9.58 s
}
One Display per dimension, because each unit really does print differently. That is honest repetition, and it is allowed only because Quantity<Length> is our own concrete type (the orphan rule from episode 23 is satisfied).
Exercise 4 asked you to add a Mass dimension and confirm that mixing it with Length in + still fails to compile:
use std::marker::PhantomData;
use std::ops::Add;
#[derive(Clone, Copy)] struct Length;
#[derive(Clone, Copy)] struct Mass;
#[derive(Debug, Clone, Copy)]
struct Quantity<Dim> { value: f64, _dim: PhantomData<Dim> }
impl<Dim> Quantity<Dim> {
fn new(v: f64) -> Quantity<Dim> { Quantity { value: v, _dim: PhantomData } }
}
impl<Dim> Add for Quantity<Dim> {
type Output = Quantity<Dim>;
fn add(self, o: Quantity<Dim>) -> Quantity<Dim> { Quantity::new(self.value + o.value) }
}
fn main() {
let l: Quantity<Length> = Quantity::new(3.0);
let m: Quantity<Mass> = Quantity::new(5.0);
let sum = l + Quantity::<Length>::new(2.0); // fine: Length + Length
println!("{}", sum.value); // 5
// let bad = l + m; // would NOT compile: no Add impl mixing Length and Mass
let _ = m; // m is unused otherwise
}
The generic impl<Dim> Add for Quantity<Dim> only ever pairs a dimension with itself. There is no impl in the universe that adds Length to Mass, so the line stays rejected forever. Right, homework cleared -- now let us look at what a move really is ;-)
What a move actually is
When you assign a non-Copy value, Rust performs a move: it copies the value's bytes from the old location to the new one, and then treats the original as invalid. The word "move" is a little misleading if you picture something being physically dragged across memory -- what actually happens is a plain byte copy of the value on the stack, plus a rule that says the source may no longer be used.
For a String, the "value" on the stack is a three-word handle: a pointer to the heap buffer, a length, and a capacity. The actual text lives on the heap. A move copies those three little words to the new binding, and Rust invalidates the source, so that two handles never point at the same buffer at once:
fn main() {
let s1 = String::from("hello");
let s2 = s1; // the three-word handle is copied; s1 is now invalid
println!("{s2}");
// println!("{s1}"); // would NOT compile: borrow of moved value `s1`
}
The crucial insight is that the heap buffer is not touched. Only the small handle is copied. That is why a move is cheap no matter how large the string's contents -- moving a one-megabyte String copies the exact same three words as moving a five-character one. This also explains why the source has to be invalidated: if both s1 and s2 stayed valid, they would both hold a pointer to the same buffer, and when both went out of scope they would both try to free it. That is the classic double-free bug, and it is precisely what the move rule makes impossible. We saw the destruction side of this in episode 20 (Drop and RAII); moves are the other half of the same coin -- ownership is what decides who runs the Drop.
A deep copy, one that actually duplicates the heap buffer, only happens when you explicitly ask for it with .clone():
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone(); // deep copy: a brand-new heap buffer with its own "hello"
println!("{s1} and {s2}"); // both valid, because clone duplicated the data
}
So clone is the opt-in "yes, actually duplicate the expensive part" button. Moves are the cheap default; clones are the visible, deliberate exception. This is a real philosophical difference from many languages, where copying a big object silently is the default and you have to work to avoid it. Rust flips that: the cheap thing is silent, and the potentially expensive thing (.clone()) is always spelled out in the source where a reviewer can see it.
Copy types do not move
Types that are Copy, like the integers, floats, bool, char, and small aggregates made only of those, behave differently. Assigning them duplicates the bits and leaves the original perfectly valid:
fn main() {
let x = 5;
let y = x; // i32 is Copy: x is duplicated, still usable
println!("{x} and {y}"); // 5 and 5
}
We met the Copy marker trait in detail in episode 25, so here is the one-line summary that matters for moves: a type is Copy when duplicating its bits gives a second, fully independent, valid value -- which is only safe when the value owns no heap resource. An i32 is just four bytes with no buffer behind them, so copying those bytes is harmless. A String owns a heap buffer, so bit-copying its handle would create two owners of one buffer, which is exactly what we are not allowed to have. That is why String, Vec<T>, Box<T> and friends are deliberately not Copy.
This reduces the whole question to something you can decide at a glance. "Does this move or copy?" becomes "is the type Copy?". If it is, assignment copies and both bindings stay valid. If it is not, assignment moves and the source is invalidated. That single question answers the large majority of ownership puzzles you will ever hit.
Moves through functions
Passing a non-Copy value to a function moves it in. From that point the caller no longer owns it, and the value is dropped at the end of the function unless it gets moved out again. Returning a value moves it out to the caller:
fn consume(s: String) {
println!("consumed: {s}");
} // s goes out of scope here and is dropped -- the buffer is freed
fn produce() -> String {
let s = String::from("made inside");
s // moved out to the caller instead of being dropped
}
fn main() {
let owned = String::from("data");
consume(owned); // owned is moved into the call
// println!("{owned}"); // would NOT compile: value moved into consume
let got = produce(); // the returned String is moved into `got`
println!("{got}");
}
This is the mechanical reason behind an idiom you have already been following without necessarily naming it: functions that only need to look at a value take a reference (&str or &T), while functions that genuinely need to own or consume it take the value by move. Taking ownership forces the caller to give the value up, which is sometimes exactly what you want (the function is going to store it, or transform it into something else) and sometimes a rude surprise (the caller wanted to keep using it). The signature is the contract: take by reference to borrow, take by value to consume.
There is a common give-and-take pattern where a function takes ownership, does something, and hands the value back:
fn tag(mut s: String) -> String {
s.push_str(" [seen]");
s // moved back out to the caller
}
fn main() {
let s = String::from("packet");
let s = tag(s); // moved in, then the return value moved back into a new `s`
println!("{s}"); // packet [seen]
}
That let s = tag(s); shadows the old binding (episode 2) with the one that comes back. In practice you would usually take &mut String here in stead of moving in and out, but seeing the move-through-and-return version makes the mechanics obvious: ownership went in, ownership came back, and the compiler tracked it the whole way.
Assignment to an existing binding drops the old value
One subtlety that trips people up: moving into a binding that already holds a value drops the old value first. The old owner is being overwritten, so its resource has to be released right then:
fn main() {
let mut s = String::from("first");
s = String::from("second"); // "first" is dropped here, its buffer freed immediately
println!("{s}"); // second
}
The buffer holding "first" is freed at the exact point of reassignment, not at the end of main. This is Drop and moves working together: a move into an occupied slot means the previous occupant must go. It is completely deterministic, and it is one of the quiet reasons Rust does not need a garbage collector -- the compiler already knows, at every line, who owns what and when the last owner disappears.
Partial moves
You can move a single field out of a struct. This moves just that field and leaves the rest in place, though it makes the struct as a whole unusable while any field is moved out. Copy fields are unaffected, because reading them copies in stead of moving:
struct Person { name: String, age: u32 }
fn main() {
let p = Person { name: String::from("Ada"), age: 36 };
let name = p.name; // moves the String field out of p
println!("name: {name}");
println!("age: {}", p.age); // age is Copy (u32), so this reads fine
// println!("{}", p.name); // would NOT compile: p.name was moved out
// let whole = p; // would NOT compile: p is partially moved
}
The age field stays readable because u32 is Copy: accessing it copied the four bytes rather than moving anything. But p.name is gone, and p as a whole is now a partially-moved value that you cannot pass around or move again. The compiler tracks moved-ness per field, which is more precise than you might expect, and this precision is genuinely useful when you are pulling a struct apart into its pieces.
You cannot move out of an index
Here is one that surprises almost everyone the first time, and it follows directly from the rules above. You cannot move a value out of a Vec (or an array, or a slice) by indexing:
use std::mem;
fn main() {
let mut v = vec![String::from("a"), String::from("b"), String::from("c")];
// let taken = v[1]; // would NOT compile: cannot move out of index of a Vec
let taken = mem::replace(&mut v[1], String::from("-"));
println!("took {taken}, now {v:?}"); // took b, now ["a", "-", "c"]
let owned = mem::take(&mut v[0]); // swaps in String::default() (an empty String)
println!("took {owned:?}, now {v:?}"); // took "a", now ["", "-", "c"]
}
Why is let taken = v[1]; rejected? Because moving the String out of slot 1 would leave a hole in the Vec -- a slot that no longer holds a valid value but is still inside a live collection. If that were allowed, the Vec could later try to Drop that empty slot and free a buffer that was already moved away. Rust refuses to create the hole in the first place.
The fix is to give the slot something valid to hold as you take the real value out. That is exactly what std::mem::replace does: it swaps a new value in and hands you the old one back, so the slot is never empty for even an instant. std::mem::take is the same move but slicker -- it swaps in the type's Default value (an empty String here) and returns what was there. These two little functions, plus std::mem::swap, are the honest way to move a value out from behind a reference, and you will reach for them constantly once you start writing data structures. If you genuinely want to remove the element and shrink the Vec, v.remove(1) or v.swap_remove(1) move the element out for you and close the gap -- but when you want to keep the slot, replace and take are the tools.
Moves in match and destructuring
Pattern matching moves too, and this is the source of a great many "value moved in the match" errors. When you match a value by value and bind an inner piece, that inner piece moves out of the thing you matched, consuming it:
enum Message { Text(String), Quit }
fn main() {
let msg = Message::Text(String::from("hello"));
match msg {
Message::Text(s) => println!("text: {s}"), // s moves the String out of msg
Message::Quit => println!("quit"),
}
// println!("{:?}", "msg is gone"); // msg was consumed by the match above
}
Because the Text arm binds s by value, the String inside msg moves into s, and msg is consumed. If you only wanted to look at the contents, that is wasteful and often not what you meant. The fix is to match on a reference, and then the bindings become references too in stead of moving anything out:
enum Message { Text(String), Quit }
fn main() {
let msg = Message::Text(String::from("hello"));
match &msg {
Message::Text(s) => println!("text: {s}"), // s is &String, msg is untouched
Message::Quit => println!("quit"),
}
// msg is still fully usable here, because we matched on &msg
if let Message::Text(s) = &msg {
println!("still have: {s}");
}
}
Matching on &msg makes each binding a reference into msg rather than a move out of it, so msg survives the match intact and we can inspect it again afterwards. (Rust's match ergonomics even let you write the patterns without sprinkling ref everywhere -- matching on a reference automatically binds by reference.) Once you internalise the rule "matching by value moves the captured parts, matching by reference borrows them", the whole family of match-move errors stops being mysterious and becomes a choice you make on purpose.
How other languages see this
Since quite some of you came to Rust from the Learn Python Series, a look sideways sharpens the picture. In Python there is no such thing as a move at all -- assignment just makes a second name for the same object:
a = [1, 2, 3]
b = a # both names point at the SAME list -- no move, no copy
b.append(4)
print(a) # [1, 2, 3, 4] -- the change through b is visible through a
print(a is b) # True -- one object, two labels
Nothing is invalidated, nothing is duplicated, and both a and b are live references to one shared list. That is convenient, but it is also exactly the aliasing that leads to "why did my list change when I only touched the other one?" bugs, and Python leans on a garbage collector to work out when the object can finally be freed. C++ is the interesting middle ground: it has move semantics too (since C++11, via std::move and rvalue references), but a moved-from object there is left in a valid-but-unspecified state that you are still technically allowed to use, which is its own subtle footgun. Rust's version is stricter and, I argue, simpler to reason about: after a move the source is statically gone, the compiler enforces it, and there is no runtime cost and no collector. You get C++-level control with Python-level "it just does the right thing" confidence -- accidentically using a moved value is a compile error, not a 3 a.m. debugging session.
What did we actually learn?
- A move copies a value's stack bytes to a new location and invalidates the source. For a
Stringthat is three little words; the heap buffer is never touched, which is why moves are cheap regardless of data size. - The source must be invalidated so two owners never free one buffer -- moves are what make double-free impossible, the flip side of the
Drop/RAII coin from episode 20. - Copy types (integers, floats, small scalar aggregates) duplicate on assignment and leave the original valid; everything that owns a heap resource is deliberately not
Copy. "Move or copy?" reduces to "is itCopy?". - Values move into functions and move out on return; take
&Tto borrow, takeTto consume. Reassigning an occupied binding drops the old value right there. - Partial moves track moved-ness per field. You cannot move out of a
Vecindex (it would leave a hole) -- usestd::mem::replaceormem::taketo swap a valid value in as you take the old one out. - Matching by value moves the captured parts and consumes the scrutinee; matching by reference borrows them and leaves the original usable.
That is the whole model, and it really is mechanical once it clicks: find the value, ask whether the type is Copy, and if not, know that the source is done the moment it moves. Next time we start looking at the types that bend these rules on purpose -- letting you reach in and mutate a value even when all you are holding is a shared, immutable-looking reference. It sounds like it should be impossible after everything we just said about ownership, and that tension is exactly what makes it interesting. One idea at a time ;-)
Exercises
Three exercises this time, from gentle to chewier. Genuinely have a go before the next episode -- typing it yourself is where the pattern sticks.
- Move a
Vec<String>into a function that prints its length, then try to use the vector afterward inmainand read the exact move error the compiler gives you. Then fix it by having the function borrow (&Vec<String>) in stead of taking ownership. - Build a struct with two
Stringfields, move one field out into its own binding, and confirm that you can still read aCopyfield on the struct but can no longer move the struct as a whole. - Given a
let mut names = vec![String::from("ada"), String::from("bit")];, write code that takes ownership of the element at index 0 without removing it from the vector (leaving a valid placeholder behind), usingstd::mem::replaceorstd::mem::take. Print both the taken value and the vector afterward.
That is moves demystified -- thanks for reading, and see you in the next one! ;-)