How to Use Connection Pools in Rust (deadpool, bb8)

Initialize a deadpool-diesel Pool with a Manager and Runtime, then fetch connections via pool.get().await.

Connection pools save your database from itself

Your web server handles a request, opens a database connection, fetches data, and closes the connection. This works perfectly for a single user. Now a bot hits your endpoint ten thousand times a second. The server spends 90% of its time waiting for TCP handshakes and authentication. The database screams "too many connections" and starts dropping packets. Your application crashes under load, not because the queries are slow, but because opening connections is expensive.

You need a connection pool. A pool keeps a stash of open connections ready to go. Instead of negotiating a new connection for every request, your code grabs an idle connection from the pool, runs the query, and puts it back. If everyone is busy, the pool can either wait for a free slot or reject the request immediately. This saves time, reduces latency, and protects the database from being overwhelmed.

The pool is a rack of reusable tools

Think of a connection pool like a tool rack in a workshop. You don't forge a new hammer every time you need to drive a nail. You grab a hammer from the rack, use it, and hang it back up. If the rack is full of hammers, you wait. If the rack is empty, you know someone else is working.

In Rust, the pool manages this lifecycle. You define how many tools the rack can hold. The pool creates connections on demand up to that limit. When your code finishes with a connection, it returns to the pool automatically. The pool also checks connections for health before handing them out. If the database closed a connection due to a timeout, the pool detects it and creates a fresh one. You get a working connection or an error. You never get a stale socket.

Minimal setup with deadpool

The community standard for async connection pooling is deadpool. It supports multiple runtimes and databases with consistent ergonomics. Here is how to set up a pool for PostgreSQL using deadpool-diesel.

use deadpool_diesel::postgres::{Manager, Pool};

#[tokio::main]
async fn main() {
    // Manager knows how to create and recycle connections.
    // It encapsulates the database-specific logic.
    let manager = Manager::new("postgres://localhost/mydb", deadpool_diesel::Runtime::Tokio1);

    // Pool holds the connections.
    // Builder lets you tune size and timeouts before construction.
    let pool = Pool::builder(manager)
        .max_size(16) // Cap concurrent connections to protect the DB.
        .build()
        .unwrap();

    // Get a connection from the pool.
    // This is async and may wait if the pool is busy.
    let conn = pool.get().await.unwrap();

    // Use conn for queries...
    // Connection returns to pool when `conn` goes out of scope.
}

Build the pool once during application startup. Share the pool handle across your handlers, workers, and threads. Do not create a new pool for every request.

Build the pool once. Share it everywhere.

How the pool manages your connections

When you call Pool::builder(manager).build(), the pool starts empty. The Manager is a factory object. The pool asks the manager to create connections only when needed. The first call to pool.get().await triggers the manager to open a connection. Subsequent calls reuse idle connections from the pool.

The pool returns a guard object, not a raw connection. This guard holds the connection and tracks the borrow. When the guard goes out of scope, the Drop implementation returns the connection to the pool. You never call pool.put(). The borrow checker and Drop trait handle the return automatically. This prevents leaks. If you forget to return a connection, the compiler forces you to acknowledge the drop or the connection stays checked out until the scope ends.

Pool implements Clone. Cloning the pool is cheap. It increments an internal reference count. It does not copy the connections or the manager. You can clone the pool handle and pass it to tokio::spawn, store it in a struct, or share it across threads. The pool handles internal synchronization.

Community convention: never wrap a pool in Arc. Pool already contains an Arc internally. Wrapping it in Arc adds redundant noise and hurts readability. Clone the pool directly.

Trust the Drop implementation. Returning the connection is automatic; leaking it is a logic error, not a syntax error.

Tuning and recycling

Pools require configuration. The default settings work for development but fail in production. You must tune the size and timeouts based on your workload.

use deadpool::Runtime;
use deadpool_diesel::postgres::{Manager, Pool};
use std::time::Duration;

fn build_pool(url: &str) -> Pool {
    let manager = Manager::new(url, Runtime::Tokio1);

    Pool::builder(manager)
        // Max size limits concurrent connections.
        // Set this based on DB limits and CPU cores.
        .max_size(32)
        // Timeout configures how long to wait for a connection.
        // Prevents hanging forever if the pool is exhausted.
        .timeout(
            deadpool::Timeouts::default()
                .wait(Some(Duration::from_secs(5)))
        )
        .build()
        .unwrap()
}

The max_size parameter is critical. It caps the number of simultaneous connections to the database. If you set this too high, you overwhelm the database. If you set it too low, your application spends time waiting for connections. A common starting point is CPU cores * 2 or CPU cores * 4, but you must measure your specific workload. Database connection limits and query patterns dictate the optimal size.

The pool also handles recycling. Before handing out a connection, the pool can run a health check. If the connection is dead, the pool drops it and creates a new one. This happens transparently. You get a working connection or an error. You never get a stale socket.

Tune the size based on load, not guesswork.

Realistic usage in an async handler

In a web server, you share the pool via dependency injection or a state struct. Here is how a handler uses the pool.

use deadpool_diesel::postgres::Pool;

// Handler receives a cloned pool handle.
// The pool is Send + Sync, so it works across tasks.
async fn get_user(pool: Pool, id: i32) -> Result<User, AppError> {
    // Get a connection.
    // Handle errors gracefully. Do not unwrap in production.
    let conn = pool.get().await.map_err(|e| AppError::Pool(e))?;

    // Run the query.
    // The connection is borrowed mutably by the query.
    let user = diesel::query_diesel!(User, conn, "SELECT * FROM users WHERE id = $1", id)
        .await
        .map_err(|e| AppError::Db(e))?;

    // Connection returns to pool automatically when `conn` drops.
    Ok(user)
}

If the pool is exhausted, pool.get().await returns an error. You must handle this. Ignoring the error with unwrap() turns a load spike into a server crash. Return a 503 Service Unavailable or retry with backoff. Treat pool errors like network errors. The pool is a resource, not a guarantee.

If you try to extract the raw connection from the pool guard, the compiler rejects you with E0507 (cannot move out of borrowed content). The guard owns the connection for the duration of the borrow. You cannot take the connection out of the pool without breaking the pool's invariants.

Handle pool errors like network errors. The pool is a resource, not a guarantee.

Pitfalls and compiler errors

Connection pools introduce new failure modes. Understanding these prevents subtle bugs.

Pool exhaustion happens when all connections are in use and new requests arrive. The pool blocks until a connection is returned or the timeout expires. If your queries take too long, the pool fills up. This creates a cascade failure. Requests back up, memory grows, and the server becomes unresponsive. Monitor pool utilization. If the pool is frequently full, increase the size or optimize your queries.

Blocking the executor is a common mistake. If you use a synchronous driver with an async pool, or if you block inside a handler while holding a connection, you starve the executor. deadpool is async-first. Use async drivers. Never call block_on inside an async context.

If you try to pass a pool to a function expecting a &mut connection, the compiler rejects you with E0308 (mismatched types). The pool gives you a guard object, not a raw reference. The guard dereferences to the connection, but the types are distinct. This distinction enforces the borrow rules.

If you hold a connection across an .await point and the connection is not Send, the compiler rejects you with E0277 (trait bound not satisfied). Most database connections are Send. If you see this error, check your driver configuration.

Pick the pool that matches your runtime. Mixing sync pools with async executors creates deadlocks.

Choosing a pool library

Rust has several pool libraries. Pick the one that fits your stack.

Use deadpool when you need an async-first pool with modern ergonomics and active maintenance. It supports tokio, async-std, and smol out of the box. It has a consistent API across databases. It is the community favorite for new projects.

Use bb8 when you are maintaining legacy code that already depends on it or when you need a sync pool for a blocking runtime. bb8 is older and less actively developed than deadpool. It works, but deadpool offers better features.

Use r2d2 when you are writing synchronous code and need a battle-tested pool that doesn't require an async runtime. r2d2 is the standard for sync Rust. It is stable and reliable. It does not support async natively.

Reach for a single connection when you are building a CLI tool, a migration script, or a prototype where connection overhead is negligible. Pools add complexity. If you only make a few queries, a direct connection is simpler.

Match the pool to the runtime. Mixing sync pools with async executors creates deadlocks.

Where to go next