IntermediateReferenceDatabases

PostgreSQL REPACK - reclaiming space without the lock

How PostgreSQL reclaims dead-tuple space by rewriting a table - the pg_repack extension's trigger-and-log-table mechanism that carried PG 15-18, and the native REPACK command landing in PG 19 that replaces the trigger with logical decoding.

Applies to
PostgreSQL15-19pg_repack covers every supported release up to 18. REPACK is new in PostgreSQL 19, which was still in beta at the last review (Beta 4, 24 September 2026, remains the latest pre-release) - the syntax described here is the beta documentation.
Updated Reviewed
intermediateAssumes working familiarity; skips the fundamentals.

Concepts covered

PostgreSQL REPACK - reclaiming space without the lock

An UPDATE in PostgreSQL does not change a row in place - it writes a new version and marks the old one dead. VACUUM comes behind and marks that space as reusable within the table, but it never gives the space back to the operating system. A table under steady churn therefore grows to its peak size and stays there, even when the live data is a fraction of it. That is bloat, and the only way to shrink the file is to rewrite the table - which is what REPACK does.

The problem has never been what to do but how long the lock lasts. Rewriting a table takes ACCESS EXCLUSIVE, and on a large table held for the whole run, that is an outage. Everything below is about how PostgreSQL has kept that lock short, first by extension and now in core.

The three options, and what changed in 19

Tool Space returned to OS? Lock held Table usable during?
VACUUM No - space stays allocated to the table SHARE UPDATE EXCLUSIVE Yes, fully
VACUUM FULL / CLUSTER Yes ACCESS EXCLUSIVE, whole run No - reads and writes blocked
pg_repack (extension) Yes ACCESS EXCLUSIVE only at setup and swap Yes - trigger captures writes
REPACK (PG 19) Yes ACCESS EXCLUSIVE, whole run No - same as VACUUM FULL
REPACK (CONCURRENTLY) (PG 19) Yes ACCESS EXCLUSIVE only for the final swap Yes - logical decoding captures writes

The interesting row is the last one. What changed in PG 19 is not the idea - copy live tuples to a new file, capture what changed meanwhile, swap, drop the old - it is the capture mechanism. The extension did it with a trigger and a log table. The core does it with logical decoding.

Before: pg_repack on PG 15-18

pg_repack is the extension that made online repacking possible before core had it. Its mechanism is a user-visible one - it installs a trigger on the table and a shadow log table to hold the changes:

How pg_repack repacks a live table (PG 15-18)The extension-era path: a trigger and a log table standing in for what is now a replication slot.
  1. setup

    Set up capture

    Create a shadow log table and attach repack_trigger to the original table - a brief ACCESS EXCLUSIVE lock to install it.

  2. copy

    Copy live rows

    Build a new table containing only the visible tuples, then build its indexes. Runs under SHARE UPDATE EXCLUSIVE - INSERTs, UPDATEs and DELETEs continue normally.

  3. concurrent

    Writes keep arriving

    Every change to the original fires the trigger and lands in the log table as a queued delta. This is where the write cost lives - every write does double work.

  4. replay

    Catch up the copy

    Apply the accumulated log-table rows to the new table, looping until the backlog is under the switch threshold.

  5. commit

    Swap and drop

    Swap the new table into place in the system catalogues - including indexes and TOAST - under a short ACCESS EXCLUSIVE lock, then drop the original.

Two things follow from doing it this way. The trigger means every write pays twice during a repack - once to the table and once to the log - and it means an interrupted run can leave the trigger and log table behind, needing manual cleanup (repack_trigger “already exists” is the signature). It also needs a primary key or unique index to identify rows in the log.

Now: REPACK in PostgreSQL 19

PostgreSQL 19 - in beta at the time of writing - makes repacking a SQL command. The plain form behaves like a friendlier VACUUM FULL; CONCURRENTLY is the flag that makes it the pg_repack-style online operation, and it swaps the capture mechanism:

How REPACK (CONCURRENTLY) repacks a live table (PG 19)The core-era path: the same copy-catchup-swap shape, but changes arrive through logical decoding instead of a trigger.
  1. setup

    Open a replication slot

    A logical-decoding slot is created (needs headroom in max_repack_replication_slots). No trigger, no log table on your table.

  2. copy

    Copy live rows

    The table is rewritten into a new file with no dead space (except fillfactor), and a new file is built for each index. The table stays readable and writable.

  3. concurrent

    Writes are decoded

    Changes during the copy are captured from WAL by logical decoding and buffered for replay - the same machinery used for logical replication.

  4. replay

    Apply before swap

    The decoded changes are applied to the new files. Watch out: changes that arrive while waiting for the final lock must also be applied after it is taken, so a hot table lengthens the lock.

  5. commit

    Swap files

    ACCESS EXCLUSIVE is taken only long enough to swap old and new files for the table and every index; the old files are deleted.

-- repack one table, locking it fully for the duration:
REPACK employees;

-- the same, but let reads and writes continue during the copy:
REPACK (CONCURRENTLY) employees;

-- physically reorder the rows to match an index (clustering):
REPACK (CONCURRENTLY) employees USING INDEX employees_ind;

-- every table you hold MAINTAIN on - non-concurrent, not in a transaction:
REPACK;

The reason the capture mechanism matters: a trigger sees only what it is attached to and costs write overhead on every row; logical decoding reads the WAL after the fact, so the table carries no extra write cost while it is being copied. That is the same reason the progress reporting is built in - REPACK reports to pg_stat_progress_repack rather than leaving you to watch a log table.

The catch in CONCURRENTLY

CONCURRENTLY moves the lock, it does not remove it. The ACCESS EXCLUSIVE is still needed to swap the files, and the changes that arrived while it was waiting for that lock are applied during it - so on a write-hot table the final lock can be noticeably long even though the copy ran lock-free. The exclusions are also real:

  • no materialized views, UNLOGGED, partitioned (as the target), system catalogue or TOAST tables
  • the table needs a primary key or index-based replica identity - same requirement as pg_repack
  • heap access method only, and not inside a transaction block
  • needs a free replication slot under max_repack_replication_slots, which implies wal_level = logical for the decoding to work at all
  • and it is not MVCC-safe - the same caveat CREATE INDEX CONCURRENTLY carries

Add USING INDEX and the rows are not just compacted but physically reordered into index order - the clustering CLUSTER does. It is a one-time ordering; later updates are not kept clustered.

The disk and memory bill

Repacking is a copy, so it needs the room for one. Free space needed is at least the table plus its indexes; if the planner picks the sequential-scan-then-sort path it can peak at double the table plus indexes, and CONCURRENTLY adds a bit more for the buffered changes. enable_sort = off disables the sort path if that is intolerable, and maintenance_work_mem should be raised to what the operation can actually use.

The dataflow, side by side

Figure 3. The two capture channels, side by side
Figure 3. The two capture channels, side by sideThe old table is copied into new files while reads and writes continue. Changes in flight reach the copy through a trigger and log table on the pg_repack path, or through WAL and a logical replication slot on the core REPACK path. A brief ACCESS EXCLUSIVE lock covers the swap either way.old tableWAL + slotcore REPACKtrigger + logpg_repackswapbrief ACCESS EXCLUSIVEnew filesPG 19PG 15-18
Both paths share the copy-catchup-swap shape; what changed in 19 is the channel: WAL and a logical slot instead of a trigger and a shadow log table. The lock at swap is the part CONCURRENTLY cannot remove.
Text equivalent
  1. Live rows are copied into new table and index files while the original stays readable and writable.
  2. Concurrent writes are captured for replay - pg_repack via a trigger and log table (every write does double work), REPACK CONCURRENTLY via logical decoding over a replication slot (no per-write cost on the table).
  3. The backlog is applied until it is small enough to finish under a brief ACCESS EXCLUSIVE lock, which swaps the files.

Where it breaks

  • An interrupted pg_repack leaves debris - the trigger and log table stay on the table, and the next run fails on repack_trigger already exists until you clean them up.
  • CONCURRENTLY can still lose the race on a hot table. Changes that arrive while it waits for the swap lock are applied during it, so the “short” final lock stretches with write rate.
  • DDL during a concurrent repack fails it. A concurrent ALTER on the target aborts the repack.
  • Disk-full mid-run. A repack is a second copy of the table - on a nearly-full volume the operation itself can be what fills the disk.
  • It is new. PG 19 was at Beta 4 when this was written; the command is documented and real, but treat beta behaviour as movable until the GA release.

What this is not for

  • Routine bloat between autovacuums - that is what VACUUM is for, and a repack on a table that is merely slowly growing is maintenance overhead for no benefit.
  • A partitioned table with CONCURRENTLY - the concurrent form rejects partitioned targets; repack the partitions instead, or accept the full lock.
  • A table you cannot give a replication slot - CONCURRENTLY needs wal_level = logical and a free max_repack_replication_slots entry, so it is not available everywhere.
  • Anything older than PG 19 that must stay online - that remains pg_repack’s job, and it is not going away while 15-18 are supported.

PostgreSQL release support

5 supported
ReleaseLatestReleasedEnd of lifeStatus
1818.6Sept 2025Nov 2030Supported
1717.11Sept 2024Nov 2029Supported
1616.15Sept 2023Nov 2028Supported
1515.19Oct 2022Nov 2027Supported
1414.24Sept 2021Nov 2026Supported
1313.23Sept 2020Nov 2025End of life
1212.22Oct 2019Nov 2024End of life
1111.22Oct 2018Nov 2023End of life

Data as of 2026-10-01 from endoflife.date. Refresh with node scripts/fetch-versions.mjs.

What to watch

Specific things that would change what this page concludes.

  • Whether REPACK CONCURRENTLY lands unchanged in the PG 19 GA release - it is documented in the beta, and beta features can still shift
  • Whether the pg_repack extension remains needed for pre-19 installs - it will, until those versions age out of support

Where PostgreSQL sits

How this component relates to the rest of the stack. Items without a link are not covered here yet.

Depends on

Must be present for this to run

Deployed with

Commonly run alongside

Alternatives

Solves a similar problem differently