Connection Pooling
Size and configure database connection pools so async services do not exhaust PostgreSQL max_connections or stall under load.
Recipe
Quick-reference recipe card - copy-paste ready.
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(20)
.min_connections(5)
.acquire_timeout(std::time::Duration::from_secs(5))
.connect(&database_url)
.await?;When to reach for this: Every server process talking to PostgreSQL, MySQL, or Redis should reuse a pool - never open per request.
Working Example
use sqlx::postgres::PgPoolOptions;
#[derive(Clone)]
struct AppState { pool: sqlx::PgPool }
async fn sizing_hint(workers: u32, db_max: u32, services: u32) -> u32 {
// rule of thumb: pool_max * service_replicas * workers <= db_max * 0.8
let per_instance = (db_max as f32 * 0.8 / services as f32).floor() as u32;
per_instance.min(20).max(2)
}
#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
let max = sizing_hint(4, 100, 3).await;
let pool = PgPoolOptions::new()
.max_connections(max)
.connect(&std::env::var("DATABASE_URL")?)
.await?;
let _state = AppState { pool };
Ok(())
}What this demonstrates:
max_connectionscaps concurrent DB sessions per process.min_connectionskeeps warm connections for latency.acquire_timeoutfails fast when pool is starved.- Sizing accounts for replica count and DB limit.
Deep Dive
How It Works
- Pool maintains idle connections and lends them to tasks.
- When pool is empty, tasks wait up to
acquire_timeout. - sqlx pool is built-in;
deadpool-postgresandbb8are alternatives for other drivers.
Sizing Formula
instances × pool_max ≤ database_max_connections × 0.7–0.8
Leave headroom for admin connections, migrations, and replicas.
Rust Notes
// deadpool-postgres (alternative)
let cfg = deadpool_postgres::Config::from_url(&url)?;
let pool = cfg.create_pool(None, tokio_postgres::NoTls)?;Gotchas
- Pool per handler - Creating new pool each request exhausts DB. Fix: One pool in
AppStateat startup. - max_connections too high - 100 Tokio tasks × 50 pool = disaster. Fix: Math from instance count and DB limit.
- Long idle transactions - Hold connection during slow external API call. Fix: Short transactions; release before I/O.
- No acquire timeout - Requests hang forever when pool starved. Fix: Set 3-10s timeout; return 503.
- PgBouncer mismatch - Transaction pooling mode breaks prepared statements. Fix: Use session mode or disable statement cache.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| sqlx built-in pool | sqlx stack | Need deadpool ecosystem features |
| PgBouncer | Many small services | Single monolith with few connections |
| Single connection | One-shot CLI scripts | HTTP servers |
FAQs
Pool per worker?
One pool per process - Tokio tasks share it, not one per thread.
Redis pooling?
deadpool-redis or multiplexed connections per redis crate guidance.
Health check?
SELECT 1 through pool for /ready endpoint.
Pool metrics?
Track acquire time, idle count, and timeouts with tracing or Prometheus.
Shutdown?
pool.close().await during graceful shutdown.
Read/write split?
Two pools pointing to primary and replica URLs.
Connection lifetime?
max_lifetime recycles old connections - set for cloud DB failovers.
Statement cache?
sqlx caches prepared statements per connection - watch memory on huge pools.
Load test pool?
Ramp until acquire timeouts appear - that is your real ceiling.
Serverless Rust?
Very small pools or external pooler (PgBouncer) required - cold starts matter.
Related
- Databases Basics - First pool
- sqlx - PgPoolOptions API
- Transactions & Isolation - Connection hold time
- Databases Best Practices - Pool rules
- Web Backends Best Practices - Service sizing
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.