The `with` Pattern in Rust

Elijah Koulaxis

June 8, 2026

There's a class of bug: the caller forgot to close the thing. Forgot to commit. Forgot to roll back. The function returned Ok and three hours later the database is still holding a row lock, or the file is still open.

The usual Rust answer is "use Drop." It's a fine answer for a lot of resources. It's not the right one when cleanup can fail or when there's a real decision to make at the end. For those, there's a nicer pattern: hand the resource to a closure instead of returning it.

I call it the with pattern. Setup --> lend resource to closure --> teardown. All three steps in one function, and the borrow checker makes sure you can't skip any of them.

A Naive API

Take this transaction API:

pub struct Connection { ... }
pub struct Transaction<'c> { conn: &'c mut Connection }

impl Connection {
    pub fn begin(&mut self) -> Transaction<'_> { todo!() }
}

impl<'c> Transaction<'c> {
    pub fn execute(&mut self, _sql: &str) -> Result<u64, DbError> { Ok(0) }
    pub fn commit(self) -> Result<(), DbError> { Ok(()) }
    pub fn rollback(self) -> Result<(), DbError> { Ok(()) }
}

#[derive(Debug)]
pub struct DbError;

And the call site:

fn transfer(conn: &mut Connection, from: u64, to: u64, cents: u64) -> Result<(), DbError> {
    let mut tx = conn.begin();
    tx.execute(&format!("UPDATE accounts SET balance = balance - {cents} WHERE id = {from}"))?;

    // If this `?` fires, we early-return. No commit, no rollback. The transaction
    // is just abandoned, and the DB holds locks until the connection dies.

    tx.execute(&format!("UPDATE accounts SET balance = balance + {cents} WHERE id = {to}"))?;
    tx.commit()
}

The footguns:

  1. ? skips both commit and rollback. The transaction is abandoned.
  2. A panic does the same.
  3. Nothing stops the caller from stashing tx somewhere and using it later.

You can change this with a Drop impl that rolls back. People do. But now the fate of the user's data is decided by a silent destructor, and Drop can't return errors, which is fragile.

The with Version

Flip the API inside out. Instead of handing back a Transaction, take a closure and run it between BEGIN and the right terminator:

use std::panic::{self, AssertUnwindSafe};

pub struct Connection { ... }
#[derive(Debug)]
pub struct DbError;

// The handle the closure receives. No public constructor, the only
// way to get one is to be inside `with_transaction`
pub struct Tx<'a> { conn: &'a mut Connection }

impl<'a> Tx<'a> {
    pub fn execute(&mut self, _sql: &str) -> Result<u64, DbError> { Ok(0) }
}

impl Connection {
    // Private terminators. The caller never sees these, so they can't
    // be skipped or called in the wrong order.
    fn begin(&mut self)    -> Result<(), DbError> { todo!() }
    fn commit(&mut self)   -> Result<(), DbError> { todo!() }
    fn rollback(&mut self) -> Result<(), DbError> { todo!() }

    pub fn with_transaction<F, R>(&mut self, f: F) -> Result<R, DbError>
    where
        // `for<'a>` says: this closure must work for ANY lifetime we pick.
        // We'll pick one that ends when this function returns, which is
        // what stops `Tx` from escaping
        F: for<'a> FnOnce(&mut Tx<'a>) -> Result<R, DbError>,
    {
        self.begin()?;
        let mut tx = Tx { conn: self };

        // Catch a panic so we can still roll back before it propagates.
        let caught = panic::catch_unwind(AssertUnwindSafe(|| f(&mut tx)));

        // Drop `tx` so its `&mut Connection` borrow ends and we can reach
        // `self` again to run the terminator.
        drop(tx);

        // Deal with the panic layer first: roll back, then re-raise.
        let result = match caught {
            Ok(r) => r,
            Err(panic_payload) => {
                // Roll back BEFORE resuming the panic, so the DB is clean
                // even if the panic eventually aborts the process.
                let _ = self.rollback();
                panic::resume_unwind(panic_payload);
            }
        };

        // Then the ordinary outcome: commit on success, roll back on error.
        match result {
            Ok(value) => {
                self.commit()?;
                Ok(value)
            }
            Err(user_err) => {
                let _ = self.rollback();
                Err(user_err)
            }
        }
    }
}

There are two layers to peel, which is why there are two matches. catch_unwind hands back a Result whose Err is a panic payload and whose Ok is the closure's own Result. We deal with the panic first (roll back, re-raise), and only then look at the normal success-or-error outcome.

Now the call site:

fn transfer(conn: &mut Connection, from: u64, to: u64, cents: u64) -> Result<(), DbError> {
    conn.with_transaction(|tx| {
        tx.execute(&format!("UPDATE accounts SET balance = balance - {cents} WHERE id = {from}"))?;
        tx.execute(&format!("UPDATE accounts SET balance = balance + {cents} WHERE id = {to}"))?;
        Ok(())
    })
}

No commit. No rollback. There is no path through this function ?, panic, normal return, where the transaction is left open. The user can't forget, because there's nothing to forget.

Why the Handle Can't Escape

The interesting line is the bound:

F: for<'a> FnOnce(&mut Tx<'a>) -> Result<R, DbError>,

for<'a> is a higher-ranked trait bound. It means the closure has to work for any lifetime 'a and we, inside with_transaction, pick one that dies when the function returns.

So this won't compile:

// rejected by the borrow checker
let escaped = conn.with_transaction(|tx| Ok(tx))?;

To return tx, the return type R would have to mention the lifetime we picked, and the caller can't name it. You also can't move tx into a thread::spawn, stash it in a static, or return it from the enclosing function. The handle is stuck inside the closure. That's the whole trick.

There's one thing when comparing with vs Drop

The thing with, well, with, is that it doesn't compose cleanly with async. A synchronous FnOnce can't .await inside, and async closures are still awkward in the language. That's why a lot of async DB clients fall back to Drop-based transactions despite the downsides.

Closing Thought

There's a design principle worth being explicit about: make misuse impossible, not just discouraged.

A doc comment that says "remember to call commit()" is an admission that the API has a footgun and the author has decided to outsource the safety to the reader. The with pattern doesn't ask the reader to be careful. The resource can't exist outside the closure, and the closure can't return without cleanup running. Both halves are enforced by the compiler.

That's a much better place to ship from than "we have good docs."

Tags:
Back to Home