Skip to main content
RunBook Academy

PostgreSQLIV · Connections, Sessions and PoolingConnections

Reading pg_stat_activity as an instrument

Intermediate⏱ ~25 min🧪 Lab requiredpsql

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

Not yet marked complete on this device.

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

ColumnSet whennow() - column answers
backend_startThe connection was establishedHow old is this connection?
xact_startThe current transaction beganHow old is this transaction?
query_startThe current statement beganHow long has this statement run?
state_changestate last changedHow 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.

ColumnGives you
application_nameWhatever the client declared. Free, and usually empty
client_addrWhere the connection came from
client_hostnameOnly populated if log_hostname is on
usenameThe role, which is a coarse proxy for the workload
backend_typeClient 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

  1. Filter by state before ageing query_start. In an idle-in-transaction session it refers to a finished statement and will sort to the top for no reason.
  2. Use xact_start for transaction age and state_change for how long a session has been idle in transaction. They answer different questions.
  3. Set application_name in every connection string. Behind a pooler it becomes the only way to tell workloads apart.
  4. Group by coalesce(leader_pid, pid) when counting concurrency, or a parallel query is counted several times.
  5. 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 pid column onto the host process table.
  • Git, CI/CD & GitOps — Part CXIV (Deployment markers) covers correlating a session’s application_name with the deployment that started it.

Quiz

Knowledge check · 6 questions

  1. 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?

  2. Q2. A monitoring script opens a transaction and polls pg_stat_activity every five seconds, always receiving identical results. What explains this?

  3. Q3. Which statements about identifying the responsible workload are correct? Select all that apply.

  4. Q4. The query column of pg_stat_activity is truncated at track_activity_query_size bytes, which is 1024 by default.

  5. 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.

  6. 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.