Moves

Understand move semantics in Rust - what a move does, why it exists, how it works under the hood, and how to handle moves in assignments, function calls, control flow, and indexed collections.

A move is the mechanism Rust uses to transfer ownership of a value from one place to another. It applies by default to every type that does not explicitly opt into the Copy trait. When you move a value, the old binding becomes inaccessible, and the new binding becomes the sole owner. This is not a high-level metaphor — it is a low-level rule enforced by the compiler that eliminates entire classes of memory bugs.

Copy types are the exception:

Types like integers, booleans, and small tuples of copyable types implement the Copy trait. Assignments of Copy types duplicate the value instead of moving it, and the original stays usable. This section covers moves for non-Copy types.

What a move actually does

Think of a move as a shallow, bitwise copy of the stack data followed by invalidation of the original binding. For a String (which stores its text on the heap), the stack portion contains three fields: a pointer to the heap allocation, a length, and a capacity. Moving a String copies those three fields to a new location on the stack, then marks the old location as logically destroyed. The heap data never moves; it stays where it is and simply gains a new owner.

let s1 = String::from("hello");
let s2 = s1;  // s1 is moved; s2 now owns the heap data
// println!("{s1}"); // error[E0382]: borrow of moved value: `s1`
println!("{s2}");

If you were to print the raw addresses of the stack slots, you’d see they differ — &s1 and &s2 point to distinct pieces of memory even though they describe the same heap allocation. The following snippet compiles before the move and would show two different addresses, confirming that the stack data is copied, not aliased.

struct Foo;
let a = Foo {};
println!("a addr: {:p}", &a);
let b = a;
println!("b addr: {:p}", &b);
// a is no longer usable; the compiler prevents further access

After the move, s1 (or a) is not “null” or “zeroed” — the compiler simply refuses to let you use it. There is no runtime check, no dangling pointer magic. The knowledge that the binding is dead lives entirely in the type system and the borrow checker.

Using a moved value is a compile-time error:

If Rust allowed you to use a moved value, the old variable would still hold a copy of the pointer to the heap data. You could then accidentally free the same memory twice (double-free) or read memory that has already been deallocated. Both are undefined behaviour. The compiler catches the mistake at compile time and stops the build with a clear error: error[E0382]: borrow of moved value.

Why moves exist

The single-owner rule eliminates ambiguity about when memory should be freed. When a variable that owns heap memory goes out of scope, Rust calls drop and deallocates the memory. If two variables could claim ownership of the same heap allocation, the compiler would not know which one should drop it — or both might try to drop it, causing a double-free. Moves ensure that exactly one owner exists at any point, so the drop site is always unambiguous.

This design also prevents use-after-free. Once ownership moves into a function or another scope, the original site loses access. You cannot accidentally read a value after its memory has been freed, because the compiler prohibits the read altogether.

Move assignments and function arguments

Assignment is the most visible move operation, but function arguments and return values follow exactly the same rules. When you pass a non-Copy value to a function, ownership moves into the parameter. When a function returns a value, ownership moves to the caller.

fn consume_and_return(s: String) -> String {
    println!("consumed: {s}");
    s // return moves ownership back out
}
fn main() {
    let original = String::from("hello");
    let returned = consume_and_return(original);
    // original is no longer valid here
    println!("returned: {returned}");
}

The move into consume_and_return transfers ownership to the parameter s. The function then returns s, moving ownership to the variable returned in main. At no point does the heap string get copied; the three-word stack payload is simply shuffled through frames. The compiler tracks these moves statically, inserting the drop call exactly when returned goes out of scope at the end of main.

Compile-time tracking means no runtime overhead:

When you see a chain of moves like this, Rust guarantees at compile time that each binding is used exactly as the sole owner. There is no reference counting and no garbage collector running in the background. The generated machine code drops the value only when the final owner goes out of scope, and that drop location is determined before the program ever runs.

You can make the ownership flow explicit with the same pattern used for functions that accept a value, modify it, and hand it back. The caller sacrifices ownership temporarily and regains it, all without any cloning.

Moves and control flow

Moves interact with conditional branches, loops, and match expressions in ways that can surprise newcomers. The compiler tracks whether a value might have been moved along different control-flow paths, and if there is any path where the value is moved, the compiler considers the value potentially moved after the branching point — even if that path was not actually taken at runtime.

let s = String::from("hello");
if true {
    let _ = s; // move s unconditionally in this branch
}
// println!("{s}"); // error[E0382]: use of moved value: `s`

Although the if true always executes, the compiler does not reason about constant propagation at the borrow-checker level. It sees a branch where s is moved and conservatively marks s as moved for all code after the if. The same principle applies to match, while, and for — any branch that consumes a value renders it unusable afterward.

Partial moves can create inconsistent ownership:

If one branch moves a field out of a struct but another branch does not, the struct may be left partially valid. The compiler will flag this and refuse to compile. Avoid moving individual fields in conditional branches unless you move the entire struct or restructure the code so that all paths handle the ownership consistently. A common clean solution is to use Option::take or to move the whole struct into an intermediate value before branching.

Loops also impose move restrictions. You cannot move a value into a loop body on the first iteration and then try to use it again on the second iteration — the value is already gone. If you need to process the same owned value repeatedly, you should iterate over references or move it into a structure that supports repeated access, such as an iterator over a collection you still own.

Moving out of indexed collections

The Vec<T>, arrays, and slices do not let you move elements out through indexing alone. A binding like let x = vec[0] where T is non-Copy would leave a hole in the collection — the vector would still have the same length, but one slot would contain an invalid value. Rust prevents this at the compile step.

let mut v = vec![String::from("a"), String::from("b")];
// let first = v[0]; // error[E0507]: cannot move out of index of `Vec<String>`

Indexing a Vec\u003cNon-Copy\u003e does not transfer ownership:

The error message cannot move out of index is a safety net. If the compiler allowed the move, the vector would contain uninitialized memory at position 0, and any later code that reads that slot — including the vector’s destructor — would trigger undefined behaviour. The borrow checker prevents this entirely.

To move elements out of a collection, use methods designed to update the collection’s length and internal state: remove, pop, swap_remove, or drain. Each of these transfers ownership of the element to you while keeping the collection valid.

let mut v = vec![String::from("a"), String::from("b")];
let first: String = v.remove(0); // moves "a" out, shifts elements left
println!("first: {first}");      // prints "a"
println!("remaining: {v:?}");    // prints ["b"]

Remove moves the element and repairs the container:

After v.remove(0), the vector has one fewer element, the heap string for "a" is now owned by first, and the vector’s remaining contents are still valid. No holes, no double-frees. The same pattern holds for Option and other container types — use .take() or .unwrap()-after-check to move the inner value and leave a valid empty state behind.

If you need to consume every element of a collection, you can often use a for loop that moves the collection itself into an iterator:

let v = vec![String::from("x"), String::from("y")];
for item in v {
    println!("{item}");
}
// v is no longer available — ownership of each element was moved into item

Summary

Moves are Rust’s default ownership-transfer mechanism for heap-allocated and other non-Copy types. Each move is a bitwise copy of the stack representation followed by the compiler marking the old binding as dead. This single-owner discipline lets Rust insert drop calls precisely once, avoiding double-frees, use-after-free, and the need for a garbage collector. Moves apply to assignments, function arguments, return values, and container operations like remove. Control flow adds extra constraints, and the compiler conservatively rejects code where a value might be moved along some paths but not others.

If you find yourself fighting moves, the first instinct should not be to clone everything; instead, look for patterns where you can pass references or reorganize ownership so that data flows linearly through functions. Understanding moves fully opens the door to writing Rust code that is both safe and allocation-minimal.