PostgreSQL Server-Side Connection Diagnostics

This guide is part of Connection Pool Observability. Client-side pool metrics answer how many connections a service holds and how long callers wait for one. They cannot answer what those connections are doing, and that is the question that separates a pool that is too small from one whose connections are held by application code that is not querying. Only the database can answer it.

This guide covers the server-side views that make that determination: pg_stat_activity and its state column, the wait-event taxonomy that explains why an active backend is not progressing, and the connection-limit parameters that bound everything. It is written to be usable during an incident, with the distinguishing queries stated explicitly.

Key operational takeaways:

  • state in pg_stat_activity separates active, idle, and idle in transaction — and that distinction is the single most valuable connection diagnostic available.
  • idle in transaction accumulating is the definitive signature of connections held by application code rather than by database work.
  • wait_event_type explains why an active backend is not making progress: a lock, an I/O wait, or a client the server is waiting on.
  • application_name is what makes any of this attributable to a service, and it costs one connection-string parameter to set.
  • max_connections cannot be raised without regard to memory: every backend is a process with its own allocation.
Backend states and what they imply Active means the backend is executing, idle means the connection is pooled and unused, and idle in transaction means the client holds an open transaction while doing something else entirely. active executing a statement right now the connection is earning its keep many active + waiters in the pool → genuine undersizing check wait_event_type next idle connected, no transaction open sitting in a client-side pool normal — this is what a pool looks like → only a problem in bulk many idle at trough = oversized pools idle in transaction transaction open, no statement running the client is doing something else holds locks and blocks vacuum → application hold or leak no pool setting fixes this The left and right columns look identical from the client: both are connections checked out of the pool Only the server distinguishes them, and they need completely different remedies
Three states, three meanings. A checked-out connection is indistinguishable from the client whether it is executing or merely holding a transaction — the database is the only place that difference is visible.

Foundational Mechanics

pg_stat_activity has one row per backend process, and the columns that matter for connection diagnosis are state, wait_event_type, wait_event, state_change, xact_start, query_start, and application_name.

state is the primary discriminator, and its three interesting values were described above. The one detail worth adding is that state_change gives the timestamp of the last transition, so now() - state_change on an idle in transaction row is how long that transaction has been open with nothing running — the single most useful derived value in the view.

wait_event_type explains a backend that is active but not progressing. Its values group into a small number of meaningful categories: Lock means blocked on a database lock, LWLock on an internal shared-memory lock, IO on storage, Client on the client itself, and IPC on another backend. A backend active with wait_event_type = 'Client' and wait_event = 'ClientRead' is the server waiting for the application to send something — which is what an idle connection in extended-query protocol frequently looks like.

xact_start versus query_start distinguishes a long transaction from a long query. A transaction open for four minutes whose current query started two seconds ago is an application holding a transaction across other work; a query running for four minutes is a slow query. They call for entirely different responses, and the two timestamps are what separates them.

-- The single most useful connection-diagnosis query.
SELECT application_name,
       state,
       count(*)                                            AS backends,
       max(now() - state_change)                           AS longest_in_state,
       max(now() - xact_start) FILTER (WHERE xact_start IS NOT NULL) AS longest_txn
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY application_name, state
ORDER BY backends DESC;

Running that during an incident answers, in one result set, which service holds the connections, what those connections are doing, and whether anything has been holding a transaction open for an unreasonable time. The backend_type filter excludes autovacuum workers and background writers, which otherwise clutter the result.

Operational Boundary: Connection-state diagnosis is covered here. Query plan analysis, index selection and vacuum tuning are database-performance concerns that this view points toward rather than resolves.

Precision Sizing & The Server-Side Limits

Three parameters bound how many connections PostgreSQL will accept, and their interaction is more subtle than the first one suggests.

max_connections is the total. It is a hard limit, it requires a restart to change, and it cannot be raised freely because every backend is a separate operating-system process with its own memory allocation — work memory, temporary buffers, catalogue caches. A rough figure is 5–10 MB per idle backend and considerably more under load, so raising max_connections from 200 to 2,000 on an unchanged instance trades connection rejections for swapping.

superuser_reserved_connections carves out slots that only superusers may use, defaulting to 3. Its purpose is to guarantee that an administrator can still connect when the database is full — which is exactly the moment it matters. The effective limit for ordinary users is max_connections - superuser_reserved_connections, and the FATAL: remaining connection slots are reserved message means that boundary, not the total, has been reached.

reserved_connections (PostgreSQL 16 and later) adds an intermediate tier for roles granted pg_use_reserved_connections, which lets monitoring agents and health checks keep working when application connections are exhausted. Assigning that role to the monitoring user is a cheap way to keep observability alive during a connection incident.

Parameter Default Purpose Raising It Costs
max_connections 100 Total backends accepted Memory per backend, restart required
superuser_reserved_connections 3 Emergency administrator access Slots taken from ordinary users
reserved_connections 0 Monitoring and health-check access Same, but worth it
idle_in_transaction_session_timeout 0 (off) Kills long-idle transactions Application errors instead of held locks
idle_session_timeout 0 (off) Kills long-idle connections Client must reconnect

The last two are worth enabling deliberately. idle_in_transaction_session_timeout set to something like 60 seconds converts a leaked transaction from an indefinite lock holder into a prompt application error — which is worse for that one request and much better for everything else on the database. idle_session_timeout does the same for connections without an open transaction, and is particularly valuable when serverless environments are reclaimed without closing their connections.

Partitioning the connection limit The total connection limit is divided into ordinary application slots, a tier reserved for monitoring roles, and superuser slots that guarantee administrative access when the database is full. max_connections = 500 ordinary application connections — 487 10 3 what your pools may consume reserved_connections — monitoring roles superuser_reserved_connections without a monitoring reservation when the database fills, the exporter cannot connect so metrics go blank at exactly the moment you most need them with one grant pg_use_reserved_connections to the monitoring role and the exporter keeps working through the incident
Reserving slots for monitoring is the difference between having telemetry during a connection incident and losing it at precisely the moment it becomes necessary.

Diagnostics & Telemetry

Three queries cover the great majority of connection investigations, and they are worth having saved rather than reconstructed under pressure.

Who holds the connections, and what are they doing — the grouped query above. Run it first, always.

What is blocking what — when backends are active with wait_event_type = 'Lock', the blocking chain is what matters:

SELECT blocked.pid          AS blocked_pid,
       blocked.application_name AS blocked_app,
       blocking.pid         AS blocking_pid,
       blocking.application_name AS blocking_app,
       blocking.state       AS blocking_state,
       now() - blocking.xact_start AS blocking_txn_age,
       left(blocked.query, 60) AS blocked_query
FROM pg_stat_activity blocked
JOIN LATERAL unnest(pg_blocking_pids(blocked.pid)) AS bp(pid) ON true
JOIN pg_stat_activity blocking ON blocking.pid = bp.pid
WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0;

The revealing column is blocking_state. A blocking backend in idle in transaction is holding a lock while doing nothing — the classic case where a connection-holding bug becomes a database-wide problem, and where killing the blocker is often the right immediate action.

What is idle in transaction, and for how long — the leak hunt:

SELECT pid, application_name, now() - xact_start AS txn_age,
       now() - state_change AS idle_for, left(query, 80) AS last_query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
  AND now() - state_change > interval '30 seconds'
ORDER BY state_change;

The query column here shows the last statement executed, not a running one, which is exactly what identifies the code path that opened the transaction and then went elsewhere. It is the server-side equivalent of the stack trace that leak detection provides on the client.

Continuous collection matters as much as ad-hoc querying. Exporting backend counts grouped by application_name and state on a short interval turns all of the above into a graph that can be consulted after the fact — and the state breakdown over time is what distinguishes a leak from load, exactly as it does client-side.

Making It Continuous

Ad-hoc queries answer questions during an incident. Continuous collection answers them afterwards, and more importantly reveals the slow trends — a leak accumulating over days, a pool that never shrinks, a service whose share of the budget has crept upward since the last release.

Two exporters cover it. postgres_exporter exposes pg_stat_activity aggregated by state and database out of the box, and its pg_stat_activity_count series with a state label is the single most useful thing it publishes for connection work. Adding a custom query gives the per-service breakdown that the built-in metric omits:

# postgres_exporter custom queries — per-service state breakdown
pg_connections_by_app:
  query: |
    SELECT coalesce(nullif(application_name, ''), 'unknown') AS application,
           state,
           count(*) AS backends,
           coalesce(extract(epoch FROM max(now() - state_change)), 0) AS max_state_seconds
    FROM pg_stat_activity
    WHERE backend_type = 'client backend'
    GROUP BY 1, 2
  metrics:
    - application: { usage: "LABEL" }
    - state:       { usage: "LABEL" }
    - backends:    { usage: "GAUGE", description: "Backends by application and state" }
    - max_state_seconds: { usage: "GAUGE", description: "Longest time in current state" }

Three alerts follow directly from those two series and are worth having on any shared database.

Total backends above 80% of the effective limit, sustained for five minutes. This is the capacity alert, and it should route to whoever owns the budget rather than to whichever service happens to be largest.

Any backend idle in transaction for longer than 60 seconds. This is the leak and lock-holding alert, and it should name the application_name so it routes to the owning team. It fires long before that transaction causes a visible problem, which is the point.

Backends for a single application_name above its allocated share. This catches a service that has silently grown past its allowance — through a replica-count change, a new worker tier, or an accidental second pool — before the growth exhausts the database.

The max_state_seconds column makes the second alert possible without a separate query, which is why it is worth including even though it looks redundant next to the count. A count of ten idle in transaction backends is unremarkable if each has been in that state for 50 ms and alarming if one has been there for ten minutes.

Operational Boundary: Collection and alerting on connection state are covered here. Alert routing, escalation policy and ownership mapping are operational-governance decisions.

Integration & Proxy Compatibility

A connection proxy changes what these views show, in ways worth anticipating.

Behind PgBouncer in transaction mode, pg_stat_activity shows the proxy’s server connections rather than the application’s client sessions. application_name reflects whatever the proxy passes through — which, unless application_name_add_host is enabled, is often the same value for every backend, collapsing the attribution these queries depend on. The backends also churn between clients, so a PID observed in one query may be serving a different service by the next.

The practical consequence is that server-side diagnosis behind a proxy must be paired with the proxy’s own statistics. pg_stat_activity answers what the database is doing; SHOW POOLS answers who is waiting for it. Neither alone is sufficient, which is covered in PgBouncer Metrics Monitoring.

On RDS Proxy the same applies with less visibility, since the proxy’s internals are not directly inspectable — CloudWatch metrics stand in for SHOW POOLS. The idle in transaction diagnosis remains valid and important: a pinned or long-held transaction behind RDS Proxy is exactly what drives the pinned-connection metric up.

State breakdown over time Stacking backends by state over a daily cycle shows active tracking traffic, idle following the pool's floor, and idle in transaction remaining near zero in a healthy service and ratcheting upward when a leak is present. 0 backends active — tracks traffic idle — the pool floor idle in transaction — ratcheting a full daily cycle → The red line rising through the overnight trough is a leak; nothing about the other two series would reveal it
Stacked by state over a full cycle, the three series separate cleanly. A leak is visible as the `idle in transaction` line failing to return to zero at trough.

Common Failure Patterns & Remediation

Symptom Root Cause Exact Fix Validation
remaining connection slots are reserved Ordinary slots exhausted Reduce pool totals; do not raise the limit first Backend count below the effective limit
Many backends idle in transaction Application holds transactions across other work Narrow the transaction boundary State breakdown shifts to active and idle
Backends active with Lock waits A blocker holding a lock, often idle in transaction Identify with pg_blocking_pids; fix or terminate Blocking chain empties
High backend count at trough Pools sized without an idle floor Enable idle reaping in the client pools Trough count falls to the idle minimums
Monitoring blind during an incident No reserved slots for the exporter Grant pg_use_reserved_connections Metrics continue through a full database
application_name all identical Not set, or collapsed by a proxy Set per service; enable proxy host tagging Grouped query separates services
Raising max_connections made things worse Memory per backend exceeded available RAM Revert; reduce demand or add a proxy instead Swap usage returns to zero

Frequently Asked Questions

Is it safe to terminate a backend found in idle in transaction?
pg_cancel_backend cancels the current query and is safe. pg_terminate_backend closes the connection, which the client will see as a connection error — acceptable during an incident when the transaction is holding locks that block everything else, but it is a mitigation rather than a fix. The durable answer is idle_in_transaction_session_timeout.
Why does pg_stat_activity show fewer connections than my pools should hold?
Most often because pools open lazily and have not grown to their configured maximum, which is normal. If the number is persistently far below the configured total, check whether a proxy sits in the path — in that case the view shows the proxy’s backends rather than the application’s sessions.
What is a healthy ratio of idle to active?
There is no universal figure, because it depends entirely on the pool’s idle floor. What matters is the trend: idle should fall during a genuine trough if idle reaping is configured, and idle in transaction should be near zero at all times regardless of load.
Does track_activity_query_size need raising?
It defaults to 1024 bytes, which truncates the query column for anything longer. For leak hunting that is usually enough — the beginning of the statement identifies it — but for services with very long generated queries, raising it to 4096 makes the diagnosis considerably easier at a small memory cost per backend.
How does this differ on MySQL?
The equivalent view is performance_schema.threads joined with processlist, and the state vocabulary differs, but the underlying distinction is the same: a connection can be executing, idle, or holding an open transaction, and only the last is invisible from the client while being the most damaging.
Can a non-superuser see other sessions’ queries?
Not by default — ordinary roles see their own rows in full and other rows with query and several other columns nulled. Granting the pg_read_all_stats role to the monitoring and on-call users is what makes the diagnostic queries on this page usable by the people who need them, and it grants no ability to modify anything.
Does querying pg_stat_activity itself consume a connection?
Yes, one — which is exactly why the monitoring role should hold a reserved slot. During a connection incident the query you most need to run is the one most likely to be refused, and a reservation is the only thing that prevents that.
Should log_connections and log_disconnections be enabled?
On a database with connection problems, temporarily yes. Together they give the exact establishment and teardown rate, which is what distinguishes churn from steady reuse and identifies which client is reconnecting. They are verbose enough that leaving them on permanently is usually impractical, but during an investigation they answer a question no view does.
What does a wait_event of ClientRead mean on a busy backend?
The server has finished its work and is waiting for the client to send the next message. In the extended query protocol that is normal between statements of a transaction, and a backend accumulating time there is usually an application holding a transaction open — the same finding as idle in transaction, seen from a slightly different angle.