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:
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.
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.
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.
replay
Catch up the copy
Apply the accumulated log-table rows to the new table, looping until the backlog is under the switch threshold.
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:
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.
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.
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.
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.
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 implieswal_level = logicalfor the decoding to work at all - and it is not MVCC-safe - the same caveat
CREATE INDEX CONCURRENTLYcarries
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
Text equivalent
- Live rows are copied into new table and index files while the original stays readable and writable.
- 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).
- 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 existsuntil you clean them up. CONCURRENTLYcan 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
ALTERon 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
VACUUMis 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 -
CONCURRENTLYneedswal_level = logicaland a freemax_repack_replication_slotsentry, 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| Release | Latest | Released | End of life | Status |
|---|---|---|---|---|
18 | 18.6 | Sept 2025 | Nov 2030 | Supported |
17 | 17.11 | Sept 2024 | Nov 2029 | Supported |
16 | 16.15 | Sept 2023 | Nov 2028 | Supported |
15 | 15.19 | Oct 2022 | Nov 2027 | Supported |
14 | 14.24 | Sept 2021 | Nov 2026 | Supported |
13 | 13.23 | Sept 2020 | Nov 2025 | End of life |
12 | 12.22 | Oct 2019 | Nov 2024 | End of life |
11 | 11.22 | Oct 2018 | Nov 2023 | End 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.