PostgreSQLIV · Connections, Sessions and PoolingConnections
Reading pg_stat_activity as an instrument
What you'll learn
- Distinguish backend_start, xact_start, query_start and state_change
- Choose the correct age calculation for the question being asked
- Identify parallel worker processes and attribute them to their leader
- Write diagnostic queries that name the responsible application rather than a PID
Prerequisites
Practice
Verified against PostgreSQL 18.x · PostgreSQL (comparison targets) 17.11, 16.15 · PostgreSQL (support calendar) 18, 17, 16, 15, 14 supported · pgBackRest 2.59.1 · PgBouncer 1.25.2 · Patroni 4.1.5 · Ubuntu (host baseline) 26.04 LTS · 2026-08-27
pg_stat_activity has one row per backend and about thirty columns.
Six of them do nearly all the diagnostic work, and two of those are
routinely confused with each other.
The four timestamps
| Column | Set when | now() - column answers |
|---|---|---|
backend_start | The connection was established | How old is this connection? |
xact_start | The current transaction began | How old is this transaction? |
query_start | The current statement began | How long has this statement run? |
state_change | state last changed | How long has it been in this state? |
Choosing between them is the whole skill.
Transaction age (xact_start) is what predicts vacuum obstruction,
lock retention and wraparound risk. A transaction open for nineteen
hours is a problem regardless of whether it is currently executing
anything.
Statement age (query_start) is what identifies a slow query. In a
session that is idle in transaction, query_start refers to a
statement that already finished, so ageing it produces a number that
looks alarming and means nothing.
State age (state_change) is what tells you how long a session has
been idle in transaction — the number that actually matters for that
state.
Connection age (backend_start) is mostly useful for spotting
connections older than a configuration change, which the
backend-context lesson in Part III showed matters.
Identity: naming the culprit rather than a number
A PID identifies a process. It does not tell anyone which deployment to roll back.
| Column | Gives you |
|---|---|
application_name | Whatever the client declared. Free, and usually empty |
client_addr | Where the connection came from |
client_hostname | Only populated if log_hostname is on |
usename | The role, which is a coarse proxy for the workload |
backend_type | Client backend, or which background process |
application_name is the one worth insisting on. It costs nothing, it
is settable from any connection string, and it converts “PID 41822 is
blocking the deployment” into “the invoice reconciliation job is
blocking the deployment”.
# In a connection string
postgresql://user@host/db?application_name=invoice-reconciliation
# Or as an environment variable
export PGAPPNAME=invoice-reconciliation
# Or per session
psql -c "SET application_name = 'schema-migration-2026-08'"
Where connections arrive through a pooler, client_addr becomes the
pooler’s address for every session, and application_name becomes the
only remaining way to tell workloads apart. That is worth arranging
before you need it.
Parallel workers
A single query can occupy several backends. leader_pid attributes
them.
SELECT coalesce(leader_pid, pid) AS leader,
pid, leader_pid IS NOT NULL AS is_worker,
state, wait_event_type, wait_event, left(query, 50) AS query
FROM pg_stat_activity
WHERE backend_type IN ('client backend', 'parallel worker')
ORDER BY leader, is_worker, pid;
Without this, a parallel query looks like several unrelated sessions
running identical SQL, and a count of active sessions overstates the
concurrency. leader_pid is null for the leader and set to the
leader’s PID for each worker, so grouping on
coalesce(leader_pid, pid) counts queries rather than processes.
The triage query worth memorising
psql -U postgres -c \
"SELECT pid,
usename,
application_name,
client_addr,
state,
coalesce(wait_event_type || ':' || wait_event, '-') AS waiting_on,
now() - xact_start AS xact_age,
now() - state_change AS in_state,
backend_xid, backend_xmin,
left(query, 50) AS query
FROM pg_stat_activity
WHERE backend_type = 'client backend'
AND pid <> pg_backend_pid()
ORDER BY xact_start NULLS LAST"
Read it in this order: state groups the sessions, waiting_on
explains the active ones, xact_age finds the long transactions,
backend_xid and backend_xmin say whether those transactions matter
for cleanup, and application_name names who to talk to.
Production discipline
- Filter by
statebefore ageingquery_start. In an idle-in-transaction session it refers to a finished statement and will sort to the top for no reason. - Use
xact_startfor transaction age andstate_changefor how long a session has been idle in transaction. They answer different questions. - Set
application_namein every connection string. Behind a pooler it becomes the only way to tell workloads apart. - Group by
coalesce(leader_pid, pid)when counting concurrency, or a parallel query is counted several times. - Set
stats_fetch_consistency = 'none'on monitoring connections that poll inside a transaction, or they will read the same cached values forever.
Cross-course references
- Observability for Production Sysadmins — Part LIX (Database observability) covers exporting these columns as metrics, and Part XII (PromQL foundations) covers turning the cumulative parts into rates.
- Linux for Production Sysadmins — Part VI (Processes) covers
mapping the
pidcolumn onto the host process table. - Git, CI/CD & GitOps — Part CXIV (Deployment markers) covers
correlating a session’s
application_namewith the deployment that started it.
Quiz
Knowledge check · 6 questions
Q1. An engineer sorts pg_stat_activity by query_start to find the longest-running query and investigates the top row, which turns out to be doing nothing. What went wrong?
Q2. A monitoring script opens a transaction and polls pg_stat_activity every five seconds, always receiving identical results. What explains this?
Q3. Which statements about identifying the responsible workload are correct? Select all that apply.
Q4. The query column of pg_stat_activity is truncated at track_activity_query_size bytes, which is 1024 by default.
Q5. Name the timestamp column you would age to answer each of these: how long a statement has been running, how long a transaction has been open, and how long a session has been idle in transaction.
Q6. Explain why the investigation stalled and give the queries that would resolve it.
A deployment has been blocked for twenty minutes waiting on a lock against the orders table. The engineer runs pg_stat_activity, sees forty-one client backends, and cannot tell which one is responsible. Every row has an empty application_name and an identical client_addr of 10.4.0.9. Several rows show the same SELECT statement text and appear to be duplicates. The database sits behind PgBouncer, and the application is a single service with twelve replicas.
Passing score: 75%. Answers are checked in this browser.