deniz.in

Markets

Weather

Loading weather

· via dev.to (home feed)

PostgreSQL 19 REPACK CONCURRENTLY cuts bloat cleanup lock stalls from five seconds to under one

A dev.to benchmark on PostgreSQL 19 beta 4 shows REPACK CONCURRENTLY holding clients up for under a second versus nearly the whole VACUUM FULL run, at the cost of extra WAL and a replication slot per job.

PostgreSQL 19 REPACK CONCURRENTLY cuts bloat cleanup lock stalls from five seconds to under one

What REPACK changes about bloat

In PostgreSQL, removing rows does not make a table file smaller. Regular VACUUM flags the vacated space as reusable internally, but the operating system never sees those bytes returned. The two established ways to actually shrink a relation have been VACUUM FULL, which holds an ACCESS EXCLUSIVE lock on the table for the entire rewrite, or the third-party pg_repack extension for an online rebuild. PostgreSQL 19 adds a built-in third path: a REPACK command whose CONCURRENTLY option rewrites the table while normal traffic continues, reserving the exclusive lock for a brief final swap.

How the test was run

According to a benchmark published on dev.to, run against PostgreSQL 19 beta 4, the author built a table holding two million rows of 200-byte text and deleted every even-numbered row, removing one million of them. The relation measured 531 MB both before and after the deletions, even following a plain VACUUM, and both VACUUM FULL and REPACK CONCURRENTLY brought it down to 266 MB. The end state is identical; the interesting part is what concurrent clients experienced on the way there.

While each command ran, a background client looped a single-row UPDATE and SELECT against the table, with any wait beyond 250 ms counted as blocked. Over three trials per command, the client accumulated 4.35 to 5.31 seconds of blocked time under VACUUM FULL, essentially the whole statement, since that command keeps its exclusive lock until the rewrite finishes. Under REPACK CONCURRENTLY the same client was blocked between 0.35 and 0.93 seconds. The statements themselves took a comparable 3 to 6 seconds of wall-clock time in both cases.

The mechanism behind the difference: REPACK CONCURRENTLY copies the table in the background and uses logical decoding to capture every change applied to the original while the copy is in flight, so the exclusive lock only appears when the files are swapped at the end.

Contention and the honest number

Rerunning with ten clients instead of one, the worst per-client stall under REPACK CONCURRENTLY was 0.28 seconds, barely worse than the single-client case, against 5.44 seconds for VACUUM FULL. Summed across all ten clients, blocked time was 2.73 seconds versus 54.4. The author flags the aggregate figure as the one that belongs in a capacity plan: many short stalls arriving at the same moment is a different kind of production event than one client pausing once.

What the speed costs

The lock-time improvement is paid for in WAL. With pg_stat_wal reset before each run, VACUUM FULL wrote 655 to 783 MB for the same 531-to-266 MB rewrite, while REPACK CONCURRENTLY wrote 816 MB with little variance, roughly 4 to 25 percent more depending on the VACUUM FULL run. The overhead comes from the decoding worker replaying concurrent changes onto the new copy, and that replay passes through WAL like any other write. For systems where WAL shipping to replicas is the tight resource, the online variant pushes more bytes down that pipe.

Temporary space, by contrast, showed no meaningful gap in this test: peak data-directory growth was 276 MB for REPACK CONCURRENTLY against 279 MB for VACUUM FULL. The documentation warns of an extra temp file for rows written to the old table during the copy, but the author's write load, a few thousand single-row updates per second, apparently never filled it enough to notice, and heavier write rates could change that picture.

Slots and visibility

Each concurrent repack runs its own logical decoding session and needs a free slot, governed by max_repack_replication_slots, which defaults to 5. With the setting pinned to 1 and two repacks on different tables started 150 ms apart, the second failed within 5 milliseconds with a specific error advising the operator to free a slot or raise the setting, while the first ran to completion unaffected. Those slots draw on the same capacity as max_wal_senders and max_replication_slots, so maintenance jobs that repack many tables in parallel need the same sizing discipline as replication itself.

PostgreSQL 19 also adds a progress view, pg_stat_progress_repack, exposing the current phase and counters such as heap_blks_scanned against heap_blks_total, enough to compute an actual completion percentage rather than watching a process refuse to exit. Worth noting the author's own correction along the way: coarse polling of pg_stat_activity initially suggested only one repack could run per server at a time, but verification showed both running simultaneously, with the slot count being the real gate.

Why it matters

Bloat cleanup has long forced a choice between a table-wide lockout and an external tool. These numbers, with per-client lock stalls collapsing from around five seconds to well under one, turn that into a much easier decision for routine maintenance, provided the WAL overhead and slot budget are accounted for. As with any single-author benchmark on beta software, the results cover one table shape and a moderate write rate, but the progress view and the explicit, fast-failing behavior when slots run out are features operators planning a PostgreSQL 19 upgrade can factor in regardless.

  • #postgresql
  • #database
  • #performance
  • #postgres-19
  • #database-ops