All Posts
SaaS Platform

Postgres 19 for Multi-Tenant SaaS: Skip REPACK, Tune Autovacuum

Every Postgres 19 roundup leads with REPACK CONCURRENTLY. For a shared-schema multi-tenant SaaS it is the wrong feature — and the one that matters ships disabled by default.

11 min read

Postgres 19 reaches general availability around September or October, and every roundup so far leads with the same feature: REPACK (CONCURRENTLY), a built-in online table rebuild that retires the pg_repack extension. If you run a shared-schema multi-tenant SaaS, that is the wrong headline. Read the documentation and REPACK (CONCURRENTLY) cannot run on partitioned tables, is not MVCC-safe, and consumes a replication slot for the duration — three constraints that rule it out of exactly the tables where multi-tenant bloat collects. The feature that will actually change your on-call rotation is the one nobody is leading with: parallel autovacuum and the new prioritization scoring. It ships turned off. Your noisiest tenant is a maintenance problem, not a query problem, and Postgres 19 is the first release that hands you a continuous fix instead of a better emergency tool.

Why this matters now

The release timeline is short enough to plan against. PostgreSQL 19 Beta 2 shipped on July 16, with the project targeting general availability around September or October. That is the planning window, and it is closing.

There is a harder date behind it. The May minor release — which fixed eleven CVEs across every supported branch — carried a notice that gets less attention than it deserves: PostgreSQL 14 stops receiving fixes on November 12, 2026. If you are on 14, you are not choosing whether to move, only where to land. If you are on 15 through 18, you get to decide, and deciding well means knowing which Postgres 19 features are actually about your workload.

Most of the Postgres 19 highlight list is developer experience — SQL/PGQ property graphs, GROUP BY ALL, jsonpath string functions, FOR PORTION OF on UPDATE and DELETE. Good things. None of them are why a multi-tenant SaaS upgrades.

The tenant nobody can query their way out of

Here is the shape of the problem in a shared-schema design, where every row carries a tenant_id. One customer runs ten times the write volume of anyone else. Their churn lands in the same physical tables as everyone else's. Dead tuples accumulate, the table bloats, index scans degrade, and support starts collecting "the app got slow" tickets from tenants who did nothing wrong. The instinct is to treat this as a query problem: add an index, tune a plan, add a read replica.

The arithmetic says otherwise. autovacuum_vacuum_scale_factor defaults to 0.2 — twenty percent of the table. On a shared events table at 500 million rows, autovacuum does not consider the table eligible until roughly a hundred million dead tuples have piled up, and autovacuum_vacuum_max_threshold caps the trigger at exactly 100,000,000 anyway. Meanwhile autovacuum_max_workers still defaults to 3. Three workers, and one of them is pinned to your largest table for hours while everything else waits its turn.

That is not a query problem. That is a single-threaded maintenance process losing a race against one tenant's write rate.

What Postgres 19 actually changes

Two things, and the order matters. The first is autovacuum_max_parallel_workers, a new setting that lets a single autovacuum worker use parallel workers. Read the documentation closely, because the scope is narrower and better targeted than the headline suggests: it applies specifically to the index vacuuming and index cleanup phases, not the heap scan. That is the multi-tenant shape exactly. A hot table in a shared-schema design carries the primary key, a (tenant_id, created_at) composite, one or two natural-key indexes, and a foreign-key index per relationship — six to ten indexes on the busiest table is unremarkable. Index cleanup is where the hours go, and index cleanup is what now parallelizes.

The default is 0. The Postgres 19 feature most likely to fix your bloat problem does nothing at all on a default install, and the actual worker count is capped again by max_parallel_workers. An upgrade alone buys you none of this.

The second change is autovacuum prioritization. Postgres 19 adds a scoring system with per-component weights — autovacuum_vacuum_score_weight, autovacuum_freeze_score_weight, autovacuum_analyze_score_weight, and two more, all defaulting to 1.0 — so the daemon picks the most urgent table rather than working through candidates in whatever order it finds them. With three workers and a hundred tables, ordering is most of the outcome.

Why I would not upgrade for REPACK

REPACK is a genuinely good addition. It absorbs VACUUM FULL and CLUSTER into one command, and the CONCURRENTLY option holds the ACCESS EXCLUSIVE lock only long enough to swap files, capturing intervening writes through logical decoding. For a single large unpartitioned table, that is a real improvement over a maintenance window.

For multi-tenant SaaS, read the restriction list in the REPACK documentation before you plan around it. CONCURRENTLY cannot be used when the table is partitioned, when the table lacks a primary key and index-based replica identity, when the table is UNLOGGED, inside a transaction block, or when max_repack_replication_slots will not allow another slot. The documentation also carries an explicit warning that REPACK with CONCURRENTLY is not MVCC-safe.

Partitioning is how mature multi-tenant systems isolate their largest tenants and age out old data, so that constraint bites precisely where you need it. You can repack individual partitions, which helps, but the "just rebuild the bloated table online" story does not survive contact with a partitioned schema.

There is a deeper reason not to lead with it. REPACK is a better emergency tool; parallel autovacuum is a better steady state. If you are reaching for an online table rebuild often enough that the feature changes your quarter, the real problem is that autovacuum is losing — and a faster way to clean up afterward is treating the symptom.

Measure it before you plan the upgrade

You cannot decide whether Postgres 19 is worth a migration slot without knowing which of your tables are actually in maintenance debt and how many indexes they carry. This is read-only, and it runs against whatever version you are on today.

// tenant-bloat-triage.ts — read-only. Ranks tables by the maintenance debt
// that drives "the app got slow" tickets in a shared-schema multi-tenant
// database, and flags which tables Postgres 19's parallel autovacuum can
// help (it parallelizes the INDEX vacuum phases, not the heap scan).
// Run: DATABASE_URL=... node --experimental-strip-types tenant-bloat-triage.ts
import { Pool } from 'pg'

const TRIAGE = `
  SELECT
    c.relname                                          AS table_name,
    pg_size_pretty(pg_total_relation_size(c.oid))      AS total_size,
    s.n_live_tup,
    s.n_dead_tup,
    ROUND(100.0 * s.n_dead_tup
          / NULLIF(s.n_live_tup + s.n_dead_tup, 0), 1) AS dead_pct,
    (SELECT count(*) FROM pg_index i
      WHERE i.indrelid = c.oid)                        AS index_count,
    -- Dead tuples required before autovacuum even CONSIDERS this table.
    -- On PG18+ this is capped again by autovacuum_vacuum_max_threshold.
    (current_setting('autovacuum_vacuum_threshold')::bigint
       + current_setting('autovacuum_vacuum_scale_factor')::float8
         * s.n_live_tup)::bigint                       AS trigger_at,
    s.last_autovacuum
  FROM pg_stat_user_tables s
  JOIN pg_class c ON c.oid = s.relid
  WHERE s.n_live_tup + s.n_dead_tup > 0
  ORDER BY s.n_dead_tup DESC
  LIMIT 20;
`

const pool = new Pool({ connectionString: process.env.DATABASE_URL })
const { rows } = await pool.query(TRIAGE)

for (const r of rows) {
  // Bloated AND index-heavy: PG19's autovacuum_max_parallel_workers pays
  // off here, because index cleanup is the phase that parallelizes.
  const parallelWin = Number(r.dead_pct) > 10 && Number(r.index_count) >= 5

  // Dead tuples near the trigger with no recent autovacuum: the daemon is
  // ALREADY losing this race. Do not wait for the upgrade — fix it today:
  //   ALTER TABLE <table> SET (autovacuum_vacuum_scale_factor = 0.02);
  const losing = Number(r.n_dead_tup) > Number(r.trigger_at) * 0.8

  console.log(
    [
      String(r.table_name).padEnd(28),
      String(r.total_size).padStart(10),
      `${r.dead_pct}% dead`.padStart(14),
      `${r.index_count} idx`.padStart(8),
      parallelWin ? 'PG19-PARALLEL-WIN' : '',
      losing ? 'AUTOVACUUM-LOSING' : '',
    ].join('  '),
  )
}

await pool.end()

The ALTER TABLE in that second comment matters more than the upgrade does. Most teams I audit have never lowered autovacuum_vacuum_scale_factor on their largest table, which means they are running the default twenty-percent threshold against a table where twenty percent is a hundred million rows. That is a one-line fix available on every supported version, and it beats waiting for September.

Four questions before you schedule the upgrade

  1. Which table has the worst dead-tuple ratio, and how many indexes does it carry? If the answer is "our biggest shared table, nine indexes," parallel autovacuum is your feature and you should budget for it. If it is "our biggest shared table, two indexes," the win is smaller than the release notes suggest.
  2. Is that table partitioned? If yes, REPACK (CONCURRENTLY) is off the table for the parent and you plan per-partition. If no, ask why not before you ask about Postgres 19.
  3. What are max_parallel_workers and autovacuum_max_workers set to today? Parallel autovacuum draws from the same pool as every other parallel operation. Turning it on without raising the pool just relocates the contention.
  4. When does your current major version stop receiving fixes? For PostgreSQL 14 that is November 12, 2026, which makes this a Q4 delivery item rather than a Q1 nice-to-have.

My perspective

I run Keaz as roughly twelve services against Postgres, and the pattern I keep hitting in client audits is one I had to unlearn myself: teams tune queries when tenants complain, because queries are what they can see. EXPLAIN output is legible. Autovacuum's backlog is not, until you go looking for it. Meanwhile the actual failure is a maintenance process with three workers and a twenty-percent trigger threshold being asked to keep pace with a customer who writes ten times as much as anyone else.

So here is the opinion I will defend: in a shared-schema multi-tenant database, throughput problems that appear "suddenly" are almost always maintenance debt that accumulated quietly for months. Postgres 19 is worth upgrading for — but for parallel index vacuuming and prioritization scoring, not for the online rebuild everyone is writing about. Take the feature that keeps the table from bloating. The one that cleans up afterward is the consolation prize.

The design decisions that lead here sit upstream of all of it — row-level security, shared schema, and the seven ways tenant data leaks. So does the write path: the idempotent inbox that persists events and drains them in a worker is also the thing generating your dead tuples. Worth noting for that pattern specifically: Postgres 19 adds INSERT ... ON CONFLICT DO SELECT ... RETURNING, so a duplicate delivery can return the existing row instead of costing you a second round trip.

Recommended action this quarter

Run the diagnostic against production this week — it is read-only and takes seconds. Write down your worst table's dead-tuple ratio and index count, then re-run it monthly so you arrive at the Postgres 19 decision with a trend line instead of a guess. Lower autovacuum_vacuum_scale_factor on that table now; you do not need a major version to stop losing the race. When you do upgrade, set autovacuum_max_parallel_workers deliberately and raise max_parallel_workers to match, because the default of 0 means the headline feature is inert. And if you are on PostgreSQL 14, put the migration on the Q4 roadmap today — November 12 is closer than it reads. Planning that upgrade and the capacity work around it is part of the fractional-CTO and architecture work I do for teams running multi-tenant Postgres in production.

Find out what your database is actually doing

If your tenants are complaining about speed and nobody can point at the query, it is usually maintenance debt rather than a plan problem — and it is measurable in an afternoon. Book a time and we will look at your worst table together.

#Postgres#Multi-Tenant#SaaS Architecture#Autovacuum#Database Migration