PostgreSQL MVCC vs DDL When Migrations Silently Break Long-Running Snapshots

You run a 90-minute analytics job inside a Serializable transaction. A teammate commits a TRUNCATE on a staging table during a scheduled schema migration. Both operations succeed. Your job’s final report is silently wrong: the table appears empty because the snapshot you hold predates the DDL commit. The reason this can happen at all is that the transaction had not yet touched that table when the DDL started, so it never held the ACCESS SHARE lock that would normally block the migration.

This is not a tool bug. It is an explicitly documented boundary in PostgreSQL’s concurrency model that data engineers can miss.

What the Documentation Calls Out

The PostgreSQL concurrency-control documentation flags this directly. In its MVCC Caveats section, it states that some DDL commands — currently only TRUNCATE and the table-rewriting forms of ALTER TABLE — are not MVCC-safe (caveats). After such a command commits, the table will appear empty to concurrent transactions using a snapshot taken before the DDL commit.

The same caveat describes the protection mechanism: any transaction that previously accessed the table holds at least an ACCESS SHARE table lock, which blocks the DDL. The window opens only when a concurrent transaction has not yet touched the table when the DDL starts (caveats).

Why Snapshots Make This Worse

The transaction-isolation page describes how a SELECT in Read Committed sees a fresh snapshot per statement, while Repeatable Read and Serializable fix the snapshot to the start of the first non-transaction-control statement (isolation). That long-lived snapshot is exactly what makes a long analytics query safe from concurrent writes — and exactly what makes it vulnerable to a DDL commit that happened after the snapshot.

For read-only Serializable work, SERIALIZABLE READ ONLY DEFERRABLE will block until it can acquire a snapshot guaranteed to be free of serialization anomalies before reading any data — the only documented case where Serializable transactions block but Repeatable Read does not (isolation). For mixed read-write transactions, no such auto-blocking exists.

Planning vs Applying the State Change

The hazard grows when migration tools separate generate plan from apply. A planner that checks for blockers, returns, and then commits gives a long-running Serializable job a window to begin work after the check but before the apply. Design judgment: treat the schema migration as a state change with snapshot semantics, and ensure any in-flight transaction holding an older snapshot of the target table is visible to the planner before the apply step runs.

The pg_locks system view exposes the currently outstanding locks held by the database server — the documented entry point for inspecting lock-manager state across table, row, page, advisory, and predicate locks (locking). Snapshot visibility, by contrast, is governed by MVCC rules that operate independently of the lock manager. Migration tooling which only inspects pg_locks is checking one of the relevant signals — not the snapshot-visibility boundary described in the caveats.

Coordination Without Locking the Workflow

The locking page documents advisory locks as application-defined locks the system does not enforce — useful when you want something faster than a flag table, that avoids table bloat, and that is automatically cleaned up by the server at the end of the session (locking). The system will not enforce the lock; the application must coordinate correctly.

Hypothetical example: a migration orchestrator acquires a session-level advisory lock at planning time, before any DDL, to signal “a migration is in progress for this table.” Long-running jobs that opt in to check the same advisory lock could wait or abort. This is a coordination pattern, not a workflow restriction — it cannot stop a job that does not opt in, and session-level advisory locks do not honour transaction semantics.

Three Mechanisms, Not One

It is easy to conflate three distinct mechanisms the documentation describes:

  • DDL non-MVCC-safety — about snapshot visibility across DDL, not about row locks (caveats).
  • Serializable predicate locking — uses SIReadLock entries in pg_locks to detect anomalies; these locks do not cause blocking and so cannot be part of a deadlock (isolation).
  • Row-level FOR UPDATE locking — can cause a disk write when a row is marked locked (locking).

Label one mechanism’s behaviour as another’s and you misdiagnose production incidents.

What the Right Posture Looks Like

For data engineering teams, the practical posture is to separate the planning question from the apply question. The docs do not promise that any of this is automatic — they describe the boundary so you can design around it. If a migration must run during analytic hours, build the coordination in explicitly rather than rely on the lock manager to surface a hazard it cannot see.

Law Wen Feng

Cloud and AI Architect in Malaysia. Writes about Azure infrastructure, AI agents, cloud migration, disaster recovery and Microsoft Fabric. More about Law Tech Rants