How to Handle Resource Cleanup in Rust (RAII)

Rust uses the Drop trait to automatically clean up resources when variables go out of scope, ensuring safe memory management without manual intervention.

The cleanup that writes itself

You write a function that opens a database connection. You run a query. You close the connection. You add error handling. You add a retry loop. You add a timeout. Now you have five places where the connection must close. You miss one. The connection leaks. The database runs out of slots. Your service crashes.

In Rust, you don't scatter cleanup code across five branches. You write cleanup once. You attach cleanup to the connection object. When the object vanishes, the cleanup runs. This pattern is RAII. Rust builds it into the language via the Drop trait. You never write manual cleanup calls. The compiler guarantees resources release exactly when they should.

RAII and the Drop trait

RAII stands for Resource Acquisition Is Initialization. The name comes from C++. The idea is universal. Acquiring a resource and setting up its cleanup happen at the same time. Creating the value acquires the resource. Destroying the value releases it.

No try/finally blocks. No with statements. No manual close() calls. The value itself carries the cleanup logic. When the value goes out of scope, the cleanup runs. If the value moves, the cleanup moves with it. If the value clones, both copies clean up independently.

Think of a rental car with a built-in return mechanism. You pick up the car. The rental company tracks it. You don't drive it back to the lot. You just stop using it. The moment you walk away, the car teleports back to the depot. You can't forget to return it. The return is tied to your possession.

Rust implements this with the Drop trait. Any type can implement Drop. The trait has one method: fn drop(&mut self). You never call this method. The compiler generates calls automatically. When a value is about to be destroyed, the compiler inserts a call to Drop::drop.

Minimal example

Here is a type that implements Drop. The cleanup runs when the value leaves scope.

/// A log file handle that prints a message when closed.
struct LogFile {
    path: String,
}

impl Drop for LogFile {
    /// Runs automatically when the LogFile is destroyed.
    fn drop(&mut self) {
        println!("Closing log: {}", self.path);
    }
}

fn main() {
    // LogFile is created. Resource acquired.
    let log = LogFile {
        path: "app.log".to_string(),
    };

    // log is used here.
    println!("Writing to {}", log.path);

    // log goes out of scope. Drop runs automatically.
    // Output: Closing log: app.log
}

The Drop implementation defines what happens on cleanup. The main function creates a LogFile. The value lives until the end of the block. When the block ends, the compiler calls Drop::drop(&mut log). The message prints. The memory frees.

How the compiler inserts cleanup

The compiler tracks every value. It knows when a value is created. It knows when a value is last used. It inserts drop calls at the last use. This is called "drop glue".

If you move a value, the drop moves with it. The original location doesn't drop. The new location drops. This ties cleanup to ownership.

fn main() {
    let log = LogFile {
        path: "app.log".to_string(),
    };

    // Move log into a function.
    // The drop moves with the value.
    process_log(log);

    // log is gone. No drop here.
}

/// Takes ownership of the log file.
fn process_log(log: LogFile) {
    // log lives here now.
    println!("Processing {}", log.path);
    // log drops here when the function returns.
}

Moving suppresses the drop at the source. This is why Vec doesn't leak when you move it. The buffer follows the data. The drop runs at the new location.

Ah-ha reveal: std::mem::drop is just a function that takes ownership and does nothing. The function body is empty. The act of passing ownership to the function forces the drop. This is a clever trick. You use std::mem::drop to force cleanup before the end of the scope.

use std::mem;

fn main() {
    let log = LogFile {
        path: "app.log".to_string(),
    };

    // Force drop now.
    mem::drop(log);

    // log is gone. Cannot use it here.
    // println!("{}", log.path); // Error: use of moved value
}

Trust the drop order. The compiler knows what depends on what.

The guard pattern

Libraries use RAII to manage locks, file handles, and other resources. The standard pattern is a "guard" struct. You acquire the resource. You get a guard. The guard holds the resource. The guard implements Drop. Drop releases the resource.

This guarantees release even if you panic. If the code panics, the guard still drops. The resource still releases. No deadlocks. No leaks.

use std::sync::Mutex;

fn main() {
    let mutex = Mutex::new(0);

    {
        // lock() returns a MutexGuard.
        // The guard holds the lock.
        let guard = mutex.lock().unwrap();

        // Do work while locked.
        *guard += 1;

        // guard drops here.
        // Drop unlocks the mutex automatically.
    }

    // Mutex is unlocked now.
    println!("Value: {}", *mutex.lock().unwrap());
}

MutexGuard is a guard struct. lock() returns the guard. The guard holds the lock. Drop unlocks. This pattern appears everywhere in Rust. File handles use it. RefCell borrows use it. Database connections use it.

Convention aside: Types that manage resources often end in Guard, Lock, or Handle. This signals RAII behavior. If you see MutexGuard, you know it drops and unlocks. If you see File, you know it drops and closes.

Trust the guard pattern. It turns a potential deadlock into a guaranteed unlock.

Realistic example: Custom resource

Here is a realistic example. A type that manages a simulated network connection. The connection acquires a socket. The connection drops and closes the socket.

/// A network connection that closes automatically.
struct Connection {
    id: u32,
}

impl Connection {
    /// Creates a new connection.
    fn new(id: u32) -> Self {
        println!("Connecting to socket {}", id);
        Connection { id }
    }
}

impl Drop for Connection {
    /// Closes the connection when the value is destroyed.
    fn drop(&mut self) {
        println!("Closing socket {}", self.id);
    }
}

fn main() {
    let conn = Connection::new(42);

    // Use the connection.
    println!("Sending data on {}", conn.id);

    // conn drops here. Socket closes.
}

The Connection struct holds the resource identifier. new acquires the resource. Drop releases it. The user never calls close. The cleanup is automatic.

Pitfalls and edge cases

Drop has rules. Breaking them causes errors or aborts.

Manual drop is forbidden. You cannot call drop manually. value.drop() is a compile error. The compiler prevents double drops. If you need early drop, use std::mem::drop(value). This takes ownership and forces the drop.

Drop panics abort the program. If Drop panics, the runtime calls abort. No unwinding. This prevents double panic. If Drop panics during unwinding, the second panic would be catastrophic. Abort is safer. Never panic in Drop. Handle errors gracefully.

Drop order is reverse declaration. Values drop in reverse order of declaration. Later values drop first. This handles dependencies. If a depends on b, a drops before b. The compiler enforces this.

fn main() {
    let a = LogFile { path: "a.log".to_string() };
    let b = LogFile { path: "b.log".to_string() };
    // b drops first. Then a drops.
    // Output:
    // Closing log: b.log
    // Closing log: a.log
}

Don't fight the drop order. Structure your data so dependencies are clear.

ManuallyDrop suppresses drop. std::mem::ManuallyDrop<T> wraps a value and suppresses drop. You must call ManuallyDrop::into_inner or forget to clean up. Use this only for custom allocators or FFI where you manage memory manually.

use std::mem::ManuallyDrop;

fn main() {
    let log = ManuallyDrop::new(LogFile {
        path: "app.log".to_string(),
    });

    // log does not drop here.
    // You must clean up manually.
    ManuallyDrop::into_inner(log);
    // Now it drops.
}

Use ManuallyDrop only when you have a proof of cleanup. Otherwise, let the compiler handle it.

Decision matrix

Use Drop when you need automatic cleanup tied to a value's lifetime. Use Drop for file handles, mutex locks, database connections, and any resource that must release when the value dies. Use std::mem::drop when you need to force cleanup before the end of the scope. Use std::mem::drop to release a lock early or free memory before a long computation. Use ManuallyDrop when you are managing memory manually or passing ownership to FFI and will handle cleanup yourself. Use ManuallyDrop for custom allocators or when interfacing with C code that takes ownership. Use a guard struct when you need to lock a resource and guarantee unlock, like a mutex or file handle. Use the guard pattern for any resource that requires a paired acquire and release operation.

Treat the Drop implementation as a promise. If you can't keep the promise, use ManuallyDrop.

Where to go next