Move Semantics
In Rust, most values have exactly one owner at a time, and when you assign a value to a new variable or pass it to a function, ownership of that value moves rather than being duplicated. This is the mechanism Rust uses to guarantee memory safety without a garbage collector: only one binding is ever responsible for freeing a given piece of heap memory. Understanding exactly when a move happens, and when it doesn’t, is one of the most important skills for writing Rust that compiles on the first try.
Overview / How it works
A String is not stored as a single blob. On the stack, a String binding is three machine words: a pointer to a heap buffer holding the text’s bytes, a length, and a capacity. When you write let s2 = s1;, Rust does not walk the heap buffer and duplicate its bytes. It copies those three stack words into s2‘s slot, so s2 now points at the same heap buffer s1 pointed at.
If Rust let both s1 and s2 stay valid after that, both would believe they owned the same heap buffer. When each went out of scope, Rust would try to free that buffer twice, a "double free", which is undefined behavior and a classic source of memory-safety bugs in languages like C and C++. Rust avoids the problem entirely by treating s1 as no longer valid the instant its bits are copied into s2. This is a move: ownership of the heap buffer transferred from s1 to s2, and the compiler will refuse to compile any later code that tries to read s1.
Crucially, this is enforced entirely at compile time. There is no runtime flag on the string data marking it "moved-from", and no runtime check happens when you use a variable. The borrow checker statically tracks, for every binding, whether it currently owns valid data or has already been moved out of, and it rejects any use of a moved-from binding as a compile error. This is why a moved value never causes a crash at runtime in safe Rust: the compiler catches the mistake before the program ever runs.
Contrast this with a type like i32. An i32 is stored entirely inline on the stack, four bytes, no heap buffer, no pointer to anything. There is nothing external to "own", so duplicating those four bytes is cheap, safe, and leaves both bindings independently usable. Types that implement the marker trait Copy (all the integer and float types, bool, char, and tuples or arrays built only from Copy types) get this behavior automatically: assignment and function calls copy them bit-for-bit instead of moving them, and the original binding stays perfectly valid afterward.
A useful analogy: think of a String as a library card that lets whoever holds it check out one specific book. Handing the card to a friend (let s2 = s1;) means your friend can now use it, but you no longer have the card, so you can’t be trusted to also return the book. Two people holding the same card and both trying to return the same book is exactly the double-free bug Rust’s move rules prevent. An i32, on the other hand, is like telling someone a phone number out loud, both of you now simply know it, with nothing to hand over or coordinate on.
Syntax
A move is not a special syntax of its own, it is a consequence of certain operations applied to a non-Copy value. The main move-triggering operations are:
let b = a; // assignment moves 'a' into 'b' (if 'a' is not Copy)
function(a); // passing by value moves 'a' into the function's parameter
let s = Struct { field: a }; // moving 'a' into a struct field
return a; // returning 'a' moves it out to the caller
| Operation | Effect on a non-Copy value a |
|---|---|
let b = a; |
a is moved into b; a becomes invalid |
f(a) (by value) |
a is moved into the function’s parameter |
a.clone() |
explicitly duplicates the data; a stays valid |
&a |
borrows a temporarily; no move occurs |
For Copy types (integers, floats, bool, char, and tuples/arrays of these), the exact same syntax instead performs a bitwise copy, and the original binding remains usable. Whether a given assignment is a move or a copy depends entirely on whether the type implements Copy, not on the syntax you write.
Examples
Example 1: A basic move
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{}", s2);
}
Output:
hello
Here s1 is moved into s2 at let s2 = s1;. From that line onward, only s2 is a valid handle to the string data; s1 cannot be used again. Since the code never tries to use s1 afterward, it compiles cleanly.
Example 2: Moving into a function
fn takes_ownership(s: String) {
println!("Inside function: {}", s);
}
fn main() {
let s = String::from("world");
takes_ownership(s);
println!("Back in main");
}
Output:
Inside function: world
Back in main
Passing s to takes_ownership by value moves it into the function’s parameter. Inside the function, that parameter owns the string and drops (frees the heap buffer) automatically when the function returns. Back in main, s is no longer valid, which is fine here because the code doesn’t try to use it again.
Example 3: Clone vs Copy
fn main() {
let s1 = String::from("rust");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);
let x = 5;
let y = x;
println!("x = {}, y = {}", x, y);
}
Output:
s1 = rust, s2 = rust
x = 5, y = 5
.clone() explicitly performs a deep copy of the heap buffer, producing an independent String so both s1 and s2 remain valid and usable, at the cost of the extra allocation and copy. x and y are i32, a Copy type, so let y = x; copies the four bytes automatically; no .clone() call is needed or even available in the same sense, since there’s no heap data to duplicate.
Example 4: Moving ownership out of a function
fn process(v: Vec<i32>) -> Vec<i32> {
let mut v = v;
v.push(4);
v
}
fn main() {
let numbers = vec![1, 2, 3];
let numbers = process(numbers);
println!("{:?}", numbers);
}
Output:
[1, 2, 3, 4]
numbers is moved into process, mutated there, and then moved back out as the return value, which is bound to a new numbers via shadowing. Ownership travels in and back out cleanly, with no cloning and no dangling references, this in-and-out pattern is common whenever a function needs to transform a collection it doesn’t otherwise need to keep.
How it works step by step
Walking through Example 1 in detail: when String::from("hello") runs, Rust allocates a 5-byte buffer on the heap containing hello, and creates a stack value for s1 holding a pointer to that buffer, a length of 5, and a capacity of 5. At let s2 = s1;, the compiler copies those three stack words (pointer, length, capacity) into a new stack slot for s2. Critically, the heap buffer itself is untouched, only the small stack-resident "header" was duplicated. From this point, the compiler’s static analysis marks s1 as moved-from: it still occupies its stack slot, but the compiler will reject any expression that tries to read it. When s2 goes out of scope at the end of main, Rust calls its destructor (Drop) exactly once, freeing the heap buffer exactly once. Because s1 was never dropped (it was moved out of before it could own anything at scope end), there is no double free and no leak.
Common Mistakes
Mistake 1: Using a value after it has been moved
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{}", s1); // error: value borrowed here after move
}
This fails to compile because s1 was moved into s2 on the previous line, and the compiler tracks that s1 is no longer valid. The fix is either to stop using s1 after the move, or to .clone() if you genuinely need two independent copies:
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone();
println!("{} {}", s1, s2);
}
Output:
hello hello
Mistake 2: Passing by value when you only needed to read the data
fn takes_ownership(s: String) {
println!("{}", s);
}
fn main() {
let s = String::from("hi");
takes_ownership(s);
println!("{}", s); // error: value used here after move
}
takes_ownership only prints s, it never needed to own it, but taking String by value forces a move, leaving main unable to use s afterward. The fix is to accept a reference instead, borrowing the data instead of taking ownership of it:
fn takes_ownership(s: &String) {
println!("{}", s);
}
fn main() {
let s = String::from("hi");
takes_ownership(&s);
println!("{}", s);
}
Output:
hi
hi
Mistake 3: Assuming a custom struct is Copy when it isn’t
struct Point {
x: i32,
y: i32,
}
fn main() {
let p1 = Point { x: 1, y: 2 };
let p2 = p1;
println!("{}", p1.x); // error: value borrowed here after move
}
Even though Point only contains i32 fields, which are individually Copy, the struct itself does not implement Copy unless you explicitly derive it. Without that derive, let p2 = p1; moves p1. If a type’s fields are all Copy and the type is small and simple, deriving Copy (and the required Clone) is usually the right fix:
#[derive(Clone, Copy)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let p1 = Point { x: 1, y: 2 };
let p2 = p1;
println!("{} {}", p1.x, p2.x);
}
Output:
1 1
Best Practices
- Prefer borrowing (
&T) over taking ownership when a function only needs to read or temporarily use a value. - Reach for
.clone()only when you genuinely need two independent, owned copies, it allocates and copies, so overusing it hides needless cost. - Derive
Copy(along withClone) for small, simple structs made entirely ofCopyfields, it makes them behave like primitives and avoids move-related friction. - Remember that returning a value from a function moves it out to the caller, this is how you can hand ownership back after modifying something locally, as in Example 4.
- Watch for moves inside loops: moving a non-
Copyvalue out of a variable used across multiple iterations will fail to compile on the second iteration; iterate by reference (&collection) instead when you don’t need to consume each element. - Use shadowing (
let x = x;) when you want to transform a value and reuse the same name, rather than inventing a new variable name for every step.
Practice Exercises
- Write a function
string_length(s: &String) -> usizethat returns the length of a string without taking ownership of it, then call it twice on the sameStringinmainto prove the original binding is still usable afterward. - Write a function
append_world(mut s: String) -> Stringthat pushes the text" world"onto the end ofsand returns it. Call it with aStringcontaining"hello"and print the result; expected output ishello world. - Predict, before compiling, which lines in the following snippet would fail to compile and why: create a
Vec<i32>, assign it to a second variable, then try to print both variables. Then fix it using.clone()so both prints succeed.
Summary
- Assigning a non-
Copyvalue, or passing it by value to a function, moves ownership; the original binding becomes invalid and the compiler rejects any later use of it. - Moves are checked entirely at compile time by the borrow checker, there is no runtime flag or cost for tracking ownership.
- Types implementing
Copy(integers, floats,bool,char, and tuples/arrays of these) are duplicated on assignment instead of moved, so both bindings stay valid. - Moves exist to prevent double frees: only one owner is ever responsible for freeing a value’s heap memory.
- Use
&Tto borrow instead of moving when a function only needs read access, and use.clone()when you truly need an independent, owned duplicate. - Custom structs are not
Copyby default, even if every field is, add#[derive(Clone, Copy)]when that behavior is desired and appropriate.
