Skip to main content
RunBook Academy

PostgreSQLIV · Connections, Sessions and PoolingConnections

Idle in transaction, and what it actually holds

Advanced⏱ ~30 minpsql

What you'll learn

  • Distinguish idle from idle in transaction from idle in transaction (aborted)
  • Determine from pg_stat_activity whether a session holds a snapshot, an xid, both or neither
  • Predict which idle-in-transaction sessions bound the cleanup horizon
  • Apply idle_in_transaction_session_timeout deliberately rather than as a blanket

Prerequisites

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.

idle in transaction is the state most likely to be named as a root cause and least likely to be understood precisely. The usual statement is that such a session blocks VACUUM from reclaiming dead rows. That is often true in effect and wrong in mechanism, and the difference decides whether an investigation succeeds.

The states, and what distinguishes them

stateThe session isServer is waiting on
activeExecuting a statementWhatever wait_event says
idleConnected, no transaction openThe client, between transactions
idle in transactionInside BEGIN, no statement runningThe client, mid-transaction
idle in transaction (aborted)Same, after an error, awaiting ROLLBACKThe client

The wait event for the two idle-in-transaction states is the giveaway:

Read-only / Safea genuine idle-in-transaction session
$ psql -U postgres -c 'SELECT pid, state, wait_event_type, wait_event, now()-xact_start AS xact_age, left(query,40) AS last_query FROM pg_stat_activity WHERE backend_type = ...'
 pid |        state        | wait_event_type | wait_event |    xact_age     |         last_query
-----+---------------------+-----------------+------------+-----------------+-----------------------------
924 | idle in transaction | Client          | ClientRead | 00:00:04.032709 | CREATE TEMP TABLE t(i int);
(1 row)

Client:ClientRead says the database has done its work and is waiting for the application. The query column shows the last statement executed, not a running one — a distinction worth internalising, because it makes the column look like an active query when it is history.

What three identical-looking sessions actually hold

Three sessions were opened on a live PostgreSQL 18.6 cluster. All three reach idle in transaction. They differ only in isolation level and in whether they wrote.

Read-only / Safefour idle-in-transaction sessions, and what each holds
$ psql -U postgres -d bank -c 'SELECT pid, state, backend_xid AS holds_own_xid, backend_xmin AS holds_snapshot, left(query,42) AS last_query FROM pg_stat_activity ...'
 pid |        state        | holds_own_xid | holds_snapshot |              last_query
-----+---------------------+---------------+----------------+---------------------------------------
924 | idle in transaction |           773 |                | CREATE TEMP TABLE t(i int);
970 | idle in transaction |               |                | SELECT count(*) FROM acct;
980 | idle in transaction |               |            772 | SELECT count(*) FROM acct;
990 | idle in transaction |           776 |                | UPDATE acct SET bal=bal+1 WHERE id=1;
(4 rows)
pidIsolationWrote?backend_xidbackend_xminBounds the horizon?
970READ COMMITTEDnoNo
980REPEATABLE READno772Yes, via its snapshot
990READ COMMITTEDyes776Yes, via its own xid
924READ COMMITTEDyes773Yes, via its own xid

And the horizon itself confirms it:

Read-only / Safethe oldest running transaction ID
$ psql -U postgres -d bank -c 'SELECT pg_snapshot_xmin(pg_current_snapshot()) AS oldest_running_xid, txid_current() AS current'
 oldest_running_xid | current
--------------------+---------
              773 |     777
(1 row)

The rule that falls out of this is short:

A session holds back cleanup when it holds a transaction ID or a snapshot. idle in transaction is neither of those things; it is a state that frequently coincides with them.

READ COMMITTED takes a new snapshot per statement and releases it when the statement ends, so a read-only session sitting between statements holds nothing. REPEATABLE READ and SERIALIZABLE take one snapshot for the whole transaction and hold it until commit. Any session that has written has been assigned a transaction ID, and that ID is running until the transaction ends.

What every idle-in-transaction session holds regardless

Three costs apply whether or not the session bounds the horizon.

Locks. Every lock the transaction acquired is held until it ends. A transaction that ran ALTER TABLE and then went idle holds an exclusive lock, and every subsequent query on that table queues behind it. This is the mechanism in Part IX, and it does not care about isolation level or whether the session wrote afterwards.

A connection slot, counted against max_connections.

Its private memory, including whatever it allocated.

So an idle-in-transaction session is always worth investigating. The refinement is about which symptom to attribute to it.

idle_in_transaction_session_timeout

PostgreSQL will end these sessions for you.

Read-only / Safethe parameter, and its context
$ psql -U postgres -c "SELECT name, setting, unit, context, short_desc FROM pg_settings WHERE name = 'idle_in_transaction_session_timeout'"
                name                 | setting | unit | context |                          short_desc
-------------------------------------+---------+------+---------+-------------------------------------------------------------
idle_in_transaction_session_timeout | 0       | ms   | user    | Sets the maximum allowed idle time between queries, when in a transaction.
(1 row)

Because it is user context, the right place for it is usually a role rather than the server:

-- Bound the workloads whose behaviour you know, individually
ALTER ROLE app_web       SET idle_in_transaction_session_timeout = '30s';
ALTER ROLE reporting_ro  SET idle_in_transaction_session_timeout = '5min';
ALTER ROLE migrator      SET idle_in_transaction_session_timeout = '15min';

The related idle_session_timeout ends sessions idle outside a transaction. It is a much blunter instrument and is usually wrong against an application connection pool, because ending a pooled connection that is idle by design forces the pool to reconnect for no benefit. It is appropriate for interactive and ad-hoc roles.

Production discipline

  1. Sort by what is held, not by how long the session has been idle. backend_xid and backend_xmin are the columns that decide whether a session is a vacuum problem.
  2. Read Client:ClientRead as “the database is waiting for the application”. The query column shows the last statement, not a running one.
  3. Treat every idle-in-transaction session as a locking and slot problem regardless, because those costs apply whatever the isolation level.
  4. Set idle_in_transaction_session_timeout per role, from measurement. A blanket value terminates migrations and exports that were behaving correctly.
  5. Record application_name and client_addr before terminating anything. Terminating resolves tonight and guarantees a repeat.
  6. Treat a move to REPEATABLE READ as a vacuum decision too. It makes read-only long transactions bound the horizon, which READ COMMITTED does not.

Cross-course references

  • Linux for Production Sysadmins — Part XXII (Network troubleshooting) covers diagnosing a client that has stopped reading, which is what ClientRead on a long-idle session can indicate.
  • Observability for Production Sysadmins — Part XX (Alert quality) covers alerting on the oldest transaction age rather than on the count of idle sessions, which is the actionable signal.
  • Kubernetes for Production Sysadmins — Part X (Termination) covers pods being killed mid-transaction, a common source of sessions that go idle and never return.

Quiz

Knowledge check · 6 questions

  1. Q1. A session has been idle in transaction for two hours. pg_stat_activity shows backend_xid and backend_xmin both null. What does this establish?

  2. Q2. A reporting workload is changed from READ COMMITTED to REPEATABLE READ so that multi-query reports see a consistent view. What side effect should be planned for?

  3. Q3. Which costs does an idle-in-transaction session impose regardless of isolation level and regardless of whether it has written? Select all that apply.

  4. Q4. The query column of a session in state idle in transaction shows the statement currently executing.

  5. Q5. Why is a server-wide idle_in_transaction_session_timeout risky, and what is the better placement?

  6. Q6. Separate the two problems in this evidence and give the order of action.

    A table has grown from 40GB to 190GB over six weeks while its row count is unchanged, and autovacuum runs on it repeatedly without reclaiming space. Separately, a nightly deployment has begun timing out while waiting to alter that same table. pg_stat_activity shows eleven sessions idle in transaction. Of these, ten belong to the role app_web with backend_xid and backend_xmin both null and idle durations under four seconds. One belongs to the role analytics_ro, has been idle in transaction for nineteen hours, and reports backend_xmin populated.

Passing score: 75%. Answers are checked in this browser.