What happens when a variable goes out of scope

Rust automatically frees memory via `drop` when a variable goes out of scope unless it was moved or copied.

When the lights go out

You write a function that opens a log file, writes an error message, and returns. You do not write any code to close the file. The function ends. The file handle disappears. The operating system reports the file is closed. You did not ask for this. The compiler did it for you.

This is scope cleanup. In Rust, every variable has a scope. The scope is the region of code where the variable exists. When execution leaves that region, the variable goes out of scope. The compiler automatically runs cleanup code to reclaim the resources the variable holds. You never write close() or free() for standard types. The compiler inserts the calls. This mechanism prevents memory leaks and resource exhaustion without manual bookkeeping.

The rental car analogy

Think of a variable like a rental car. When you rent the car, you become the owner of that rental agreement. You are responsible for returning the car. Returning the car means handing back the keys and letting the agency wash and park it.

In Rust, "returning the car" happens automatically when the variable goes out of scope. The compiler inserts the return code for you. You do not write return_car(). The compiler writes it. If you hand the keys to a friend, you transfer ownership. Your friend now owns the rental. When your friend's scope ends, they return the car. You do not return it. If you try to return the car after handing the keys away, the agency rejects you. You no longer own the rental.

This analogy maps directly to Rust's ownership rules. The variable is the rental. The scope is the rental period. The drop function is the return process. The compiler ensures the car is returned exactly once, by the current owner, at the end of the rental period.

What the compiler inserts

The compiler analyzes your code and identifies every point where a variable goes out of scope. At each point, it inserts a call to the drop function. drop is a function in the standard library that takes ownership of a value and runs its destructor.

The destructor is defined by the Drop trait. If a type implements Drop, the drop function calls the drop method on that type. If a type does not implement Drop, the drop function does nothing. The compiler still inserts the call, but the call is a no-op.

/// Demonstrates automatic drop when scope ends.
fn main() {
    // String allocates heap memory for the text.
    // String implements the Drop trait.
    let s = String::from("hello");

    // s is alive here.
    // The heap buffer is accessible.
    println!("{}", s);

    // Scope ends.
    // Compiler inserts drop(s).
    // drop(s) calls String::drop.
    // String::drop frees the heap buffer.
    // The stack slot for s is reclaimed.
}

The drop function requires ownership. You cannot call drop on a reference. If you have a reference, you do not own the data. You cannot decide when it dies. This prevents double-free errors. If two references could both drop the same data, the second drop would access freed memory and crash. Rust ties drop to ownership to guarantee safety.

Scope is the trigger. Drop is the action. The compiler wires them together.

Stack versus heap cleanup

Not all variables need the same cleanup. The compiler distinguishes between stack data and heap data.

Stack data lives in the function's stack frame. Types like i32, bool, and references live on the stack. When a stack variable goes out of scope, the compiler simply adjusts the stack pointer. The memory is reclaimed instantly. No drop call is needed. The stack frame is reused for the next function call.

Heap data lives in a separate memory region. Types like String, Vec, and Box hold a pointer to heap memory. The struct itself lives on the stack, but the payload lives on the heap. When such a variable goes out of scope, the compiler calls drop. The drop implementation reads the pointer, calls the allocator to free the heap memory, and then the stack slot is reclaimed.

If you hold a String, the cleanup involves two steps. The heap buffer is freed. The stack struct is popped. If you hold an i32, the cleanup involves one step. The stack slot is popped. The compiler knows the difference. It generates the correct code for each type.

Custom cleanup with the Drop trait

You can define custom cleanup logic for your own types by implementing the Drop trait. The trait has a single method: fn drop(&mut self). The method takes &mut self, which allows the destructor to modify the object's state before cleanup.

/// A struct that logs when it is dropped.
struct Logger {
    name: String,
}

impl Drop for Logger {
    /// Runs when Logger goes out of scope.
    fn drop(&mut self) {
        // Print a message using the name.
        // Self is mutable, so we could modify fields here.
        println!("Dropping logger: {}", self.name);
    }
}

fn main() {
    // Logger is created.
    let log = Logger {
        name: String::from("app"),
    };

    // log is alive here.
    println!("Logger active");

    // Scope ends.
    // Compiler calls drop(log).
    // Logger::drop runs.
    // "Dropping logger: app" is printed.
    // String inside log is freed.
}

The Drop method returns nothing. Drop cannot fail. If cleanup fails, the program panics or leaks. There is no error return. The community convention is to keep drop logic simple. Avoid operations that can panic. If drop panics, the program aborts. The compiler does not catch panics in destructors. This is by design. Panicking during cleanup usually means the system is already in a bad state.

If you need to handle errors during cleanup, store the error in the struct and report it elsewhere. Do not try to return errors from drop.

Moves transfer the responsibility

Ownership transfer changes who is responsible for cleanup. When you move a value, the original variable becomes invalid. It no longer owns the data. The new owner takes responsibility.

/// Demonstrates move semantics and drop.
fn main() {
    // s1 owns the String.
    let s1 = String::from("hello");

    // s1 is moved to s2.
    // s1 is now invalid.
    let s2 = s1;

    // s2 owns the String.
    println!("{}", s2);

    // Scope ends.
    // Compiler calls drop(s2).
    // s1 does not drop.
    // s1 is dead after the move.
}

If you try to use a moved value, the compiler rejects you with E0382 (use of moved value). The error tells you the value was moved and is no longer available. This prevents double drops. If s1 were still valid, the compiler would insert drop(s1) at the end of the scope. s2 would also drop. The heap buffer would be freed twice. The program would crash. The move rule prevents this.

If you move the value, you move the responsibility. The compiler enforces this with E0382.

Copy types skip the process

Some types implement the Copy trait. Types like i32, bool, f64, and references implement Copy. When you assign a Copy type, the compiler copies the bits. The original value remains valid. No ownership transfer occurs.

/// Demonstrates Copy semantics.
fn main() {
    // x is an i32.
    // i32 implements Copy.
    let x = 42;

    // x is copied to y.
    // x remains valid.
    let y = x;

    // Both x and y are alive.
    println!("x = {}, y = {}", x, y);

    // Scope ends.
    // Compiler calls drop(y).
    // Compiler calls drop(x).
    // Both are no-ops for i32.
    // Stack slots are reclaimed.
}

The compiler knows i32 implements Copy. It generates a bitwise copy instead of a move. Both variables own their own copy of the data. When the scope ends, both variables drop. Since i32 does not implement Drop, the drop calls are no-ops. The stack slots are reclaimed.

If you define a struct that contains only Copy fields, you can derive Copy. The struct then behaves like i32. Assigning the struct copies the bits. No drop is needed.

Realistic example: Controlling memory in loops

Scope cleanup matters most in loops. If you allocate memory inside a loop, the memory is freed when the loop variable goes out of scope. This keeps peak memory usage low.

/// Processes data in a loop, clearing memory each iteration.
fn process_batch() {
    // Loop runs three times.
    for i in 0..3 {
        // Allocate a new String for each iteration.
        // Capacity is reserved to avoid reallocations.
        let mut data = String::with_capacity(1024);

        // Simulate work.
        // Append characters to the string.
        for _ in 0..100 {
            data.push('x');
        }

        // data goes out of scope here.
        // drop(data) runs automatically.
        // Heap memory is reclaimed.
        // Next iteration starts with fresh memory.
    }
    // Loop ends.
    // No leaks.
    // Peak memory is bounded by one String.
}

In this example, data is created at the start of each iteration. It goes out of scope at the end of the iteration. The compiler calls drop(data) automatically. The heap memory is freed before the next iteration begins. The function never holds more than one String at a time.

If you moved data outside the loop, the string would grow across iterations. Memory usage would increase. Scope boundaries control resource lifetime. Keep scopes tight. Small scopes mean fast cleanup.

Sometimes you need to free memory before the scope ends. You can call drop explicitly. This is called early drop.

/// Demonstrates early drop in a loop.
fn process_with_early_drop() {
    let mut data = String::with_capacity(1024);

    for i in 0..3 {
        // Simulate work.
        for _ in 0..100 {
            data.push('x');
        }

        // Clear the string for the next iteration.
        // data.clear() keeps the capacity.
        // Memory is not freed.
        data.clear();

        // If you need to free memory, use drop.
        // drop(data) takes ownership.
        // data is dead after this line.
        // You must reassign data to continue using it.
        // drop(data);
        // data = String::with_capacity(1024);
    }
}

The convention is to call drop on a separate line. drop(data); makes the intent clear. Do not hide drop inside a larger expression. Readers should see the early drop immediately. Early drop is a signal that you are managing resources intentionally. Use it when profiling shows memory pressure. Default to implicit drop.

Pitfalls: Cycles and leaks

Scope cleanup works reliably for tree-like ownership. Problems arise with cycles. If two variables hold references to each other, neither can drop.

Consider Rc<T>. Rc uses reference counting. The value drops when the count reaches zero. If A holds an Rc<B> and B holds an Rc<A>, the counts never reach zero. The values leak. Scope ends, but drop never frees the memory.

Rust provides Weak references to break cycles. Weak does not increment the reference count. When all Rc owners drop, the value is freed. Weak references become invalid. You must handle the invalid state.

Leak detection is not automatic. The compiler does not warn about potential leaks. You must design your data structures to avoid cycles. Use Weak where appropriate. Profile memory usage to find leaks.

Another pitfall is ManuallyDrop. ManuallyDrop<T> wraps a value and suppresses the automatic drop. You must call ManuallyDrop::into_inner to take ownership and drop the value manually. If you forget, the value leaks. ManuallyDrop is used for FFI and custom allocators. It is dangerous. Only use it when you have a specific reason.

Treat ManuallyDrop as a last resort. If you use it, document why. The community calls this the "minimum unsafe surface" rule. Keep manual drop logic isolated and well-tested.

Decision: When to control drop manually

Use implicit scope drop when the resource should live exactly as long as the variable. This is the default and covers 99% of cases. The compiler handles cleanup correctly. You do not need to intervene.

Reach for explicit drop(x) when you need to free memory before the scope ends, such as inside a loop to keep peak memory low. Measure memory usage first. Only use early drop when profiling proves you need it.

Pick Copy types for small, stack-only data where cloning is cheap and you do not need ownership semantics. Derive Copy for structs that contain only Copy fields. This avoids unnecessary drop calls.

Use std::mem::forget when you intentionally want to leak a value, such as when converting a Rust value to a C pointer that never returns to Rust. forget suppresses drop. The value leaks. Use this only for FFI interop.

Use ManuallyDrop when you are building a custom allocator or managing memory manually. Isolate the usage. Document the invariants. Test thoroughly.

Default to implicit drop. Only reach for manual control when profiling proves you need it.

Where to go next