Database Connection Pooling for Backend Services¶
A backend can have plenty of application workers and still stop making progress because every request is waiting for a database connection. The opposite failure is also common: every worker opens a large pool, the database accepts hundreds of sessions, and query latency gets worse as more sessions compete for CPU, memory, locks, and storage.
Connection pooling is therefore a capacity and queueing decision, not just a way to avoid repeating a TCP handshake. The design question is: how many database operations should be allowed to run concurrently, across every process and deployment replica, before new work waits or is rejected?
This chapter uses PostgreSQL for concrete examples, with Python, Go, Java, PgBouncer, and AWS RDS Proxy as implementation references.
1. Understand what each pool is limiting¶
An application connection pool keeps a set of database connections available for reuse. A request checks out a connection, performs database work, and returns it. The pool avoids repeatedly paying connection establishment and authentication costs, and it places a finite limit on the number of connections that one application process can hold at once.
A pool is not a query cache or transaction manager. It limits connections held by the process; it does not make a slow query faster.
There are two distinct layers:
- Application pool: limits connections held by one application process. It may wait for a free connection or time out when the pool is exhausted.
- Database pooler or proxy: accepts client connections from applications and manages a smaller set of server-side database connections. It may reuse a server connection between different clients, depending on its pooling mode and the session state those clients use.
A process-local pool does not create a global limit. If a deployment has multiple replicas and worker processes, every process can create its own pool. The database sees the aggregate, not the per-process configuration.
2. Budget connections across the whole deployment¶
Start with the database's connection budget, then divide it among workloads. Do not start by copying a library's default pool size.
For PostgreSQL, max_connections is the upper bound on concurrent server connections. It is a startup setting, and PostgreSQL allocates some resources based directly on it; raising it is not free. PostgreSQL 18 also has reserved_connections and superuser_reserved_connections so privileged operators can retain access when ordinary slots are exhausted. Those reserved slots are not spare application capacity.
A practical budget should account for:
- Web and API processes at the maximum autoscaled replica count.
- Worker processes inside each replica, not just the number of containers.
- Rolling-deployment surge, blue/green overlap, and old processes draining.
- Background workers, scheduled jobs, migrations, reporting, monitoring, and replication connections.
- Emergency operator access and a safety margin for traffic variation.
Use the maximum possible pool size, including overflow, in the calculation:
application connection ceiling
= max replicas
× worker processes per replica
× max connections per process
Consider a database configured for 250 connections. Suppose the team reserves 20 slots for operational access and 30 for replication, migrations, monitoring, and other services. It also keeps 25 connections of headroom rather than planning to run at the limit. The application budget is then 175 connections.
If the service can scale to 10 replicas, each with 3 worker processes, a rolling deployment can temporarily add 2 replicas. That is 36 processes at peak. A maximum of 4 connections per process consumes 144 connections; a maximum of 5 consumes 180 and breaks the budget. The correct setting is based on the peak topology, not the ordinary state of 10 pods.
Treat this as a ceiling, not a target. Allocate explicit budgets to every service, one-off job, admin tool, and both sides of any proxy.
PostgreSQL's documented default for max_connections is typically 100, but managed services can choose different values and may impose their own limits. Read the running configuration and provider limits rather than assuming the default.
3. Size the pool from measured database capacity¶
A larger pool is not automatically faster. Every active PostgreSQL connection is a server process with memory and scheduling costs. Too many concurrently active queries can increase context switching, lock contention, cache churn, and storage queueing. The database may complete less useful work even while the application has more connections available.
When every connection is checked out, callers queue or time out. A bounded wait protects the database; an unbounded wait consumes request workers and can outlive the caller's deadline.
Use production measurements and a representative load test to find the point where increasing active database concurrency stops improving throughput or starts harming tail latency. Track both throughput and p95/p99 latency. Test realistic query mixes, transaction lengths, indexes, storage, and background maintenance; a benchmark made of one short SELECT is not a capacity model.
Little's Law can help estimate average in-flight work:
average checked-out connections
≈ database operations per second
× average time each operation holds a connection
For example, 600 operations per second with an average connection-hold time of 20 milliseconds implies about 12 connections checked out on average. That does not mean a pool of 12 is sufficient. Bursts, long-tail queries, transactions, lock waits, and deployment overlap still matter. Use the estimate to sanity-check measurements, then choose a bounded pool and test the tail of the workload.
Set an acquisition timeout that fits inside the request's remaining deadline. A request that has already spent most of its time waiting for a connection should not begin a database operation that cannot finish within its time budget. Avoid infinite waits and avoid creating an unlimited overflow pool: both turn a local bottleneck into a wider outage.
4. Keep connection leases short and predictable¶
A connection is a scarce lease. Check it out only when database work is ready to begin, and return it as soon as that work is complete.
- Do not hold a transaction open while calling an HTTP service, publishing to a broker, waiting for user input, or doing expensive CPU work.
- Close result sets and cursors promptly. In Go, an unclosed
Rowscan keep its connection occupied. - Commit or roll back every transaction. An exception path must not leave the connection in an open transaction.
- Do not share one checked-out connection concurrently across unrelated goroutines or tasks. Share the pool handle; let each transaction own its connection.
- Keep one pool or engine per process and create it during process startup. Do not create a new engine for every request.
- Treat session state as part of the connection's lifetime. A pooled connection can be reused by another request, so do not assume session variables, temporary tables, advisory locks, or listeners disappear merely because application code returned the connection.
Most mature pools reset transactional state on return. SQLAlchemy, for example, rolls back a connection when it is returned so uncommitted work and transaction-held locks do not leak into the next checkout. A rollback is not a universal reset of every session feature. If code changes session-level settings or creates session-scoped objects, understand the pool's reset behavior and the database's semantics.
5. Choose the right pooling layer¶
An application pool is usually the first layer to configure. Add a database pooler when connection establishment, connection count, or bursty client concurrency is a measured problem—not simply because a proxy is available.
| Option | What it reuses | Main trade-off |
|---|---|---|
| Application pool | Connections within one process | Simple and close to the code, but aggregate connections multiply across processes |
| PgBouncer session pooling | A PostgreSQL server connection for the life of a client session | Broad session compatibility, but long-lived clients hold server connections |
| PgBouncer transaction pooling | A server connection only for the duration of a transaction | Higher multiplexing, but session-dependent behavior must be removed or redesigned |
| Managed proxy such as RDS Proxy | Server connections behind a managed endpoint | Less connection-management work, but proxy limits, pinning, and provider behavior still matter |
PgBouncer's session mode returns a server connection when the client disconnects. In transaction mode it returns the server connection when the transaction ends. statement mode is more restrictive: multi-statement transactions are not allowed.
Transaction pooling is not a transparent switch. Features that depend on a persistent server session—such as SET state, LISTEN, session advisory locks, holdable cursors, and SQL-level PREPARE/DEALLOCATE—may not work as the application expects. PgBouncer's current documentation does support protocol-level named prepared statements in transaction and statement modes when max_prepared_statements is non-zero; do not confuse that with SQL-level PREPARE. Check the compatibility table for the exact PgBouncer version you deploy.
AWS RDS Proxy also multiplexes client work onto database connections, generally at transaction boundaries. Session state can pin a client to one database connection for the rest of the session, reducing reuse. For PostgreSQL, operations such as SET, session-level prepared-statement commands, temporary objects, cursors, and session advisory locks can cause pinning. Monitor the provider's pinning and connection-borrow metrics; a proxy does not guarantee that every client connection can share a backend.
A proxy adds a queue and a failure boundary. Set its server-connection cap and client borrow timeout deliberately; a large client limit does not increase database capacity.
6. Configure bounded pools in common backends¶
The following examples show the important controls. Values are illustrative; derive them from the shared database budget and measured workload.
Python with SQLAlchemy¶
from sqlalchemy.ext.asyncio import create_async_engine
engine = create_async_engine(
DATABASE_URL,
pool_size=4,
max_overflow=0,
pool_timeout=1.5,
pool_pre_ping=True,
)
The engine owns the pool; create it once per worker process and dispose of it during application shutdown. pool_size is the persistent pool capacity, while max_overflow adds temporary concurrent connections. Setting overflow to zero makes the per-process ceiling easier to budget. pool_timeout bounds how long a checkout waits. pool_pre_ping checks a connection when it is checked out and can replace a stale idle connection, but it cannot rescue a query that fails after checkout or replay an in-flight transaction.
Go with database/sql¶
import (
"context"
"database/sql"
"time"
)
func openDB(ctx context.Context, dsn string) (*sql.DB, error) {
// Register a PostgreSQL driver that uses the "postgres" name.
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(4)
db.SetMaxIdleConns(2)
db.SetConnMaxIdleTime(5 * time.Minute)
db.SetConnMaxLifetime(30 * time.Minute)
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, err
}
return db, nil
}
A *sql.DB is a concurrency-safe handle that manages a pool; it is not one physical connection. SetMaxOpenConns bounds open connections, and calls wait when that limit is reached. The Go documentation warns that this wait behaves like a semaphore and can contribute to deadlocks if code holds one connection while waiting for another. Use transaction APIs for work that must stay on one connection, and inspect db.Stats() for InUse, Idle, WaitCount, and WaitDuration.
Java with HikariCP¶
Set maximumPoolSize from the process budget; it includes both idle and in-use connections. connectionTimeout bounds how long a caller waits for a connection. Do not copy the library's default size into every replica without multiplying it by the number of processes. Configure connection lifetime below infrastructure-imposed limits when required, with enough variation to avoid every connection expiring together.
7. Diagnose where the time is going¶
A high connection count alone does not tell you whether the pool is healthy. Instrument the application pool and the database separately.
Application metrics should include:
- Open, idle, and checked-out connections, alongside the configured maximum.
- Number of threads or tasks waiting for a connection.
- Connection-acquisition wait time as a histogram, plus timeout and rejection counts.
- Connection creation, validation, invalidation, and failure counts.
- Time a connection remains checked out, and transaction duration.
For PostgreSQL, pg_stat_activity shows one row per server process. A useful first view groups sessions by application, state, and wait event:
SELECT
datname,
usename,
application_name,
state,
wait_event_type,
count(*) AS connections
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY datname, usename, application_name, state, wait_event_type
ORDER BY connections DESC;
Investigate transactions that remain open without doing work:
SELECT
pid,
usename,
application_name,
now() - xact_start AS transaction_age,
wait_event_type,
wait_event,
left(query, 120) AS query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY xact_start;
Interpret the evidence carefully. A pool can be exhausted because queries are slow, transactions are long, locks are blocking progress, connections are leaked, or the pool is simply smaller than the workload needs. Low database CPU does not rule out a pool problem: sessions may be waiting on locks, network I/O, storage, or application code that holds a connection too long.
If PgBouncer is in use, inspect SHOW POOLS and SHOW STATS as well as PostgreSQL. A growing client wait queue with a saturated server pool points to a different limit than an application pool whose connections are all checked out.
8. Plan for failure and deployment changes¶
Pool exhaustion: set finite acquisition and request deadlines. Decide whether excess work should wait briefly, be rejected, or be routed to a durable worker. Do not let requests accumulate indefinitely while holding application workers.
Database slowdown: a pool cannot repair a slow query or lock convoy. Use query plans, lock diagnostics, and transaction-duration metrics to find the cause before raising the pool size.
Stale connections and failover: a health check on checkout can detect some dead idle connections. It cannot guarantee that a connection remains alive for the next statement. A connection can fail mid-query or mid-commit. Treat the transaction outcome as potentially unknown, and retry only a business operation whose idempotency contract makes that safe.
Rolling deployments and autoscaling: old and new replicas can overlap, and autoscaling can create a burst of fresh pools. Include maximum surge in the budget, drain old instances gracefully, and avoid eager creation of a full pool on every new process unless the database can absorb the connection storm.
Session pinning: a proxy may accept many client connections but multiplex far fewer of them than expected. Identify which session features cause pinning and remove unnecessary state before changing proxy limits.
Test exhaustion with a small pool, a slow query, and concurrent requests. Verify bounded checkout waits, cancellation cleanup, graceful shutdown, and recovery after a database restart. Repeat at maximum replicas and deployment surge.
9. Interview questions with reasoned answers¶
Q1. Why use a connection pool instead of opening a connection for every request?
A pool amortizes connection establishment and authentication, reuses established sessions, and caps concurrent connections from a process. Opening a connection per request adds setup load and can create connection storms. The pool still needs a deployment-wide budget because every process has its own pool.
Q2. How do you calculate the maximum number of connections a service can use?
Multiply the maximum replica count—including deployment surge—by worker processes per replica and the maximum connections per process. Add every other application and operational client that shares the database. Keep the total below the usable database budget with explicit headroom.
Q3. How do you choose a pool size?
Measure the database's throughput and tail latency under a representative workload while varying active concurrency. Estimate average in-flight work from request rate and connection-hold time, then validate burst behavior and long-running transactions. Choose a bounded size near the measured useful concurrency, not the largest value the database accepts.
Q4. What changes when PgBouncer uses transaction pooling?
A client keeps its logical connection, but the underlying PostgreSQL connection is assigned only for a transaction and can be reused afterward. Session state therefore cannot be assumed to persist. Audit temporary tables, session variables, advisory locks, cursors, listeners, and prepared statements against the deployed PgBouncer version.
Q5. The application pool is exhausted but PostgreSQL CPU is low. What do you investigate?
Check connection-hold time, long or idle transactions, lock waits, network waits, open result sets, and application code running while a connection is checked out. Compare pool wait time with database query time. Increasing the pool before finding the wait can make contention worse.
Q6. Does a connection health check make retries safe?
No. It can reject a stale idle connection before use, but a connection can fail during a query or after the server commits but before the client receives the result. Retry a complete operation only when its business effect is idempotent or can be reconciled.
Q7. Why not raise PostgreSQL max_connections whenever clients are rejected?
The setting affects server resource allocation, and more concurrent sessions can increase scheduling and contention costs. First identify whether the limit is caused by oversized per-process pools, leaked connections, long transactions, or an undersized database. Increase the server limit only after load testing shows that the database can benefit from the additional concurrency.
Q8. Which metrics distinguish a pool bottleneck from a database bottleneck?
Use application checkout wait, checked-out count, waiters, and hold time alongside PostgreSQL session states, wait events, query latency, and connection utilization. If the pool is full but the database has spare capacity and short queries, the pool may be too small. If sessions are active or waiting on locks and query latency is high, investigate the database workload first.
References¶
- PostgreSQL 18: Connections and Authentication
- PostgreSQL 18: The Cumulative Statistics System and
pg_stat_activity - PgBouncer: Configuration
- PgBouncer: Feature compatibility by pooling mode
- Go: Managing database connections
- SQLAlchemy 2.0: Connection Pooling
- HikariCP: Configuration
- HikariCP: About Pool Sizing
- Amazon RDS Proxy: Connection considerations
- Amazon RDS Proxy: Avoiding session pinning