VACUUM FULL is not actually a tool you can use in production. It rewrites the entire table under ACCESS EXCLUSIVE lock, which means everything that touches that table (including the autovacuum worker that is, somewhere else in the system, probably the reason you needed VACUUM FULL in the first place) waits. On a serious table on a busy system, that wait is measured in hours. Nobody does this.

So for the better part of a decade we have all reached for pg_repack. It is excellent. It is also a third-party extension that you have to install, that maintains its own machinery for catching up to writes that happen during the repack, and that occasionally surprises you with edge cases involving IDENTITY columns, exclusion constraints, or unlogged tables. It works. It is not, however, in the box.

In PostgreSQL 19, it is in the box. REPACK CONCURRENTLY is the new in-core command, and at PGConf.dev this week Álvaro Herrera is presenting the implementation, which is mostly the work of Antonin Houska based on his pg_squeeze extension.

This piece is about how it actually works. It matters, because the design choices it makes are different from pg_repack’s in ways that will determine whether REPACK CONCURRENTLY is a drop-in replacement for you or whether you keep pg_repack around for another release.

The locking model

pg_repack famously avoids holding ACCESS EXCLUSIVE for the duration of the copy by installing a trigger on the source table that records every change into a log table, copying the heap, replaying the log, and finally swapping pg_class.relfilenode under a brief exclusive lock at the very end. The catch-up is trigger-based and entirely in user space.

REPACK CONCURRENTLY does not do this. The initial copy of the table is created under SHARE UPDATE EXCLUSIVE — the same lock level VACUUM and ANALYZE take — using an MVCC snapshot. At the moment that snapshot is established, a logical replication slot is created at the same point, and a concurrent background worker performs logical decoding from the slot into a stash of pending changes. Once the initial copy is finished, the stash is replayed onto the new copy, and then — and only then — does the operation upgrade to the exclusive lock needed to swap the relfilenode.

If you have squinted at this and thought “that’s how logical replication catches up after a base copy,” you are correct. The new command reuses the same machinery CREATE SUBSCRIPTION uses to initialize a table. Which is the right call: that code has been hardened over the last several releases and there is no reason to invent a parallel mechanism.

The practical consequence is that the catch-up is decoded from WAL, not from triggers. The source table is not modified at all during the operation. There is no log table to write to, no trigger overhead on the writers, no chance that something does ALTER TABLE ... DISABLE TRIGGER ALL and silently invalidates the run. Writers pay for the operation only to the extent that the WAL traffic they were already generating is decoded a second time.

The three things that will bite you

The commit message is unusually forthright about the loose ends, and you should treat each of them as a hard constraint until proven otherwise.

One concurrent REPACK CONCURRENTLY per cluster. Because of how the historic snapshot is set up, only one such operation can run at a time across the whole instance. If you have an operational practice of running pg_repack on three tables in parallel during a maintenance window, that practice does not survive the move to the in-core command. Sequence them.

Replication slots are a scarce resource. Each REPACK CONCURRENTLY consumes one slot for the duration of the operation. If you are already running near max_replication_slots for logical replication or for tools like pglogical or Debezium, you need to budget for this. A failed REPACK CONCURRENTLY that leaves an orphaned slot — which can happen if the backend is killed at the wrong moment — will hold WAL until you clean it up. This is the same failure mode you already know from logical replication; the toolbox for diagnosing it is pg_replication_slots and the same instinct for nervously eyeing pg_wal/ disk usage.

The final swap can deadlock. The lock upgrade at the swap phase is a real lock upgrade, and like every lock upgrade in PostgreSQL it can lose a deadlock detection race and abort. This is a sharper edge than pg_repack’s final-phase behavior. In production this will manifest as: you ran REPACK CONCURRENTLY on a hot table at 3pm and it ran for forty-five minutes and then it aborted with ERROR: deadlock detected and rolled back. The work is wasted. Plan to run during lower-traffic windows, just as you would for pg_repack’s final swap, only more so.

Does this kill pg_repack?

Not yet.

For straightforward tables, REPACK CONCURRENTLY is the right tool: simpler, in-core, no extension to install, no triggers on the source. For shops with disciplined maintenance windows and reasonable replication-slot budgets, this is the migration target.

But pg_repack has fifteen years of edge-case handling. It supports partial repacks (--only-indexes), it handles a richer set of object types, and crucially it allows multiple concurrent repacks. If your operational pattern is “every weekend we kick off five pg_repack jobs in parallel and walk away,” REPACK CONCURRENTLY is not that yet. (If this is your operational pattern, you may want to consider some alternatives anyway.)

The right answer for most shops in 19 is: try REPACK CONCURRENTLY on the next table that genuinely needs a rewrite. Keep pg_repack installed. Re-evaluate in 20, by which point the single-process restriction is the kind of thing that quietly gets lifted and nobody notices.

The longer-term picture is harder to argue with. The bloat-removal problem has been an embarrassment for the project for a long time, and getting a native solution into the core — even one with rough edges — is the kind of structural improvement that makes the next decade’s operational story materially better. The piece that has been missing is finally there.