Build1 publisher3 min readPublished
Postgres 19's REPACK moves the exclusive lock from the whole rewrite to the file swap
PostgreSQL 19's beta REPACK rewrites a bloated table while writers keep committing, and one developer's benchmarks put nearly all of the cost in the sequential scan rather than in replaying the writes it captured.
The Engineer · Build desk

What happened
- PostgreSQL 19 adds a REPACK command that folds the space-reclaiming rewrite of VACUUM FULL and the index-order rewrite of CLUSTER into a single statement.
- Its CONCURRENTLY option runs that same rewrite while the table stays readable and writable to application traffic.
- In a developer's beta-3 test on a 424 MB table driven by eight writer connections, 33 writes completed during VACUUM FULL against 3,988 during REPACK (CONCURRENTLY), with the same space reclaimed.
- The availability came out of the clock: VACUUM FULL finished the job in 0.97 seconds, REPACK (CONCURRENTLY) in 4.32.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- decision For a table with a real window and no traffic, the post's author still points at plain REPACK; CONCURRENTLY is a purchase of served traffic, not of speed, and it should be chosen that way.
- exposure The exclusive lock survives at the swap, so any session holding the table can park that request and everything queued behind it, at the moment when the rewrite work is already paid for and hardest to abandon.
- capability Phase and blocks scanned are pollable while a repack runs, and the abort path leaves nothing to clean up, so waiting it out or killing it is a decision with real data behind it rather than a guess.
The rewrite is the easy part to reason about. REPACK (CONCURRENTLY) copies the heap into a new file while the old one keeps accepting writes, captures what changed using logical decoding, replays it, and only then takes a brief lock to swap the files [8]. Two cost terms, and the benchmark separates them. Sixteen connections committing 1,120 updates a second landed 16,228 writes during the rewrite of a 1549 MB table, and replaying that backlog took two tenths of a second [11]. Divide 16,228 by 1,120 and the rewrite ran about 14.5 seconds, which puts replay at roughly 1.4 percent of the window [19]. The author's reading is that almost all of the cost is the sequential scan, which does not care how busy the table is [12].
That is the figure to interrogate before assuming it transfers. Scan cost tracks heap size; the backlog to replay tracks scan time multiplied by write rate. The tables here ran 424 MB to 1549 MB with two indexes over three million tuples [4][10], and the whole operation finished in seconds. A heap that takes minutes to scan accumulates proportionally more change to replay at the same commit rate, and nothing in this test measures what replay costs at that size. The new pg_stat_progress_repack view is the instrument for finding out, since it reports phase, heap blocks scanned, tuples, and indexes rebuilt [10].
The end of the operation is where the availability claim takes its asterisk. The slowest write during the concurrent run was 125 ms, and that is the swap [9]. CONCURRENTLY still needs ACCESS EXCLUSIVE for that swap, asks for it at the end, and if another session is holding the table it waits with a pending exclusive request that everything arriving afterwards queues behind [14]. One session idle in a transaction that touched the table is enough to convert a command chosen for availability into a stall of a length nobody controls, and it lands after the expensive work is already spent. Cancellation, at least, is well behaved: a cancel one second into a repack of a 1 GB table left the table at 2,000,000 rows and 1033 MB, added nothing to the database, and left no replication slot [13].
The post raises the schema question but leaves the restriction unnamed. It opens by saying one of the restrictions is something you would want to fix on your own schedule rather than discover during an upgrade, then gives its length to lock behaviour and breaks off mid-sentence in a second lock-queue run without naming the restriction [16]. Logical decoding is the mechanism [8], which narrows where such a requirement would live, but the rule itself is not in this evidence, and a restriction nobody has stated is not something a DBA can audit against. Beta 3 is beta 3, and the author says as much [3].
What CONCURRENTLY buys is not only writes. A single connection looping a keyed count against a 1.1 GB table got exactly one read through a VACUUM FULL [6]. Where there is a genuine window and nothing running, the post's own advice is plain REPACK [15]. Reach for CONCURRENTLY and the price appears twice: about four and a half times the duration [17], and a lock request at the end that you had better be certain nothing is sitting in front of [14].
What to watch
- Whether PostgreSQL 19's REPACK documentation names the restriction the post flags, which is what any pre-upgrade schema check would have to be built against.
- Whether the interrupted lock-queue experiment gets published with a number for how long a held table stalls a pending swap request.
- Whether the beta 3 timings hold at general availability, and whether anyone repeats them on a heap that takes minutes rather than seconds to scan.