JusDB
Free DB Audit
PostgreSQL

REPACK CONCURRENTLY in PostgreSQL 19 vs pg_repack: Online Table Rebuilds

Learn what PostgreSQL 19's REPACK (CONCURRENTLY) locks, what it needs before it runs, how it compares with pg_repack, and when to keep the extension.

JusDB Team
Published November 9, 2026
19 min read

A bloated PostgreSQL table usually gets one of two fixes. VACUUM FULL holds an ACCESS EXCLUSIVE lock for the whole rewrite, so every read and write waits. The pg_repack extension avoids that lock with a trigger and a log table, but it is one more extension to install and keep in step with the server. PostgreSQL 19 adds a core option: the REPACK command, which replaces VACUUM FULL and CLUSTER, and its CONCURRENTLY option, which rebuilds the table while reads and writes continue. This post covers what REPACK (CONCURRENTLY) locks, what it needs, how it differs from pg_repack, and when to keep the extension. The runbook at the end starts with measuring bloat.

TL;DR
  • PostgreSQL 19 is not GA as of 9 November 2026. RC 1 shipped on 15 October 2026, RC 2 is planned for 12 November, and GA is planned for 19 November. REPACK and REPACK (CONCURRENTLY) are in the RC 1 release notes; they were not among the beta 4 reverts.
  • REPACK t does what VACUUM FULL t did, and REPACK t USING INDEX i does what CLUSTER did. Both still take ACCESS EXCLUSIVE for the whole run.
  • REPACK (CONCURRENTLY) holds SHARE UPDATE EXCLUSIVE while it copies, captures concurrent changes through logical decoding, and takes ACCESS EXCLUSIVE only for a final catch-up pass and the file swap.
  • It needs a primary key or an index-based replica identity, a free slot under max_repack_replication_slots (default 5), and free disk at least equal to the table plus its indexes. It is not MVCC-safe for old snapshots.
  • Keep pg_repack for PostgreSQL 18 and earlier, for managed platforms that are not on 19, and for partitioned parents. Measure bloat before you rebuild anything.

Scope and review date: November 2026. This post covers the REPACK command in PostgreSQL 19 RC 1 and compares it with pg_repack 1.5.3 on PostgreSQL 18 and earlier. PostgreSQL 19 is a release candidate, so behavior can still change before GA; check the final release notes before you plan production work. Commands, defaults, and lock modes were checked against the PostgreSQL 19 documentation and the pg_repack documentation on 9 November 2026. They were not run in a lab.


Is REPACK still in PostgreSQL 19?

Yes, as of the release candidate. The PostgreSQL 19 release notes (marked "as of 2026-10-09") list two utility command items: "Add REPACK command which replaces VACUUM FULL and CLUSTER" and "Allow REPACK to rebuild tables without access-exclusive locking", enabled with the CONCURRENTLY option. The second item also adds the server setting max_repack_replication_slots.

Beta 4 reverted several features, including SQL/PGQ property graphs, FOR PORTION OF, MERGE/SPLIT PARTITIONS, and the pg_get_*_ddl() functions. REPACK was not one of them. The RC 1 announcement lists "several fixes for REPACK (CONCURRENTLY)" among the changes since beta 4. The open items page shows about two dozen REPACK issues resolved during the beta and none open.

sql
SELECT version();
SHOW server_version_num;   -- 190000 on a PostgreSQL 19 beta, RC, or 19.0

Interpretation: anything below 190000 has no REPACK command, and the statement fails with a syntax error. On those servers, pg_repack is still the online option.

Pre-GA software

The PostgreSQL project announced that "PostgreSQL 19 should be made generally available on November 19, 2026" unless an issue needs a delay or another release candidate. Treat everything below as RC 1 behavior, and do not run release candidates in production.

What does REPACK replace: VACUUM FULL or CLUSTER?

Both. The REPACK reference page describes a command that rewrites a table into a new file with no dead tuples, so the freed space goes back to the operating system. With USING INDEX, it also orders the rows by an index, which is what CLUSTER did. The release notes say the two older commands "did similar things, but with confusing names", so they were unified, and both "have been retained for compatibility".

The PostgreSQL 19 VACUUM page now calls the FULL option deprecated and says it "makes VACUUM behave like REPACK without a USING INDEX clause". The CLUSTER page says CLUSTER "is equivalent to REPACK with a USING INDEX clause". Existing scripts keep working.

sql
-- Same effect as VACUUM FULL public.orders: ACCESS EXCLUSIVE for the whole run
REPACK public.orders;

-- Same effect as CLUSTER public.orders USING orders_created_at_idx
REPACK public.orders USING INDEX orders_created_at_idx;

-- Online rebuild: reads and writes continue until the final swap
REPACK (CONCURRENTLY, VERBOSE) public.orders;

Interpretation: only the last form avoids the long exclusive lock; the first two are new names for commands you know. REPACK with no table name processes every table you have the MAINTAIN privilege on, but not with CONCURRENTLY. Progress for REPACK, CLUSTER, and VACUUM FULL appears in the new pg_stat_progress_repack view.

How does REPACK (CONCURRENTLY) lock the table?

In two stages. The REPACK reference explains that without CONCURRENTLY, the ACCESS EXCLUSIVE lock is taken at the start and held throughout, because changes made during the copy would otherwise be lost in the swap. With CONCURRENTLY, "the bulk of the operation is run with SHARE UPDATE EXCLUSIVE lock". Changes made by other sessions during the copy are captured with logical decoding and applied to the new table. Then the command requests ACCESS EXCLUSIVE, applies whatever changes are left, and swaps the table and index files.

SHARE UPDATE EXCLUSIVE is the lock plain VACUUM takes. Per the lock reference, DML continues while DDL and vacuum on that table wait.

REPACK (CONCURRENTLY): how the online rebuild works

Steps 1 and 2 run under SHARE UPDATE EXCLUSIVE; only steps 3 and 4 block reads and writes

REPACK CONCURRENTLY online rebuild Step 1: REPACK copies the live rows into a new table file and builds new index files, holding a SHARE UPDATE EXCLUSIVE lock so reads and writes continue. Meanwhile, application writes keep flowing and are captured through logical decoding. Step 2: the captured changes are applied to the new files. Step 3: REPACK takes a brief ACCESS EXCLUSIVE lock and applies the last captured changes. Step 4: the old and new table and index files are swapped and the old files are deleted. Copy rows new heap and indexes Catch up apply decoded changes Brief lock ACCESS EXCLUSIVE Swap files old files deleted App writes keep flowing 1 2 3 4 captured by logical decoding only steps 3 and 4 block reads and writes SHARE UPDATE EXCLUSIVE from step 1 until step 3 starts

Lock length: the PostgreSQL 19 docs say the final lock "is typically held for a short time, depending on the amount of changes accumulated while the lock was being waited for". Heavy writes or a long wait for the lock make step 3 longer.

Watch the locks while a rebuild runs. This query shows who holds or waits for a lock on the table:

sql
SELECT l.pid, l.mode, l.granted, a.state,
       now() - a.xact_start AS xact_age,
       left(a.query, 60) AS query
FROM pg_locks l
JOIN pg_stat_activity a USING (pid)
WHERE l.locktype = 'relation'
  AND l.relation = 'public.orders'::regclass
ORDER BY l.granted DESC, xact_age DESC;

Interpretation: during the copy, the REPACK backend holds ShareUpdateExclusiveLock. At the end, it shows AccessExclusiveLock with granted = false until older transactions on the table finish. A waiting ACCESS EXCLUSIVE request also makes later lock requests queue behind it, so one long transaction holds up both the swap and new queries.

Not MVCC-safe

The REPACK page warns that after the repacking transaction commits, "the table will appear empty to concurrent transactions, if they are using a snapshot taken before repacking started". A long REPEATABLE READ or SERIALIZABLE transaction, such as a reporting job, that first reads the table after the swap gets zero rows with no error. A pg_dump started earlier locks the table, so it delays the swap instead. Do not overlap a rebuild with either.

What does REPACK (CONCURRENTLY) need before it will run?

The REPACK reference lists the cases where CONCURRENTLY cannot be used: the relation is not a table (a materialized view, for example), the table is UNLOGGED, partitioned, a system catalog or TOAST table, uses an access method other than heap, or is marked user_catalog_table. It also refuses a table that "lacks a primary key and index-based replica identity", a run inside a transaction block, and a run when max_repack_replication_slots leaves no room for another replication slot.

Two more rules apply to every REPACK. It refuses a table with an invalid index, which you must drop or rebuild first. You also need the MAINTAIN privilege on the table, so a maintenance role does not have to be the owner or a superuser.

sql
SELECT c.relname,
       c.relkind,         -- 'r' = ordinary table; 'p' (partitioned) is not allowed
       c.relpersistence,  -- 'p' = permanent; 'u' (UNLOGGED) is not allowed
       am.amname,         -- must be 'heap'
       c.relreplident,    -- 'd' = default (uses the primary key), 'i' = USING INDEX
       EXISTS (SELECT 1 FROM pg_index i
               WHERE i.indrelid = c.oid AND i.indisprimary)   AS has_pk,
       EXISTS (SELECT 1 FROM pg_index i
               WHERE i.indrelid = c.oid AND NOT i.indisvalid) AS has_invalid_index
FROM pg_class c
JOIN pg_am am ON am.oid = c.relam
WHERE c.oid = 'public.orders'::regclass;

Interpretation: you want no invalid index and either a primary key or relreplident = 'i'. Without a primary key, set a unique index on NOT NULL columns with ALTER TABLE ... REPLICA IDENTITY USING INDEX.

Replication slots and WAL level

max_repack_replication_slots "specifies the maximum number of replication slots for use of the REPACK command", defaults to 5, and can only be set at server start (replication settings). Each concurrent run needs one of these slots, so with the default, at most five rebuilds can run at once on a server.

PostgreSQL 19 no longer needs wal_level = logical for this. With replica (the default), the server raises the effective level to logical while any logical slot exists, and the read-only effective_wal_level setting reports it. The initializing phase of a concurrent run covers that switch.

sql
SHOW wal_level;                     -- replica (default) or logical
SHOW effective_wal_level;           -- logical while any logical slot exists
SHOW max_repack_replication_slots;  -- default 5; changing it needs a restart

Interpretation: with wal_level = minimal, logical decoding is not available. While a rebuild runs, expect extra WAL from logical-level records and from rewriting the table and its indexes; standbys and the archive must absorb it.

Disk space and other limits

The REPACK reference says you "need free disk space at least equal to the sum of the table size and the index sizes". A sort for USING INDEX can add up to the table's size again, and concurrent mode also stores the row data of concurrent changes on disk. The command fails if more than about 104 million rows are updated or deleted concurrently during one run.

sql
SELECT pg_size_pretty(pg_table_size('public.orders'))          AS table_with_toast,
       pg_size_pretty(pg_indexes_size('public.orders'))        AS indexes,
       pg_size_pretty(pg_total_relation_size('public.orders')) AS minimum_free_space;

Interpretation: minimum_free_space is the floor. Compare it with free space on the data volume, and add room for WAL growth and spilled change data.

How does REPACK (CONCURRENTLY) compare with pg_repack?

Both copy the live rows, replay changes made during the copy, and swap at the end. They differ in how they capture changes and where they lock. The pg_repack documentation describes a log table fed by a trigger on every INSERT, UPDATE, and DELETE, a copy, an index build, a replay, and a catalog swap. It holds ACCESS EXCLUSIVE "for a short period during initial setup" and again for the swap and drop.

VACUUM FULL, pg_repack, and REPACK (CONCURRENTLY) side by side

Switch tabs to compare locks, change capture, and requirements

VACUUM FULL

AspectVACUUM FULL (and plain REPACK)
Available inCore, every supported version; deprecated in 19 docs
Lock while copyingACCESS EXCLUSIVE: reads and writes wait
Exclusive lock at startYes, held for the whole run
Exclusive lock at swapYes, still the same lock
Concurrent changes captured byNot needed: writes are blocked
Key requirementNone
Extra diskTable plus its indexes
Installed asBuilt-in command

pg_repack

Aspectpg_repack 1.5.3
Available inPostgreSQL 9.5 to 18 in 1.5.3 docs; 19 listed in the development docs
Lock while copyingSHARE UPDATE EXCLUSIVE (1.5.3; ACCESS SHARE before)
Exclusive lock at startBrief ACCESS EXCLUSIVE to add the trigger and log table
Exclusive lock at swapBrief ACCESS EXCLUSIVE to swap and drop
Concurrent changes captured byTrigger writing to a log table
Key requirementPrimary key, or a unique index on a NOT NULL column
Extra diskAbout twice the table plus its indexes
Installed asExtension plus a client binary of the same version

REPACK (CONCURRENTLY)

AspectREPACK (CONCURRENTLY), PostgreSQL 19 RC 1
Available inPostgreSQL 19 only (pre-GA in November 2026)
Lock while copyingSHARE UPDATE EXCLUSIVE
Exclusive lock at startNo; the initializing phase waits for open write transactions instead
Exclusive lock at swapBrief ACCESS EXCLUSIVE for the last changes and the file swap
Concurrent changes captured byLogical decoding through a replication slot
Key requirementPrimary key, or an index-based replica identity
Extra diskTable plus its indexes, plus spilled change data
Installed asBuilt-in command

The real difference: pg_repack adds write overhead through a trigger on every changed row and needs two exclusive locks. REPACK (CONCURRENTLY) reads changes from WAL and needs one, but it only exists from PostgreSQL 19.

Three operational differences matter more than the table suggests.

  • Waiting for the lock. pg_repack --wait-timeout (default 60 seconds) bounds each exclusive lock wait, then cancels conflicting queries, or skips the table with --no-kill-backend. REPACK documents no such option. A session lock_timeout applies to lock waits in general; if it fires at the swap, the copy is thrown away.
  • Lock upgrade. The open items page lists "What to do about concurrent repack lock upgrade?" as Won't Fix for 19, so a deadlock at the final upgrade can still cancel a nearly finished rebuild.
  • DDL during the run. pg_repack blocks DDL on the table. REPACK (CONCURRENTLY) "might fail to complete if DDL commands are executed on the table". Freeze migrations either way.

When should you keep using pg_repack?

In November 2026, pg_repack is still the default for most production fleets. Keep it in these cases.

  • PostgreSQL 18 and earlier. REPACK does not exist before 19, and no minor release will add it.
  • Managed services that are not on 19. GA is not due until 19 November 2026, and managed platforms add new majors later. Amazon RDS lists pg_repack 1.5.3 for its current PostgreSQL 17 and 18 minor versions and for its PostgreSQL 19 preview builds through RC 1 (RDS extension versions). Later, also check that your provider lets you change max_repack_replication_slots.
  • Partitioned parents. REPACK (CONCURRENTLY) rejects a partitioned table. pg_repack accepts --parent-table. On 19, you can run REPACK (CONCURRENTLY) per leaf partition; confirm that on staging first.
  • Jobs that rely on --no-kill-backend. If maintenance jobs skip a table rather than wait for a lock, keep them until rewritten and tested.
bash
# PostgreSQL 18 and earlier: confirm the client matches the extension
psql -d appdb -Atc "SELECT extversion FROM pg_extension WHERE extname = 'pg_repack'"
pg_repack --version

# Dry run first, then the real repack with a bounded lock wait
pg_repack --dbname=appdb --table=public.orders --dry-run
pg_repack --dbname=appdb --table=public.orders \
  --wait-timeout=30 --no-kill-backend

Interpretation: if the versions differ, pg_repack fails with an error such as "program 'pg_repack V1' does not match database library 'pg_repack V2'". With --no-kill-backend, a table whose lock is not free within 30 seconds is skipped. pg_repack's development docs list PostgreSQL 19 as supported since May 2026, but the latest tagged release is 1.5.3, so check which build your packager ships for 19.

How do you confirm bloat before rebuilding a table?

Rebuilding is the last step. A full rewrite costs I/O, WAL, and disk, and the bloat returns if its cause remains. Start with the statistics views, then measure the suspect table.

sql
SELECT relname, n_live_tup, n_dead_tup,
       round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 1) AS dead_pct,
       last_autovacuum, last_vacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;

Interpretation: these counters are estimates. High dead_pct with a recent last_autovacuum suggests something stops vacuum removing rows; with an old one, look at autovacuum settings. Neither shows how much space a rebuild returns.

sql
CREATE EXTENSION IF NOT EXISTS pgstattuple;

SELECT pg_size_pretty(table_len)                      AS table_size,
       round(dead_tuple_percent::numeric, 1)          AS dead_pct,
       round(approx_free_percent::numeric, 1)         AS free_pct,
       pg_size_pretty(approx_free_space)              AS free_space
FROM pgstattuple_approx('public.orders');

Interpretation: per the pgstattuple documentation, pgstattuple_approx uses the visibility map to skip all-visible pages; dead-tuple figures are exact and free space is estimated. High free_pct with low dead_pct is space a rebuild can return. High dead_pct means run a plain VACUUM first.

sql
-- What is holding back the xmin horizon?
SELECT pid, state, now() - xact_start AS xact_age, backend_xmin,
       left(query, 60) AS query
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY age(backend_xmin) DESC
LIMIT 5;

Interpretation: an old backend_xmin, a stale replication slot, or a forgotten prepared transaction stops vacuum removing dead rows. Fix that first, or the rebuilt table bloats again. This check matters twice for REPACK (CONCURRENTLY), because its initializing phase waits for every transaction that has been assigned a transaction ID to end.

Use the right tool for indexes

If only the indexes are bloated, REINDEX CONCURRENTLY rebuilds them without rewriting the table. REPACK always rewrites the table and every index on it.

How do you run REPACK (CONCURRENTLY) safely?

This runbook assumes PostgreSQL 19 and a table that passed the checks above. Rehearse it on staging with production-like writes first.

Online rebuild runbook for PostgreSQL 19

Six steps; step 4 takes the only exclusive lock, at its very end

  1. Measure the bloat

    Rebuild only when free space is large and the cause is fixed. High dead tuples call for VACUUM first.

    SELECT dead_tuple_percent, approx_free_percent FROM pgstattuple_approx('public.orders');
  2. Clear long transactions and DDL

    Open write transactions stall the start, readers of the table delay the swap, and old snapshots can read an empty table afterwards. Pause migrations on the table.

    SELECT pid, now() - xact_start AS age FROM pg_stat_activity WHERE xact_start < now() - interval '10 min';
  3. Run the pre-flight checks

    Primary key or replica identity index, no invalid indexes, a free repack slot, and free disk of at least table plus indexes.

    SELECT pg_size_pretty( pg_total_relation_size('public.orders'));
  4. Run REPACK (CONCURRENTLY)

    Brief lock at swap

    Run it outside a transaction block, from a session that will not time out.

    psql -d appdb -v ON_ERROR_STOP=1 \ -c "SET statement_timeout = 0" \ -c "REPACK (CONCURRENTLY, VERBOSE) public.orders"
  5. Watch progress and the transaction ID

    Follow the phase in pg_stat_progress_repack. A long run holds back vacuum on other tables.

    SELECT phase, heap_blks_scanned, heap_blks_total FROM pg_stat_progress_repack;
  6. Verify and refresh statistics

    Compare the size with step 3, run ANALYZE, and re-measure free space.

    ANALYZE public.orders; SELECT pg_size_pretty( pg_total_relation_size('public.orders'));

Main risk: the command can fail after most of the work is done, through a deadlock at the lock upgrade, concurrent DDL, or too many concurrent changes. Nothing is swapped until step 4 commits, so the original table stays in place, but the time and I/O are lost.

Reading the progress view

The progress reporting docs name two phases to watch: initializing, which can outlast the longest transaction running at the start, and catch-up, which includes the wait for the final lock.

sql
SELECT p.pid, p.phase,
       round(100.0 * p.heap_blks_scanned / nullif(p.heap_blks_total, 0), 1) AS scanned_pct,
       p.heap_tuples_updated, p.heap_tuples_deleted,
       age(a.backend_xid) AS xid_age,
       now() - a.xact_start AS running_for
FROM pg_stat_progress_repack p
JOIN pg_stat_activity a USING (pid);

Interpretation: heap_tuples_updated and heap_tuples_deleted only advance in catch-up, while concurrent writes are replayed. A run stuck in catch-up with few new changes is waiting for its lock; check pg_locks. The command counts as one long transaction for vacuum, so a rising xid_age means bloat is building elsewhere.

Frequently asked questions

Does REPACK CONCURRENTLY replace pg_repack?

On PostgreSQL 19, it covers the main pg_repack use case, an online table rebuild, without an extension. It reads concurrent changes from WAL through logical decoding instead of a trigger, and takes one brief exclusive lock instead of two. pg_repack is still needed on PostgreSQL 18 and earlier, on platforms not yet on 19, and for partitioned parent tables.

Does REPACK CONCURRENTLY lock the table?

Yes, but only briefly at the end. It holds a SHARE UPDATE EXCLUSIVE lock while it copies and catches up, so reads and writes continue but DDL and vacuum on the table wait. It then takes an ACCESS EXCLUSIVE lock to apply the last captured changes and swap the files. Long transactions on the table delay that final lock.

Is VACUUM FULL removed in PostgreSQL 19?

No. The PostgreSQL 19 documentation marks the FULL option as deprecated and says it makes VACUUM behave like REPACK without a USING INDEX clause. CLUSTER also remains, as the equivalent of REPACK USING INDEX. Existing scripts keep working, but new runbooks should use REPACK.

Can I use REPACK CONCURRENTLY on Amazon RDS?

Not in production as of November 2026. PostgreSQL 19 is not GA, and Amazon RDS offers the 19 release candidate only in its Database Preview Environment. RDS lists pg_repack 1.5.3 for PostgreSQL 17, 18, and its 19 preview builds, so pg_repack remains the online option there for now.

Key takeaways

  • REPACK and REPACK (CONCURRENTLY) are in PostgreSQL 19 RC 1 and survived the beta 4 reverts. GA is planned for 19 November 2026, so recheck the final release notes.
  • Plain REPACK is VACUUM FULL under a new name, and REPACK ... USING INDEX is CLUSTER. Only CONCURRENTLY avoids the long exclusive lock.
  • Before a concurrent run, check for a primary key or replica identity index, invalid indexes, a free repack slot, free disk of at least the table plus its indexes, and long transactions.
  • Keep pg_repack on PostgreSQL 18 and earlier, on managed services without 19, and for partitioned parents.
  • Measure bloat with pgstattuple_approx and fix the cause before rebuilding; for index-only bloat, use REINDEX CONCURRENTLY.

Plan table rebuilds with JusDB

JusDB's PostgreSQL performance tuning team can review your bloat measurements and autovacuum settings, find what is holding back cleanup, and help you choose between VACUUM, REINDEX CONCURRENTLY, pg_repack, and REPACK (CONCURRENTLY) for each table.

Bring the table sizes, the output of the bloat queries above, your PostgreSQL versions and hosting platform, and your maintenance windows. We can help you plan and rehearse the rebuild; your team approves every production change.

Share this article

Database engineering notes

Articles like this one, in your inbox. No spam, unsubscribe anytime.

JusDB Team

Official JusDB content team

Keep reading

CloudNativePG: Running PostgreSQL on Kubernetes in Production

Run PostgreSQL on Kubernetes with CloudNativePG 1.30: a production Cluster manifest, synchronous replication, failover, Barman Cloud Plugin backups, PITR, PgBouncer, and upgrades.

PostgreSQL18 minNov 3, 2026
Read

PostgreSQL Advisory Locks: Patterns and Pitfalls

Use PostgreSQL advisory locks safely: session vs transaction locks, key choice, singleton jobs, leader election, and the PgBouncer, LIMIT, and lock-table traps.

PostgreSQL18 minOct 27, 2026
Read

PostgreSQL Lock Waits: How to Find the Blocking Query and Fix It

Find the session behind a PostgreSQL lock wait with pg_blocking_pids, see why a waiting ALTER TABLE stalls every SELECT, and choose between cancel, terminate, and timeouts.

PostgreSQL18 minOct 27, 2026
Read