How to fix database connection pool exhaustion (pool at 100%, HTTP 503)?
sre@prod-bastion ~ $ psql -f ~/oncall/pool.sqlPOOL STATE COUNTapp-01..03 active 150 (50/50 each)idle in txn leaked? 0 Mean query 42s was 50msHTTP 503s 2,891 # Nothing leaked. Every connection is busy# and hold time rose 840x. The pool is# reporting the query, not its own size. sre@prod-bastion ~ $ CREATE INDEX CONCURRENTLY idx_users
The pool is full, every request is timing out, and the obvious lever is right there in the config file. Raising it is almost always the wrong move, and the reason is arithmetic rather than opinion.
What does connection pool exhaustion actually mean?
It means every connection the pool owns is currently executing a statement, and a new request has nowhere to go.
That is worth stating precisely, because it rules out the thing most teams check first. An exhausted pool is not the same as a leaked pool. In a leak, connections sit idle or idle in transaction while the application forgets to return them. In exhaustion, they are all genuinely busy. Same symptom at the application, opposite causes, and the difference is one query away.
-- The distinction that decides everything. Active means the-- database is working; idle in transaction means your code-- borrowed a connection and wandered off.SELECT count(*), state, wait_event_type, wait_eventFROM pg_stat_activityWHERE datname = current_database()GROUP BY state, wait_event_type, wait_eventORDER BY count DESC; count | state | wait_event_type | wait_event-------+---------------------+-----------------+------------ 150 | active | Client | ClientRead 0 | idle in transaction | | One hundred and fifty connections, all active, none idle in transaction. Nothing is leaking. The queries got slow.
Why is the pool a symptom rather than a cause?
Because pool occupancy is not a free variable. It is determined by two things you do not set directly.
Concurrency in use equals arrival rate multiplied by mean hold time. This is Little's law, and it holds whether or not you are thinking about it. Traffic sets the arrival rate. The database sets the hold time. The pool size only sets the point at which the consequence becomes visible.
arrival rate x mean hold time = connections in use before migration 220 req/s x 0.050 s = 11.0after migration 220 req/s x 42.000 s = 9,240 Traffic never moved. Hold time did, by 840x. A pool of 50was never going to hold 9,240, and neither is a pool of 500. This is why the config lever fails. Doubling the pool doubles the number of connections you can hold open while they all wait the same 42 seconds. You have not added capacity, you have added queue depth, and you have moved it from a place your application controls into the database's connection slots.
How do you confirm which query is holding the connections?
Rank statements by total time, not by call count or by worst single call. The statement that broke you is usually neither the slowest nor the most frequent.
-- Total time is calls x mean, which is what actually consumes-- pool capacity. A 42-second query called twice matters less-- than a 900ms query called four thousand times.SELECT calls, round(mean_exec_time::numeric, 2) AS mean_ms, round(total_exec_time::numeric / 1000, 1) AS total_s, left(query, 60) AS statementFROM pg_stat_statementsORDER BY total_exec_time DESCLIMIT 5; Then look at what the longest-held connections are actually running, right now, with their age.
-- Anything here older than a few seconds is a candidate. Sort-- by duration, not by backend start, or you get the connections-- that have been open longest rather than busy longest.SELECT pid, now() - query_start AS duration, state, wait_event, left(query, 60) AS statementFROM pg_stat_activityWHERE state = 'active' AND now() - query_start > interval '1 second'ORDER BY duration DESC; Why does raising max connections make it worse?
Because the queue does not disappear, it relocates, and the new location has fewer safety features.
Every PostgreSQL connection is a backend process with its own memory allocation, and work_mem is allocated per sort or hash node, not per connection. Several hundred connections all running the same expensive plan can multiply memory demand far past what the instance has, at which point you trade an application-side 503 for an out-of-memory event on the primary. The 503 is recoverable in seconds. The primary falling over is not.
There is also a scheduling cost. More concurrent backends against the same saturated resource means more context switching and more lock contention, so mean hold time rises further, which raises occupancy again. The lever you pulled to relieve pressure is itself a source of pressure.
How do you fix connection pool exhaustion?
Fix the hold time. The pool recovers on its own once you do, usually within a couple of minutes, because the connections were never lost.
If a migration introduced a plan regression, the fix is the index it dropped or never created, applied without taking a write lock.
# CONCURRENTLY keeps writes flowing while the index builds. It# takes longer and cannot run inside a transaction block, which# is exactly the trade you want during an incident.psql -U postgres -d userdb -c " CREATE INDEX CONCURRENTLY idx_users_region_status ON users(region, status, last_login);" # Confirm the planner actually adopted it before declaring the# incident closed. A built index the planner ignores is not a fix.psql -U postgres -d userdb -c " EXPLAIN ANALYZE SELECT * FROM users WHERE region = 'us-east' AND status = 'active' ORDER BY last_login;" Only after hold time is back to normal is pool size worth revisiting, and then as capacity planning rather than incident response. Size it from measured arrival rate and measured hold time, and put a connection pooler in front of the database if the number you arrive at is large. Raising the ceiling to survive a regression is not sizing, it is deferring.
What does a real connection pool exhaustion incident look like?
Sherlocks AI investigated one on a three-node user-service cluster at 10:22 IST on 29 March 2026. HikariCP reported 50 of 50 active connections on app-server-01, 02, and 03 simultaneously, 150 in total, and every user was getting an HTTP 503. The window produced 2,891 of them.
The first hypothesis was a connection leak, and it was ruled out from pg_stat_activity: all 150 connections were in active state executing queries, none idle in transaction. The real cause was upstream of the pool entirely. A Flyway migration had added a WHERE clause column without a matching index, turning a 50 ms query into a 42-second sequential scan. Creating the composite index on (region, status, last_login) brought it to roughly 12 ms, and the pools drained within two minutes without anyone touching maximumPoolSize. The full writeup is in the connection pool exhaustion investigation.
The migration is the thing to internalise. The pool was the loudest signal and the last link in the chain, which is why the slow queries after a schema migration guide is usually the one you need next. When hold time rises for a storage reason rather than a plan reason, database storage throttling covers that path. More worked examples are on the database examples page, and the engine's own view of connection states is in the PostgreSQL monitoring documentation.
Connection pool exhaustion FAQ
Should I increase max pool size when the pool exhausts? Usually no. An exhausted pool almost always means hold time rose. Raising the ceiling moves the same queue into the database, where a memory blowout is a worse outcome than a 503.
How do I tell exhaustion from a connection leak?
Connection state. A leak shows connections idle or idle in transaction. Exhaustion shows them all active, genuinely running statements.
Why did the pool fill when traffic did not change? Because occupancy is arrival rate times hold time. Hold time is set by the database, so a plan regression fills the pool at completely flat traffic.
How long should the pool take to recover after a fix? Once hold time drops, within a couple of minutes. Connections were never lost, only held, so they return as soon as statements finish.
Does a connection pooler like PgBouncer solve this? It helps with connection count and setup cost, not with hold time. A pooler in front of a regressed plan still queues, just somewhere else.
Can I just add a statement timeout? It caps the damage and is worth having, but it converts a slow request into a failed one. It is a blast-radius control, not a fix for the plan.