A saturated pool has a signature that looks different from a slow database. When every connection is checked out, a new request does not get an error immediately. It waits for a connection to come back, up to whatever checkout timeout the client library enforces, and only then fails. That means the first observable symptom is latency that grows in steps, followed by a burst of acquisition errors rather than a spike in database CPU the Radview analysis of pool exhaustion.
The three-way distinction below is the one that saves the most time during an incident.
| Signal | Pool exhaustion | Slow queries | Lock contention |
|---|---|---|---|
| Database CPU | Often low or normal | High during query execution | Usually low |
| Pool checked out | Pinned at the cap | Rises with query duration | Rises, often unevenly |
| Pool wait time | Climbs, then timeouts | Climbs with query time | Spiky |
| pg_stat_activity | Many idle in transaction or long active | Many active, running real work | Active sessions waiting on locks |
| Typical error | Checkout timeout, pool empty | Statement timeout | Deadlock detected, lock wait |
| Recovery on its own | Only when load drops | No | Yes, after the blocker commits |
The cleanest discriminator is the ratio of wait time to execution time. If requests spend longer waiting for a connection than running their queries, the pool is the bottleneck and query tuning will not help until the queue clears the Webalert diagnosis guide. If execution time dominates and wait time is small, you have slow queries and should go to EXPLAIN instead.
Lock contention is the trickiest lookalike, because blocked sessions stay active and hold connections while doing no work. A session waiting on a lock looks identical to a busy session in a connection count metric. The Postgres documentation on explicit locking and the isolation chapter describe how a transaction can hold locks for its whole duration, and the default read committed level means a waiting writer blocks until the holder commits or rolls back the PostgreSQL isolation documentation. A transaction-level view is essential here: one long transaction is one held connection, no matter how idle it looks.



