Postgres 19 is close to shipping the thing people have kept pg_repack installed for: an online table rebuild as a core SQL command. REPACK (CONCURRENTLY) rewrites a bloated table under a lock that ordinary reads and writes don’t notice for the bulk of the work, then swaps the files at the end behind an exclusive lock that may itself have to wait for readers. The first release candidate is out now. A Snowflake engineering post touring the feature (and its pg_squeeze lineage) is what pointed me at it; the part worth having in advance is what the docs say versus what RC1 actually does.
My table: three million rows, 868 MB of heap, one full-table UPDATE with autovacuum turned off on the table, so 1,736 MB with half of it dead. REPACK (CONCURRENTLY) rebuilt that in 3.1 seconds. A probe reading the table once a second kept succeeding right through the rebuild, an INSERT I fired one second into the rebuild completed and survived, and the heap came back at 868 MB. (Tables need a primary key or an index-based replica identity; my bare no-PK table was refused with “has no identity index,” and the same shape with REPLICA IDENTITY USING INDEX ran.) The docs describe the shape correctly: the bulk of the work runs under SHARE UPDATE EXCLUSIVE, and ACCESS EXCLUSIVE is taken only for the final swap. With nothing standing in the way, no probe read waited.
The two caveats are where it gets interesting, and I hit both by accident before I hit them on purpose.
The documented one is a warning in the 19 manual: REPACK with CONCURRENTLY “is not MVCC-safe.” After the rebuild commits, “the table will appear empty to concurrent transactions” whose snapshot predates it, and which have not touched the repacked table before the rewrite starts. A transaction that has already read the table stays holding its read lock and blocks the swap instead; that is the other caveat below. I opened a REPEATABLE READ transaction and took its snapshot by reading a different table, so it held no lock on the bloated one, let the repack run and finish, then read the rebuilt table from inside that old transaction: zero rows, no error, nothing. The same connection, one commit later, saw 3,000,002. A report running under REPEATABLE READ doesn’t crash when a repack lands inside its window. It reads an empty table and completes normally, which is worse.
The other caveat doesn’t get a warning label, and I found it before I knew to look for it. In my first attempt, the old-snapshot transaction had also read the table itself. Locks are held to the end of a transaction, not the end of a statement, so a session sitting idle in a transaction kept the table’s read lock. The copy finished in the first few seconds; the swap then waited behind that one idle reader for two and a half minutes, until I killed it. While the swap sat in the lock queue, a plain SELECT from a third session queued behind the pending ACCESS EXCLUSIVE and hung too. The docs promise the swap lock is held briefly once acquired. What sets the wait before that is the set of open transactions still holding the table’s read lock — the swap waits out every one of them, and the wait ends when the last of them ends, by commit, rollback, or a killed session, which is how mine finished — and every new read that arrives queues behind the waiting swap.
So REPACK (CONCURRENTLY) wants the discipline of a constraint change, not of a vacuum. The pre-flight check is the one you would run before an ALTER TABLE needing the big lock: look in pg_locks for granted locks on the table, then join to pg_stat_activity for the holder’s state and xact_start, idle ones included. pg_stat_activity alone shows an idle transaction’s last query, not what it still holds. Like that ALTER TABLE, the swap queues for readers before taking the lock, and each new arrival gets stuck behind the wait; the difference is that ALTER holds the lock for the length of its own work afterward, while REPACK holds it only for the swap itself. Add the visibility rule and the pre-flight is complete: anything that must not observe an empty table, a REPEATABLE READ report, a serializable job, must not span the repack.
Scope, so nobody reads more into this than the run supports: RC1 in a container on an idle box, one mid-rebuild insert rather than live update churn, no USING INDEX ordering. The failure bounds under heavy concurrent changes are doc claims. The empty snapshot and the queued swap are the parts I ran, and they are the two things a Postgres 19 migration plan has to answer for.