Skip to main content
RunBook Academy

PostgreSQLVII · MVCC, Transactions and VisibilityMVCC

Long-running transactions

Intermediate⏱ ~30 minpsql

What you'll learn

  • Quantify the damage a single held snapshot does to a churning table
  • Distinguish idle in transaction from active, and know which is worse
  • Configure the timeouts that bound the exposure, and their trade-offs
  • Find the holder during an incident, including the cases with no session to find

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.

Everything so far has been mechanism. This lesson is the bill.

The measurement

A table with 50,000 rows, freshly vacuumed and analysed.

Read-only / Safebaseline
$ psql -U postgres -c "SELECT pg_size_pretty(pg_relation_size('churn')) AS size, n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname='churn'"
  size   | n_live_tup | n_dead_tup
---------+------------+------------
1776 kB |      50000 |          0
(1 row)

One session then does the least it possibly can while still holding a snapshot: opens a REPEATABLE READ transaction, runs a single SELECT count(*), and goes quiet. It holds no locks anyone would notice. It has written nothing, so it has no transaction id at all.

While it sits there, the workload runs ten rounds of UPDATE churn SET v = v + 1, each followed by an explicit VACUUM.

Service impact possibleafter ten rounds of update and vacuum, with the snapshot still held
$ psql -U postgres -c "SELECT pg_size_pretty(pg_relation_size('churn')) AS size, n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname='churn'"
 size  | n_live_tup | n_dead_tup
-------+------------+------------
19 MB |      50000 |     500000
(1 row)

1776 kB to 19 MB. The same 50,000 live rows. Half a million dead versions that ten separate vacuums declined to remove.

Vacuum says so itself, in as many words:

Read-only / SafeVACUUM VERBOSE during the same window
$ psql -U postgres -c "VACUUM (VERBOSE) churn"
tuples: 0 removed, 550000 remain, 500000 are dead but not yet removable
removable cutoff: 683682, which was 10 XIDs old when operation ended
index scan not needed: 0 pages from table (0.00% of total) had 0 dead
item identifiers removed

0 removed. Not slow, not partial, not deferred. Zero. Vacuum ran, read the whole table, and correctly concluded that it was not allowed to touch anything, because a snapshot exists that might still need those versions.

And it does not come back

The holding transaction commits. Vacuum runs once more.

Read-only / Safeafter the holder ends, one more VACUUM
$ psql -U postgres -c "VACUUM churn" -c "SELECT pg_size_pretty(pg_relation_size('churn')) AS size, n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname='churn'"
 size  | n_live_tup | n_dead_tup
-------+------------+------------
19 MB |      50000 |          0
(1 row)

The dead rows are gone. The 19 MB is not. Part VI established why: vacuum makes space reusable, it does not return it to the filesystem.

So the table is now eleven times its original size, permanently, until something rewrites it under an exclusive lock. The session responsible read one row count and then did nothing at all.

idle in transaction is the dangerous state

pg_stat_activity.state distinguishes cases that look similar in a process list and are not remotely equivalent.

StateWhat it meansHolding a snapshot?
activeExecuting a statementYes
idle in transactionIn a transaction, waiting for the clientYes
idle in transaction (aborted)Same, after an errorYes
idleNo transaction openNo

idle in transaction is worse than active. An active query is at least making progress and will end. An idle in transaction session has stopped doing anything and is waiting on a client that may never speak again — a laptop that closed, a deployment that killed a pod mid-request, a developer’s psql behind a BEGIN typed before lunch.

It holds the snapshot for exactly as long as it takes anyone to notice.

Finding the holder

During an incident, the question is “what is holding the horizon”, and the answer is the maximum of age(backend_xmin).

SELECT pid,
       usename,
       application_name,
       state,
       age(backend_xmin)                              AS xmin_age,
       now() - xact_start                             AS xact_duration,
       now() - state_change                           AS in_state_for,
       wait_event_type, wait_event,
       left(query, 80)                                AS query
  FROM pg_stat_activity
 WHERE backend_xmin IS NOT NULL
 ORDER BY age(backend_xmin) DESC
 LIMIT 10;

Read xmin_age first, not xact_duration. A transaction open for two hours on an idle cluster has done no damage; one open for two minutes during a bulk load may have done a great deal. Age in transactions is the measure of harm, because it counts what has happened since.

Terminating a holder

pg_terminate_backend(pid) ends the session and rolls back its transaction. For an idle in transaction session this is nearly always safe: it has, by definition, not been doing anything.

For an active session, decide deliberately. Part IX covers cancel versus terminate in full, with the measurements. The short form matters here because the two verbs behave differently depending on what the session is doing:

  • Against a session running a statement inside a transaction, pg_cancel_backend() aborts that transaction, which does release its snapshot and its locks. The session then sits idle in transaction (aborted), holding nothing but a connection slot.
  • Against a session that is already idle in transaction, pg_cancel_backend() returns true and does nothing at all — there is no statement to cancel, and the snapshot is still held. This was measured on 18.6 and is exactly the case this lesson is about.

So for the idle in transaction holder, terminate is the only verb that works.

What to take from this

  • One held snapshot took a table from 1776 kB to 19 MB in ten cycles and ten vacuums removed zero rows. The size did not come back.
  • The horizon is cluster-wide. What the holding session queried is irrelevant.
  • idle in transaction is worse than active, because nothing will end it.
  • Set idle_in_transaction_session_timeout, preferably per role. The default is unlimited.
  • Sort by age(backend_xmin), not by duration. If no session appears, check prepared transactions, replication slots and replica feedback.

Cross-course references

  • Linux for Production Sysadmins — Part VI (Processes) covers identifying the client process behind an idle-in-transaction backend, and Part LXXXI (Incident command) covers the decision to terminate somebody else’s session.
  • Observability for Production Sysadmins — Part LIX (Database observability) covers alerting on transaction duration, and Part XX (Alert quality) covers why an alert on this needs a threshold nobody routinely exceeds.

Quiz

Knowledge check · 6 questions

  1. Q1. A reporting session holds an open transaction against a small reference table for six hours. The operations team argues it is harmless because that table is barely written to. What is wrong with the argument?

  2. Q2. VACUUM VERBOSE reports '0 removed, 550000 remain, 500000 are dead but not yet removable'. What does this tell you?

  3. Q3. Vacuum is removing nothing cluster-wide. pg_stat_activity shows no session with a backend_xmin older than a few seconds. Which check is most likely to find the cause?

  4. Q4. Which statements about idle_in_transaction_session_timeout are correct? Select all that apply.

  5. Q5. Once the holding transaction ends, a subsequent VACUUM removes the accumulated dead rows and the table returns to its original size on disk.

  6. Q6. You are paged for rapid table growth. Describe the first three checks you would run and why you would order them that way.

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