Development

Reading Postgres Locks and Bloat During a Migration

Antonio Evans8 min read

A split-view scene of a database server room at night.
Key points

Key points

  1. 1A session that shows state active with wait_event_type Lock and wait_event relation is blocked on a table lock, not doing work.
  2. 2Joining pg_locks on locktype, relation and transactionid, or calling pg_blocking_pids, tells you which process holds the lock a migration is waiting for.
  3. 3A migration that needs ACCESS EXCLUSIVE queues behind any open transaction touching the table, and every later query queues behind the migration.
  4. 4Bloat and index state change during a migration, so record table size, dead tuples and index validity before, during and after the run.

Start with what the sessions are actually doing

A migration that feels slow is usually one of three things: waiting for a lock, doing I/O, or burning CPU. Those have different fixes, so the first step is to tell them apart instead of guessing from the migration log. Postgres exposes that distinction in pg_stat_activity through the state, wait_event_type and wait_event columns.

A useful first query, adapted from Crunchy Data's lock investigation guide, lists every non-idle backend with how long its transaction and current query have been running the Crunchy Data walkthrough:

SELECT
  pid,
  usename,
  application_name,
  state,
  wait_event_type,
  wait_event,
  now() - xact_start  AS xact_age,
  now() - query_start AS query_age,
  left(query, 60)     AS query
FROM pg_stat_activity
WHERE state <> 'idle'
  AND pid <> pg_backend_pid()
ORDER BY query_age DESC;

Read the output as a decision table:

statewait_event_typewait_eventWhat it means for the migration
activeLockrelationWaiting on a table lock held by another session
activeLocktransactionidWaiting on a row lock or a transaction that touched rows it needs
activeLockextensionWaiting on a lock that a background process or extension holds
activeIODataFileRead, WALWriteWorking, but the storage is the bottleneck
activeCPU(usually NULL or LWLock)Working, or contending on an internal lightweight lock
idle in transactionClientClientReadHolding locks while doing nothing; the classic blocker
activeClientClientWriteSending a large result set to the client

The row that matters most during a migration is idle in transaction. A connection pool or an ORM that opened a transaction and paused holds whatever locks it already took, and an ALTER TABLE waiting for ACCESS EXCLUSIVE will sit behind it indefinitely. Bytebase's guide to reading pg_stat_activity for lock problems makes the same recommendation: filter on active sessions with a non-null wait event, and if the same pid keeps showing the same wait across two runs a few seconds apart, treat it as a real wait rather than noise the Bytebase lock guide.

The blocking chain: who holds the lock your migration wants

Knowing a pid is waiting tells you little. You want the holder. pg_locks shows every lock request, granted or not, with the lock mode and the object. The PostgreSQL wiki keeps a long-standing query that joins pg_locks to itself on every lock identity column and then to pg_stat_activity twice, so each blocked pid appears next to its blocker and both queries the PostgreSQL wiki on lock monitoring.

That self-join is correct but verbose. If you are on a version that has pg_blocking_pids(), the query is a few lines, as shown on Stack Overflow the pg_blocking_pids answer:

SELECT
  a.pid,
  a.state,
  a.wait_event_type,
  a.wait_event,
  pg_blocking_pids(a.pid) AS blocked_by,
  now() - a.xact_start     AS xact_age,
  left(a.query, 70)        AS waiting_query
FROM pg_stat_activity a
WHERE cardinality(pg_blocking_pids(a.pid)) > 0
ORDER BY xact_age DESC;

For the migration itself, add the lock mode. This is the detail most monitoring dashboards omit, and it explains why a fast DDL statement can still take an outage: lock requests are queued in order, so the ACCESS EXCLUSIVE request blocks every later reader even before it is granted. The RunxBuild guide on adding columns to a live table describes the mechanism and the fix in one line: SET LOCAL lock_timeout = '3s' inside the migration transaction, so the DDL fails fast instead of parking the table behind itself the RunxBuild column guide.

SELECT
  l.pid,
  l.locktype,
  l.relation::regclass AS relation,
  l.mode,
  l.granted,
  pg_blocking_pids(l.pid) AS blocked_by
FROM pg_locks l
WHERE l.relation = 'users'::regclass
ORDER BY l.granted, l.pid;

A worked example makes the queue behavior concrete. Suppose session A runs an UPDATE users inside an open transaction and then idles, holding a ROW EXCLUSIVE lock on users. Session B runs ALTER TABLE users ADD COLUMN last_login timestamptz, requesting ACCESS EXCLUSIVE, and waits. Session C runs a plain SELECT on users. Because B's request is queued first, C waits behind B even though C's read would not conflict with A at all. The table is unavailable to new readers not because of a long migration, but because of a paused client. Raising lock_timeout on the ALTER converts this into a failed migration you can retry, instead of an application outage.

Lock modes worth knowing before you read the output

The mode column in pg_locks only makes sense alongside the operations that take each mode. For migrations, four cases cover most of what you will see.

Adding a column with a constant default is catalogue-only on modern Postgres, so the ACCESS EXCLUSIVE lock lasts milliseconds regardless of table size. A volatile default such as now() or gen_random_uuid() forces a full table rewrite under that same lock, which is the case that turns a one-line statement into minutes of blocked traffic the RunxBuild column guide.

A plain CREATE INDEX takes a SHARE lock, which permits reads and blocks writes; CREATE INDEX CONCURRENTLY takes SHARE UPDATE EXCLUSIVE instead, which permits reads and writes but blocks other DDL on the same table the Bytebase index guide. The Postgres manual documents the concurrent build in detail, including that it cannot run inside a transaction block and that a failed build leaves an invalid index behind the PostgreSQL CREATE INDEX documentation. A common misconception is worth settling: CONCURRENTLY is a build option, not a property of the finished index, so nothing in pg_indexes records it later the Stack Overflow explanation.

ALTER TABLE ... SET NOT NULL is the quiet one. Even when no nulls exist, Postgres must verify by scanning every row while holding ACCESS EXCLUSIVE, which blocks reads as well as writes the Atlas guide to lock-safe NOT NULL. The safe sequence is add a NOT VALID check constraint, validate it under a weak lock, then set NOT NULL, which newer versions can do by trusting the validated constraint the RunxBuild column guide.

Index builds and some maintenance operations also report progress. pg_stat_progress_create_index exposes a phase column with values from initializing through building index to waiting for old snapshots, plus counters for blocks and tuples done the Bytebase index guide. When a concurrent build seems stuck, that view tells you whether it is scanning, waiting for writers, or waiting for an old snapshot to be released by a long transaction.

Bloat, size and the numbers that change mid-migration

Bloat is not a lock problem, but it is part of reading what a migration is doing to the database. Updates and deletes leave dead tuples behind, and a table that looks small on disk can carry a large amount of dead space that VACUUM has not yet reclaimed. During a backfill, every updated row creates a new version, so dead tuple counts and table size rise while the migration runs. That is expected; what matters is whether they come back down.

A practical pattern is a snapshot table you fill before, during and after the migration, so you can compare rather than remember:

CREATE TABLE IF NOT EXISTS migration_watch (
  taken_at      timestamptz DEFAULT now(),
  relname       text,
  total_bytes   bigint,
  live_tuples   bigint,
  dead_tuples   bigint,
  seq_scans     bigint
);

INSERT INTO migration_watch (relname, total_bytes, live_tuples, dead_tuples, seq_scans)
SELECT
  c.relname,
  pg_total_relation_size(c.oid),
  s.n_live_tup,
  s.n_dead_tup,
  s.seq_scan
FROM pg_class c
JOIN pg_stat_user_tables s ON s.relid = c.oid
WHERE c.relname IN ('users', 'orders');

Run that insert at the start, once mid-run, and once after the migration and a VACUUM. If dead tuples are an order of magnitude higher after the backfill and the table size never returns to baseline, the backfill batch size is too large or too fast for autovacuum to keep up, and the next migration on that table will run against a fatter table.

One more decision rule matters for the observation window itself. Static safety linting on the DDL text catches obvious hazards, but the same statement can be a no-op or a full rewrite depending on the column's existing type and the table's size the Hacker News discussion of migration safety. That is the argument for watching the live database rather than trusting the ruleset alone: the catalog tells you what Postgres actually decided to do.

Tie the observation to the expand and contract model

The queries above are most useful when the migration follows a shape that gives you something observable to check. The expand and contract pattern splits one risky change into a sequence of small, backwards-compatible steps: add the new structure, migrate data and client code to it, then remove the old structure once nothing uses it the Prisma expand and contract guide. Each step is separately deployable, so each gets its own observation window instead of one long, opaque cutover.

Jasmin Fluri's write-up of the same pattern names the three phases (expand, migrate, contract) and the reason teams accept the extra steps: safe incremental releases, and the ability to roll forward rather than unwind a large batch of changes the expand and contract overview. Harness frames the same idea as a compatibility rule: every schema change must be safe for the current app version and the next one, because during a rolling deploy both may be running the Harness zero-downtime guide.

For observability, that means you can attach a specific check to each phase. During expand, confirm the ALTER TABLE ADD COLUMN returned in milliseconds and left no waiting backends behind it. During migrate, watch dead tuples and table size as the backfill runs. During contract, confirm no pg_stat_activity row references the old column before you drop it. A migration that is structured this way produces a readable story in the catalogs; a single big-bang change produces one long lock wait and no way to tell which statement caused it.

A monitoring checklist you can run during the migration

Put the checks in this order. It moves from "is anything blocked" to "what is the lock holder doing" to "is the database healthy underneath".

  1. Run the pg_stat_activity query with query_age twice, a few seconds apart. Note any pid that stays active with the same wait event.
  2. Run the pg_locks query for the affected relation. Record the mode and granted values for the migration's own pid.
  3. Call pg_blocking_pids() on the migration pid. Follow the chain until you reach a pid with no blocker; that is the root.
  4. Read the root's state, xact_start and query. idle in transaction means a client is holding locks; an old xact_start on a busy query means a long transaction.
  5. Check pg_stat_progress_create_index if an index build is involved, and pg_stat_progress_vacuum if a vacuum is running alongside.
  6. Record size and dead tuple numbers in your watch table so the after-migration comparison is quantitative.
  7. Check for invalid indexes in pg_index if any concurrent build failed, since a failed build leaves a broken index that ordinary queries will ignore.

If step 3 lands on a process you do not recognize, the safest move is not pg_terminate_backend but a look at application_name and client_addr first. The wiki's alternate query includes application_name for exactly this reason: it maps a pid back to a code path or service the PostgreSQL wiki on lock monitoring.

Teams that run migrations often end up automating the read side: a small script that samples these queries every few seconds, stores the results, and flags any migration whose wait time exceeds a threshold. That is the natural place where an AI assistant that can query your Postgres and summarize the output earns its keep, and where a specialist can wire the check into your deploy pipeline and your monitoring. If you need a tool built for that, describe it and a Quanta specialist will pick it up and build it with you at Quanta.

FAQ

FAQ

Why does my instant ALTER TABLE still block every query?

Because lock requests are queued in order. If a long transaction already holds a lock on the table, your ACCESS EXCLUSIVE request waits, and because it is queued first, every later read and write waits behind it even though those operations would not conflict with the original holder the RunxBuild column guide. Set lock_timeout so the DDL fails fast and can be retried at a quieter moment.

How do I tell a lock wait from a slow query?

Look at wait_event_type in pg_stat_activity. A value of Lock with a detailed wait event of relation or transactionid means the session is waiting on a lock, not working the Crunchy Data walkthrough. Values of IO or CPU indicate the statement is executing and the bottleneck is elsewhere.

What is the fastest way to find the session blocking the migration?

Use pg_blocking_pids(pid) on the migration's backend pid. It returns the pids that are blocking it, so you can join back to pg_stat_activity and read the blocker's state, transaction age and query in one step the pg_blocking_pids answer. The self-join on pg_locks in the PostgreSQL wiki does the same without the helper function the PostgreSQL wiki on lock monitoring.

Does CREATE INDEX CONCURRENTLY avoid all locking?

No. It avoids blocking writes by taking SHARE UPDATE EXCLUSIVE instead of SHARE, but it still blocks other DDL operations on that table and cannot run inside a transaction block the Bytebase index guide. If the build fails it leaves an invalid index that must be dropped and rebuilt the PostgreSQL CREATE INDEX documentation.

How much bloat should I expect during a backfill?

Expect dead tuples to rise in proportion to the number of rows your backfill updates, since each update writes a new row version. The useful signal is not the peak but the return to baseline after a vacuum; if table size stays far above its pre-migration level, the batch size is outpacing autovacuum and the next migration will start from a larger table. The expand and contract model helps here by keeping each backfill step small and independently observable the Prisma expand and contract guide.

Sources

Sources

  1. One PID to Lock Them All: Finding the Source of...
  2. How to show Postgres locks | PostgreSQL How-to Guide
  3. Lock Monitoring
  4. How to detect query which holds the lock in Postgres?
  5. Postgres Add Column: The Safe Way on a Live Table - RunxBuild
  6. How to Use Postgres CREATE INDEX CONCURRENTLY - Bytebase
  7. Documentation: 18: CREATE INDEX - PostgreSQL
  8. CREATE INDEX CONCURRENTLY is executed but the ... - Stack Overflow
  9. Safe NOT NULL Migrations on PostgreSQL: Avoid Aborted ... - Atlas
  10. Is your Postgres migration safe or not safe? - Hacker News
  11. Using the expand and contract pattern for schema changes - Prisma
  12. Expand and Contract Method for Database Changes | by Jasmin Fluri
  13. Zero Downtime Database Migrations: Safe Schema Changes - Harness
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles