Drops, Moves, and Copies in Rust Programming Language: Memory Management and Ownership Transfer Explained

Drops, Moves, and Copies in Rust

Drops, Moves, and Copies in Rust

A while back I spent an entire afternoon debugging why a struct in my code wasn’t cleaning up a file handle the way I expected. The issue turned out to be a subtle misunderstanding of how Drop interacts with moves. That afternoon taught me more about Rust’s memory model than any tutorial had up to that point, and it’s the reason I wanted to write this guide — not as a dry reference, but as the explanation I wish I’d had before I lost that afternoon.

If you already understand the basics of ownership, this article goes one level deeper: what actually happens when a value is dropped, moved, or copied, how Rust decides which behavior applies, and how to use the Drop, Copy, and Clone traits correctly in your own types.

A Quick Refresher: Why This Topic Exists

Rust doesn’t have a garbage collector. Instead, every value has exactly one owner, and Rust automatically cleans up (drops) a value the moment its owner goes out of scope. That cleanup process, and the rules around what happens to a value when it’s assigned, passed, or returned, are governed by three related but distinct concepts:

These three ideas work together to give Rust deterministic, predictable memory management — you always know exactly when a value’s resources will be released, unlike in garbage-collected languages where cleanup timing is unpredictable.

Moves: The Default Behavior for Most Types

By default, when you assign a non-Copy value to a new variable or pass it to a function, Rust performs a move. The original variable is invalidated, and only the new one can be used.

fn main() {
    let original = String::from("Rust");
    let moved = original; // ownership moves to `moved`

    println!("{}", moved);
    // println!("{}", original); // ERROR: value borrowed after move
}

This isn’t just a compiler restriction for the sake of it — it reflects what’s actually happening in memory. A String is a small struct on the stack (pointer, length, capacity) pointing at heap data. When you “move” it, Rust bitwise-copies that small struct to the new variable but considers the old one dead. If both were still considered valid, you’d eventually get a double-free when both went out of scope and both tried to free the same heap memory.

Moves and Function Calls

fn consume(s: String) {
    println!("Consumed: {}", s);
} // s is dropped here

fn main() {
    let name = String::from("moved into function");
    consume(name);
    // name is no longer valid here
}

If you need to use a value both inside and after a function call, you either pass a reference (borrowing, covered in ownership fundamentals) or return the value back out of the function:

fn consume_and_return(s: String) -> String {
    println!("Using: {}", s);
    s // ownership moves back out
}

fn main() {
    let name = String::from("round trip");
    let name = consume_and_return(name);
    println!("Still have it: {}", name);
}

Output:

Using: round trip
Still have it: round trip

Copies: When Rust Duplicates Instead of Moving

Some types are cheap and simple enough that Rust duplicates them automatically instead of moving them. These are types that implement the Copy trait — things like i32, f64, bool, char, and tuples composed entirely of Copy types.

fn main() {
    let x = 5;
    let y = x; // x is copied, not moved

    println!("x = {}, y = {}", x, y); // both valid!
}

Output:

x = 5, y = 5

Why Only Some Types Are Copy

A type can only implement Copy if a bitwise duplication is both cheap and correct — meaning it doesn’t manage any heap resource that would need special cleanup. String, Vec<T>, and Box<T> all manage heap memory, so they can’t be Copy — if they were, dropping both copies would free the same memory twice.

You can derive Copy for your own simple structs, as long as every field is also Copy:

#[derive(Copy, Clone, Debug)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p1 = Point { x: 1, y: 2 };
    let p2 = p1; // copied, not moved

    println!("{:?} and {:?}", p1, p2); // both valid
}

Output:

Point { x: 1, y: 2 } and Point { x: 1, y: 2 }

Note that Copy requires Clone as a supertrait — every Copy type must also implement Clone, though Copy makes the duplication implicit (happens automatically on assignment) while Clone requires an explicit .clone() call.

Copy vs. Clone: The Practical Difference

#[derive(Clone, Debug)]
struct Config {
    name: String, // String isn't Copy, so Config can't be Copy either
}

fn main() {
    let c1 = Config { name: String::from("prod") };
    let c2 = c1.clone(); // explicit deep copy

    println!("{:?} and {:?}", c1, c2);
}

Because Config contains a String, it can’t derive Copy — but it can derive Clone, which lets you opt into an explicit, possibly expensive, deep copy whenever you actually need one. This distinction is deliberate: Rust wants expensive operations to be visible in your code, never hidden behind a simple assignment.

The Drop Trait: Automatic Cleanup

The Drop trait lets you define custom cleanup logic that runs automatically when a value goes out of scope. This is Rust’s version of a destructor, and it’s the backbone of the RAII (Resource Acquisition Is Initialization) pattern — tying resource cleanup directly to object lifetime.

struct FileHandle {
    name: String,
}

impl Drop for FileHandle {
    fn drop(&mut self) {
        println!("Closing file: {}", self.name);
    }
}

fn main() {
    let _f1 = FileHandle { name: String::from("data.txt") };
    println!("File handle created");
} // _f1 goes out of scope, drop() is called automatically

Output:

File handle created
Closing file: data.txt

You never call drop() directly by name in normal circumstances — Rust calls it automatically. In fact, trying to call .drop() manually is a compile error, precisely to prevent double-free-style bugs:

fn main() {
    let f1 = FileHandle { name: String::from("data.txt") };
    // f1.drop(); // ERROR: explicit destructor calls not allowed
}

If you genuinely need to drop something early, use std::mem::drop, a plain function that simply takes ownership of the value and immediately lets it fall out of scope:

fn main() {
    let f1 = FileHandle { name: String::from("data.txt") };
    println!("About to close early");
    drop(f1); // explicitly drops f1 right here
    println!("Already closed");
}

Output:

About to close early
Closing file: data.txt
Already closed

Drop Order: The Details That Matter

Rust drops values in the reverse order they were declared within a scope — last in, first out, just like unwinding a stack.

struct Noisy(&'static str);

impl Drop for Noisy {
    fn drop(&mut self) {
        println!("Dropping {}", self.0);
    }
}

fn main() {
    let _a = Noisy("A");
    let _b = Noisy("B");
    let _c = Noisy("C");
}

Output:

Dropping C
Dropping B
Dropping A

For struct fields, Rust drops them in declaration order (not reverse), and for a struct’s own Drop::drop implementation, that runs before its fields are dropped. Knowing this order matters when your cleanup logic in one field depends on another field still being valid.

Moves and Drop: How They Interact

Here’s the subtlety that cost me that debugging afternoon I mentioned earlier: once a value has been moved, Rust will not call drop on the original variable, because it’s no longer considered to own anything.

struct Resource {
    id: u32,
}

impl Drop for Resource {
    fn drop(&mut self) {
        println!("Releasing resource {}", self.id);
    }
}

fn main() {
    let r1 = Resource { id: 1 };
    let r2 = r1; // r1 is moved into r2
    println!("r2 owns resource {}", r2.id);
} // only r2 is dropped here — r1 was never a valid owner at this point

Output:

r2 owns resource 1
Releasing resource 1

Notice drop only fires once, for r2. This is exactly the guarantee Rust is built around: a resource is dropped exactly once, no matter how many times ownership moves between variables, because at any given moment there’s only ever one true owner.

Internal Working: Drop Flags and Partial Moves

Under the hood, in cases where the compiler can’t statically determine at compile time whether a value was moved out (for example, inside conditional branches), Rust used to insert a hidden runtime “drop flag” to track whether a value still needs dropping. Modern Rust has largely optimized this away through better static analysis, but understanding that this bookkeeping exists helps explain why partial moves work the way they do:

struct Pair {
    first: String,
    second: String,
}

fn main() {
    let pair = Pair {
        first: String::from("one"),
        second: String::from("two"),
    };

    let first = pair.first; // partial move — only `first` field moves out
    println!("{}", first);
    println!("{}", pair.second); // still valid — `second` wasn't moved
    // println!("{}", pair.first); // ERROR: pair.first was moved
}

Rust tracks moves at the field level here, which is why pair.second remains usable even though pair.first was moved out. pair as a whole, however, can no longer be used or passed around, since it’s only partially valid.

Real-World Example: RAII for a Database Connection

This pattern shows up constantly in real Rust codebases — using Drop to guarantee a resource is released no matter how a function exits, including on early returns or panics:

struct DbConnection {
    url: String,
}

impl DbConnection {
    fn connect(url: &str) -> Self {
        println!("Connecting to {}", url);
        DbConnection { url: url.to_string() }
    }

    fn query(&self, sql: &str) {
        println!("Running query on {}: {}", self.url, sql);
    }
}

impl Drop for DbConnection {
    fn drop(&mut self) {
        println!("Disconnecting from {}", self.url);
    }
}

fn run_report() {
    let conn = DbConnection::connect("db://prod");
    conn.query("SELECT * FROM orders");
    // conn is dropped automatically here, even if this function
    // returned early or panicked above
}

fn main() {
    run_report();
    println!("Report finished");
}

Output:

Connecting to db://prod
Running query on db://prod: SELECT * FROM orders
Disconnecting from db://prod
Report finished

You get guaranteed cleanup without a finally block or manual bookkeeping — this is one of the more elegant patterns Rust makes ordinary.

Common Mistakes and Debugging Tips

cargo new drop_demo
cd drop_demo
cargo clippy
cargo run

Best Practices

FAQs

Q: Can a type implement both Copy and Drop? No — the compiler explicitly disallows this combination, since Copy implies no special cleanup is needed, which directly contradicts what Drop is for.

Q: Does Clone always perform a deep copy? Not necessarily by rule — Clone is a trait you implement, so its behavior is up to you. Convention strongly favors deep copies, but reference-counted types like Rc<T> implement Clone to simply increment a reference count instead.

Q: What happens if drop panics? It’s allowed but discouraged; if a panic occurs during unwinding while another drop is already running, the program will abort rather than continue unwinding normally.

Q: Is there a way to see when something gets dropped without writing a custom Drop impl? Yes — you can temporarily wrap a value or add println! calls inside a minimal Drop implementation just for debugging, then remove it afterward.

Q: Why can’t I use a value after passing it to a function that takes ownership? Because ownership moved into that function; once the function returns, if it didn’t return the value back, the original binding in your caller is no longer valid.

Summary

Moves, copies, and drops are really three sides of the same guarantee: Rust always knows exactly who owns a value and exactly when that value’s resources should be released. Moves transfer ownership without any hidden cost, copies duplicate simple data automatically when it’s safe to do so, and Drop ties cleanup directly to a value’s lifetime so you never have to remember to free something manually. Once you’ve internalized how these three behaviors interact — especially the fact that moved-from values are simply never dropped — a huge class of “why isn’t my cleanup running” or “why won’t this derive Copy” confusion disappears.

References

Exit mobile version