Postgres 19 REPACK vs pg_repack vs VACUUM FULL: Online Table Maintenance Compared

Postgres 19 REPACK vs pg_repack vs VACUUM FULL: Online Table Maintenance Compared

Postgres 19 REPACK vs pg_repack vs VACUUM FULL: Online Table Maintenance Compared

Your telemetry table reports 2.1 TB on disk. A row count times average row width says it should hold about 1.3 TB. That 800 GB gap is bloat, and every sequential scan, every index lookup and every backup now pays for it. Postgres 19 REPACK is the first credible in-core answer to that problem: a REPACK command with a CONCURRENTLY option that rewrites a table without locking out writers for the duration. One caveat up front, because it changes what you should do this quarter: PostgreSQL 19 is still in beta. Beta 3 shipped on 13 August 2026 and remains the current beta as of 21 September 2026. It is not generally available, and the project explicitly advises against running betas in production.

So this is a planning article as much as a technical one. You will leave knowing why bloat accumulates at the tuple level, how to measure whether you actually have a problem, and how the three rewrite options really differ on locking, disk, WAL and failure recovery.

What this covers: MVCC bloat mechanics, index bloat and cache hit rates, bloat measurement queries, a three-way comparison of VACUUM FULL, pg_repack and in-core REPACK, the partitioning alternative for time-series tables, parallel autovacuum as prevention, and the features that got reverted out of 19.

Context and Background

For most of PostgreSQL’s history, reclaiming disk space from a bloated table meant choosing between two bad options. VACUUM FULL rewrites the relation into a fresh file and gives the space back to the operating system, but it holds an ACCESS EXCLUSIVE lock for the entire rewrite. On a multi-terabyte table that is hours of total unavailability, not a maintenance blip. CLUSTER has the same lock profile and additionally reorders rows by an index.

The community’s practical answer was pg_repack, an out-of-tree extension descended from pg_reorg. It does the rewrite online by installing a trigger that logs concurrent changes into a side table, building a replacement table and indexes, replaying the log, and finally swapping the files under a brief exclusive lock. It works, it is widely deployed, and it carries real operational sharp edges — an extension that must be installed server-side, a hard requirement for a primary key, and a cleanup burden when a run is interrupted.

PostgreSQL 19 folds that capability into the server. The new REPACK command absorbs what VACUUM FULL and CLUSTER did, and adds CONCURRENTLY for the online variant. Critically, the in-core version does not use triggers at all — it captures concurrent changes through logical decoding, which is a meaningfully different engineering trade-off from the extension it replaces.

The timing matters for anyone running IoT telemetry on Postgres. Append-mostly time-series tables bloat in a specific, predictable way, and as we covered in the Postgres 18 versus TimescaleDB and ClickHouse comparison for IoT workloads, the maintenance story is often what decides whether plain Postgres stays viable at scale. Before you plan a migration around REPACK, it is worth understanding that the best answer for a 2 TB telemetry table is frequently to not repack it at all.

Why Your Table Is Bloated: The MVCC Mechanism

Bloat is dead space inside a relation’s files. PostgreSQL’s multi-version concurrency control never modifies a row in place: an UPDATE writes a new tuple version and marks the old one dead. Plain VACUUM makes that dead space reusable within the relation but almost never shrinks the file. The relation stays big on disk.

Postgres 19 REPACK context diagram showing how MVCC dead tuples and index page splits produce table bloat

Figure 1: How an UPDATE turns into permanent on-disk bloat, through both the heap and the indexes.

The diagram traces two paths from a single UPDATE. On the heap side, the old tuple version becomes dead, VACUUM marks its space free for reuse, and the file only shrinks if that free space happens to sit at the physical end. On the index side, stale entries linger, B-tree pages split, and the split pages never merge back — which is why index bloat behaves differently from heap bloat and needs to be measured separately.

Dead tuples and why VACUUM rarely returns space

When you update a row, Postgres writes a complete new tuple version, typically into the same page if there is room, and sets the old version’s transaction metadata so that it becomes invisible once no running snapshot can see it. VACUUM later scans the table, finds those dead tuples, removes their index entries, and records the freed space in the relation’s free space map. New inserts and updates can then use it.

What VACUUM does not do is give the space back to the filesystem. There is exactly one exception: if the trailing pages of the relation are entirely empty, VACUUM can truncate them. That truncation is controlled by the vacuum_truncate setting, and the documentation notes something operators frequently miss — the truncation itself requires an ACCESS EXCLUSIVE lock on the table. It is a brief lock, and vacuum will yield it under contention, but it is not free.

The practical consequence is that truncation almost never helps a busy table. Dead space is scattered through the relation, not conveniently concentrated at the end. A table that ballooned to 2 TB during a bad batch job stays 2 TB even after autovacuum catches up. The space is reusable, so the table will not keep growing at the same rate, but the file is permanently that size until something rewrites it.

This is the distinction that decides whether you need a rewrite at all. Reusable free space is not a problem if the table’s steady-state working set will absorb it. It becomes a problem when the ratio is extreme — a table where 60% of the pages are dead space forces sequential scans to read 2.5 times more data than necessary, and pushes live rows out of shared buffers.

HOT updates and the fillfactor trade

Heap-Only Tuple updates are the mechanism that keeps well-behaved tables from bloating their indexes. When an UPDATE changes no indexed column, and there is enough free space on the same page to hold the new version, Postgres writes the new tuple into that page and links it to the old one with a forward pointer. No index entry is created. Index lookups land on the old tuple’s line pointer and follow the chain.

Two conditions must hold, and both are within your control. The first — no indexed column changes — is a schema question. An index on a frequently updated column, particularly a last_seen_at or status field, silently disables HOT for every update that touches it. Auditing indexes against your actual update patterns is often the highest-leverage bloat work available, and it costs nothing but a code review.

The second condition is free space on the page, which is what fillfactor controls. It is a table storage parameter expressing the percentage of each page that inserts may fill, and it defaults to 100 for heap tables. At 100, a freshly inserted page has no headroom, so the first update to any row on it cannot be HOT. Setting it to 85 or 90 on an update-heavy table reserves space for in-page versions.

The trade is direct and quantifiable. A fillfactor of 85 makes the table roughly 18% larger at rest, because you are deliberately storing less data per page. In exchange you get in-page update headroom that avoids both index churn and the page-migration that produces scattered bloat. For an append-mostly telemetry table that is rarely updated, this is a bad trade — you pay the 18% and get nothing. For a mutable state table it is usually worth it.

Index bloat and the buffer cache tax

Index bloat is the part teams underestimate, because it degrades performance through a channel that does not look like a disk-space problem. B-tree indexes in PostgreSQL split pages when they fill, and those pages do not merge back when entries are deleted. A page that once held 300 entries and now holds 30 stays a full 8 KB page in the index.

The damage shows up in the buffer cache. Shared buffers hold pages, not rows. An index whose pages are 10% occupied needs roughly ten times as many page reads to traverse the same number of entries. Those pages consume cache slots, evicting genuinely useful pages, and your cache hit rate falls across the whole workload — not just for queries using that index.

This is why a bloated index can produce a mysterious, system-wide latency increase that no single slow query explains. You see shared_blks_read climbing in pg_stat_statements for queries that have nothing to do with the bloated table. The mechanism is cache displacement.

The good news is that index bloat has a cheap fix that does not require a table rewrite. REINDEX CONCURRENTLY, available since PostgreSQL 12, rebuilds an index without blocking reads or writes for the bulk of the operation. If measurement shows your heap is fine and only the indexes are bloated — a very common pattern on append-mostly tables with a few hot updated columns — reindexing is the correct answer and none of the three rewrite options in this article apply.

Measure First: Do You Actually Have a Problem?

Many teams reaching for REPACK do not have a bloat problem. They have a slow query, they read that bloat causes slow queries, and they skip straight to the rewrite. Measure first. The cheapest diagnostic is the one built into vacuum’s own statistics.

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,
       autovacuum_count
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC
LIMIT 20;

These counters are estimates maintained by the statistics collector, and they measure currently-dead tuples rather than historical bloat. A table that bloated last month and has since been vacuumed shows a low n_dead_tup while still occupying an oversized file. Treat this query as a check on whether autovacuum is keeping up, not as a bloat measurement.

For a real measurement, use the pgstattuple contrib extension, which scans the relation and reports exact figures.

CREATE EXTENSION IF NOT EXISTS pgstattuple;

SELECT * FROM pgstattuple('public.sensor_readings');
SELECT * FROM pgstatindex('public.sensor_readings_ts_idx');

pgstattuple returns free_percent and dead_tuple_percent, which together give you the recoverable fraction. pgstatindex returns avg_leaf_density — the figure that matters for index bloat. A healthy B-tree built by REINDEX sits near 90% leaf density, since the default index fillfactor is 90. A density below 50% means you are reading roughly twice the pages you need to.

The cost is the catch: pgstattuple performs a full scan of the relation. On a 2 TB table that is a multi-hour read that competes with production traffic. Use pgstattuple_approx() instead, which samples and relies on the visibility map, returning an estimate in a fraction of the time. Run the exact version only on indexes, or on a restored backup.

Reading the numbers without over-reacting

A useful decision rule: a heap where pgstattuple reports under 20% combined free and dead space is not worth rewriting. The rewrite costs you a full read and write of the relation, roughly an equivalent volume of WAL, and replication lag on every standby. Recovering 15% of a 2 TB table is 300 GB, which sounds like a lot until you price the I/O and the risk.

Above 40%, a rewrite usually pays for itself, particularly if sequential scans or index-only scans are a meaningful part of the workload. Between 20% and 40%, the deciding factor is whether the bloat is stable or growing. Stable bloat at 30% is a table that found its equilibrium — the free space is being reused, and a rewrite buys you a one-time saving that will slowly re-accumulate. Growing bloat at 30% means autovacuum is losing, and the rewrite treats a symptom while the cause remains.

Decision flow for choosing between VACUUM FULL, pg_repack and Postgres 19 REPACK based on measured bloat

Figure 2: The triage path. Most branches end somewhere other than a table rewrite.

Figure 2 encodes that triage. Notice how many paths exit before reaching a rewrite command at all: bloat under the threshold, a partitioned table where dropping partitions is the answer, or an index-only problem solved by REINDEX CONCURRENTLY. When autovacuum is losing, the fix is tuning autovacuum_vacuum_cost_delay and autovacuum_vacuum_scale_factor, not a rewrite you will need to repeat in three months.

The Three-Way Comparison

Here is the summary before the detail. All three commands do the same fundamental thing — read every live tuple, write it into a new file, rebuild the indexes, swap. They differ in what they lock, for how long, what they require, and how they fail.

Dimension VACUUM FULL pg_repack REPACK (CONCURRENTLY)
Availability In core, every version Extension, server-side install PostgreSQL 19, beta as of Sept 2026
Lock held ACCESS EXCLUSIVE, entire rewrite ACCESS EXCLUSIVE at setup and swap; SHARE UPDATE EXCLUSIVE in between ACCESS EXCLUSIVE for the swap only
Change capture None — table is locked AFTER trigger into a log table Logical decoding via a replication slot
Table requirement None Primary key or UNIQUE NOT NULL index Primary key or index-based replica identity
Disk headroom Table plus index sizes; up to 2x table with sort Roughly 2x table plus indexes Table plus indexes, plus a temp file for in-flight DML
Partitioned tables Repacks each partition Supported via --parent-table CONCURRENTLY not allowed; use per-partition
Managed services Always available Only if the provider ships it Available once the provider offers 19
Interrupted run Clean rollback May leave triggers, log tables, invalid indexes Server-side rollback, slot released

VACUUM FULL: simple, total, and often correct

VACUUM FULL acquires an ACCESS EXCLUSIVE lock and holds it until the rewrite completes. Nothing reads the table, nothing writes to it. On a 2 TB relation with several large indexes, expect hours.

It gets dismissed too quickly. It has no dependencies, no extension, no replication slot, no trigger, and no cleanup story — if it fails, the transaction rolls back and the new file is discarded. If you genuinely have a maintenance window, it is the fastest of the three, because it does not pay the overhead of capturing and replaying concurrent changes.

The lock is also more dangerous than its duration suggests, for a reason that applies to all three approaches. ACCESS EXCLUSIVE conflicts with every other lock mode, and PostgreSQL’s lock queue is ordered. When VACUUM FULL waits behind a long-running query, every subsequent query on that table queues behind the waiting VACUUM FULL — including plain SELECTs that would otherwise have run fine. A single forgotten analytics query turns a maintenance operation into a full outage of that table. Always set lock_timeout before issuing any rewrite command.

SET lock_timeout = '5s';
VACUUM (FULL, VERBOSE, ANALYZE) sensor_readings;

pg_repack: the incumbent, and its sharp edges

pg_repack performs a seven-step dance. It creates a log table, installs an AFTER trigger on the original table that records every INSERT, UPDATE and DELETE into that log, builds a new table containing all current rows, builds indexes on it, applies the accumulated log entries, swaps the tables and indexes through the system catalogs, and drops the original.

The ACCESS EXCLUSIVE lock is taken twice: briefly at the start to install the trigger, and again at the end for the swap and drop. In between, as of version 1.5.3, it holds SHARE UPDATE EXCLUSIVE, which permits normal DML but blocks DDL on the target table.

Three failure modes deserve attention. First, the lock stampede. pg_repack defaults to a 60-second --wait-timeout for its exclusive locks, and when that expires it cancels the conflicting queries. If they persist, it escalates to pg_terminate_backend() after twice the timeout. That is a sensible default for a tool whose job is to finish, and a genuinely surprising one the first time it kills a production query. --no-kill-backend makes it skip the table instead.

Second, interrupted runs leave debris. The documentation is explicit: after a fatal error you clean up by hand, via DROP EXTENSION pg_repack CASCADE followed by CREATE EXTENSION pg_repack. Symptoms include a warning that the table already has a repack_trigger, or a leftover invalid index that the tool refuses to drop on your behalf. On a write-heavy table where a run was killed mid-flight, that orphaned trigger keeps writing to a log table nobody is draining.

Third, and most relevant right now: pg_repack 1.5.3 lists support for PostgreSQL 9.5 through 18. It does not yet claim PostgreSQL 19. Anyone planning an upgrade needs to know whether their maintenance tooling follows them across the version boundary.

REPACK in core: logical decoding instead of triggers

The in-core command’s syntax is worth reading precisely, because it differs from what you might guess:

REPACK [ ( option [, ...] ) ] [ table_and_columns [ USING INDEX [ index_name ] ] ]
REPACK [ ( option [, ...] ) ] USING INDEX

Options are VERBOSE, ANALYZE and CONCURRENTLY. Plain REPACK employees behaves like VACUUM FULL. Adding USING INDEX employees_ind reorders rows physically, which is what CLUSTER did. REPACK (CONCURRENTLY) employees USING INDEX does it online, reusing the previously configured clustering index.

The mechanism is the real differentiator. Rather than a trigger, CONCURRENTLY captures the changes that occur during the copy using logical decoding, replaying them before it requests the ACCESS EXCLUSIVE lock for the swap. A new max_repack_replication_slots setting governs the slot pool available for this, defaulting to 5 and settable only at server start.

Sequence diagram of Postgres 19 REPACK CONCURRENTLY capturing writes through a logical replication slot

Figure 3: REPACK CONCURRENTLY captures concurrent writes through a logical slot and replays them before the brief swap lock.

Figure 3 shows why this matters operationally. Change capture happens inside the server, in the WAL stream that already exists. There is no user-visible trigger to leave behind, no log table to orphan, and no separate client process holding connections open. If the command fails, ordinary transaction abort handling cleans up, including releasing the slot.

Three concrete advantages follow. Managed services are the biggest: REPACK is a SQL command available to anyone holding the MAINTAIN privilege on the table, so it works on any managed Postgres that ships version 19, with no extension allowlist to negotiate. Second, progress is observable through the new pg_stat_progress_repack view, alongside the existing vacuum and cluster progress views. Third, crash safety is the server’s own, not a client tool’s cleanup routine.

The restrictions are specific and you should check them before planning around the feature. CONCURRENTLY cannot be used when the table is UNLOGGED, when it is partitioned, when it lacks both a primary key and an index-based replica identity, when the target is a system catalog or TOAST table, when REPACK runs inside a transaction block, or when max_repack_replication_slots has no slot available.

The 2 TB Time-Series Table: Do Not Repack It

Everything above assumes a monolithic table. For the IoT telemetry workloads this site’s readers run, that assumption is usually the actual bug.

Comparing a monolithic 2 TB telemetry table against a time-partitioned table for bloat maintenance

Figure 4: Partition retention replaces the rewrite entirely. Dropping a partition unlinks a file; repacking reads and writes two terabytes.

Consider how an append-mostly telemetry table bloats. Rows arrive continuously, are rarely updated, and are deleted in bulk when they age past retention. That bulk DELETE is the bloat source: it marks millions of tuples dead, scattered across whatever pages they landed on. Autovacuum reclaims the space for reuse, the file never shrinks, and you end up with a relation whose size reflects peak retention rather than current data.

Range partitioning by time changes the problem’s shape entirely. Instead of deleting rows, you DROP or DETACH the partition covering the expired window. There is no tuple-by-tuple delete, no dead tuples, and effectively no bloat. DROP TABLE on a partition is a catalog operation plus a file unlink — seconds of work, near-zero WAL, and no measurable standby impact.

CREATE TABLE sensor_readings (
    device_id   bigint       NOT NULL,
    reading_ts  timestamptz  NOT NULL,
    metric      text         NOT NULL,
    value       double precision,
    PRIMARY KEY (device_id, reading_ts)
) PARTITION BY RANGE (reading_ts);

CREATE TABLE sensor_readings_2026_09
    PARTITION OF sensor_readings
    FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');

-- Retention: unlink the file instead of deleting rows
DROP TABLE sensor_readings_2026_08;

Compare the two retention strategies concretely. Deleting one month from a 2 TB unpartitioned table might touch 80 GB of rows, generate WAL proportional to every tuple deleted, force autovacuum to scan and clean the affected pages, and leave the file exactly as large as before. Dropping an 80 GB partition writes a handful of catalog rows and unlinks files. The WAL difference alone is roughly four orders of magnitude.

There is one wrinkle relevant to this article: REPACK CONCURRENTLY cannot target a partitioned table directly. Plain REPACK on a partitioned table repacks each partition in turn, and cannot run inside a transaction block. If you need the online variant, you issue it per partition. In practice that is fine and arguably better — you repack the one hot partition that receives updates, and leave the sealed historical ones alone.

This is also where the choice between plain Postgres and a purpose-built time-series engine gets decided. Our comparison of InfluxDB, TimescaleDB and ClickHouse for IoT time-series walks through where each one’s storage model stops fighting you. If you are repacking a telemetry table quarterly, the honest assessment is that partitioning should have come first and the engine choice deserves revisiting.

Trade-offs, Gotchas, and What Goes Wrong

REPACK CONCURRENTLY is not MVCC-safe. The documentation places it alongside TRUNCATE and the table-rewriting forms of ALTER TABLE. After the rewrite commits, a concurrent transaction holding a snapshot taken before the commit will see the table as empty. This only affects transactions that had not touched the table before the command started, since any that had would hold an ACCESS SHARE lock and block the swap. The practical risk is a long-running repeatable-read report that reads other tables first and reaches this one after the swap — it sees no rows and reports zero, silently.

Disk headroom is not a single number. When REPACK uses an index scan or an unsorted sequential scan, it needs free space equal to the table size plus the index sizes. When it chooses a sequential scan followed by a sort, peak requirement rises to roughly double the table size plus the indexes. You can force the cheaper path by setting enable_sort = off for the session, at the cost of speed. CONCURRENTLY adds a further increment, because tuples inserted into the old file while the copy is still running are staged in a temporary file until they can be applied.

WAL volume and standby lag scale with the rewrite. A full rewrite writes every live tuple and every index entry into new files, all of it WAL-logged. Rewriting 1.3 TB of live data generates WAL on that order, which ships to every standby. If your standbys are on constrained network links, or you run synchronous replication, the rewrite becomes a replication event, not just a local one. Repack during a low-traffic window even when the command is “online”, and watch pg_stat_replication throughout.

Concurrent DDL breaks the online path. The REPACK docs warn that CONCURRENTLY may fail to complete if other transactions execute DDL against the table during the operation. pg_repack blocks DDL outright with its SHARE UPDATE EXCLUSIVE lock. Either way, pause your migration tooling.

The feature set is still moving. Between 8 and 16 September 2026, fifteen commits on the 19 branch touched REPACK and REPACK CONCURRENTLY — five of them narrowing what the command accepts, including restricting it to the heap access method, refusing it on user catalog tables, and disallowing it when the replica identity index has been dropped. That is normal beta hardening, and it is also a reason not to write runbooks against Beta 3 semantics.

Prevention: Parallel Autovacuum, and What Got Cut

The other half of PostgreSQL 19’s story is prevention. A new autovacuum_max_parallel_workers setting lets a single autovacuum worker recruit helpers to process a table’s indexes in parallel. It is the autovacuum equivalent of the PARALLEL option that manual VACUUM already had.

Read the scope carefully, because the name oversells it. The parallelism applies specifically to the index vacuuming and index cleanup phases, each worker handling one index. Heap scanning and heap vacuuming stay single-threaded. The default is 0, meaning disabled, and the effective count is further capped by max_parallel_workers.

That scope tells you exactly who benefits: tables with many indexes, or with expensive ones such as GIN and GiST, where index cleanup dominates the vacuum cycle. A telemetry table with two B-tree indexes will see close to nothing. A wide dimension table with a dozen indexes may see its autovacuum cycle time fall substantially.

Budget the memory before enabling it. Worst case, a busy cluster consumes autovacuum_max_workers × autovacuum_max_parallel_workers × maintenance_work_mem. With three autovacuum workers, four parallel workers each and a 1 GB maintenance_work_mem, that is 12 GB. PostgreSQL 19 also adds autovacuum prioritization, with a family of score-weight settings such as autovacuum_vacuum_score_weight and autovacuum_freeze_score_weight that tune how the daemon ranks candidate tables.

What got cut from 19

The beta cycle has been unusually subtractive, and the pattern is worth reading if you are planning around the release. SQL/PGQ property graphs — CREATE PROPERTY GRAPH and GRAPH_TABLE — were reverted on 7 September 2026, removing 47 commits. The feature will not ship in 19, and the earliest realistic target is 20.

It was not alone. As Command Prompt’s release-watch summary for 8–16 September 2026 documents, six reverts in that window named 74 commits between them. UPDATE and DELETE ... FOR PORTION OF, the SQL:2011 temporal feature, was reverted on 15 September. Online data checksum transitions went on 16 September, with the commit message stating the reasoning plainly: postcommit fixes during beta raised suspicions of more issues surfacing after GA, so the code was pulled rather than shipped. Three DDL-reconstruction functions and two CREATE SCHEMA changes also came out.

Read that as a quality signal rather than a bad omen. A project willing to remove a feature three weeks before its target GA date is a project you can trust with a 2 TB table. It is also a concrete reason to confirm the final release notes rather than any beta-era feature list, including this one, before you commit to an upgrade plan.

Practical Recommendations

Start by measuring, because the most likely correct answer is that you do not need any of these commands. Run pgstattuple_approx() on the suspect table and pgstatindex on its largest indexes. If dead and free space is under 20%, tune autovacuum and stop.

If only the indexes are bloated, REINDEX CONCURRENTLY solves it without touching the heap, on every supported version. This covers more real cases than teams expect, particularly on append-mostly tables.

If the table is time-series and unpartitioned, partitioning is the durable fix and every rewrite you perform before doing it is work you will repeat. Convert to range partitioning, move retention from DELETE to DROP TABLE, and the bloat problem stops recurring.

Only then choose a rewrite. On version 18 or older, that means a maintenance window with VACUUM FULL if you can get one, or pg_repack if you cannot — with --no-kill-backend set unless you have consciously decided that cancelling production queries is acceptable. On 19, once it reaches GA, REPACK (CONCURRENTLY) is the better tool for anything that cannot go offline, particularly on managed services.

Before any rewrite:

  • Confirm free disk space exceeds the table size plus all index sizes, with headroom for a possible sort.
  • Set lock_timeout in the session so a queued ACCESS EXCLUSIVE request cannot stall every reader.
  • Check for long-running transactions and pause migration tooling for the duration.
  • Watch pg_stat_replication for standby lag as WAL volume climbs.
  • Run ANALYZE afterwards, or pass the ANALYZE option, since the planner’s ordering statistics are now stale.
  • Re-measure with pgstattuple_approx() to confirm the space actually came back.

Treat PostgreSQL 19 as a planning input, not a deployment target, until the release notes are final. Test REPACK CONCURRENTLY against a restored copy of your real table now, so the operational surprises land in staging.

Frequently Asked Questions

Is PostgreSQL 19 released yet?

No. As of 21 September 2026, PostgreSQL 19 is in beta. Beta 3 was released on 13 August 2026 and remains the current beta on the project’s beta information page. The roadmap still lists the major release as planned for September 2026, and reporting suggests the release management team is targeting GA by the end of October, but no GA date has been formally announced. The project advises against running beta versions in production.

Does REPACK replace pg_repack completely?

For most workloads on version 19, yes. In-core REPACK (CONCURRENTLY) covers the same ground without an extension install, captures concurrent changes through logical decoding rather than triggers, and leaves no debris when interrupted. The gaps are narrow: CONCURRENTLY cannot target a partitioned table directly, and it will not work on unlogged tables or tables lacking a primary key or index-based replica identity. pg_repack 1.5.3 also does not yet list PostgreSQL 19 support.

How much disk space does REPACK need?

At minimum, free space equal to the table size plus the sum of its index sizes, because temporary copies of both are built before the swap. If REPACK chooses a sequential scan followed by a sort rather than an index scan, peak requirement rises to roughly double the table size plus the indexes. Setting enable_sort = off for the session forces the cheaper path at some cost in speed. CONCURRENTLY adds a further increment for staging in-flight DML.

Why does VACUUM not free disk space?

Plain VACUUM marks dead tuples’ space as reusable within the relation and records it in the free space map, but it does not shrink the file. It can only return space to the operating system by truncating entirely empty pages at the physical end of the relation, and that truncation itself requires a brief ACCESS EXCLUSIVE lock. Because dead space is normally scattered throughout a busy table rather than concentrated at the end, truncation rarely recovers much.

Will parallel autovacuum fix my bloat?

Only indirectly, and only for some tables. autovacuum_max_parallel_workers parallelises the index vacuuming and index cleanup phases, not heap scanning or heap vacuuming. It helps when a table has many indexes or expensive ones and index cleanup is the bottleneck in the vacuum cycle. It defaults to 0. If autovacuum is falling behind because of cost-based delays or an aggressive write rate, tuning autovacuum_vacuum_cost_delay and the scale factors matters more.

Should I repack a 2 TB time-series table?

Usually not. Repacking a table that size means reading and rewriting every live tuple, generating comparable WAL volume, and shipping it all to your standbys. If the bloat comes from bulk deletes at retention boundaries, the durable fix is range partitioning by time, so that retention becomes DROP TABLE on a partition instead of a mass DELETE. Dropping a partition is a catalog change plus a file unlink, and it generates almost no WAL.

Further Reading

By Riju — about

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *