Skip to content

Why Killing Postgres VACUUM Means Rewriting the Indexes

zheap tried undo logs and stalled. OrioleDB's bet is that you have to version the key space too.

Ji-ho Choi
Ji-ho Choi
Security & Cloud Editor · Aug 19, 2026 · 4 min read
Why Killing Postgres VACUUM Means Rewriting the Indexes

Every serious attempt to fix Postgres's MVCC bloat problem has died in the same place: the indexes. That's the real lesson buried in a recent deep-dive on OrioleDB's concurrency design by Franck Pachot, and it explains both why zheap failed and why OrioleDB had to rebuild far more of Postgres than anyone wanted to.

The short version: multi-version concurrency isn't just about keeping old row values around. It's about keeping the key space consistent — an old row version is worthless if a scan running at an old snapshot can no longer find it. Postgres's heap solves this brutally: every UPDATE writes a whole new tuple, every index gets a new entry pointing at it, and VACUUM cleans up the corpses later. Undo-log designs that only fix the table half of that equation don't actually fix the problem.

The part zheap couldn't fix

PostgreSQL's append-only heap is the root cause of a familiar cluster of pain: table bloat, index write amplification, VACUUM falling behind on churn-heavy workloads, transaction ID wraparound anxiety. It's the architecture Uber famously cited when it left Postgres in 2016, and HOT updates only paper over it when no indexed column changes and the page has spare room.

zheap, started at EnterpriseDB in 2018, was the direct assault: rewrite rows in place, push old versions to an undo log, reconstruct history on demand — the Oracle and InnoDB playbook. But it was built entirely inside the table access method API. Indexes were untouched, which meant index entries still pointed at heap slots whose contents changed underneath them, and the design still needed cleanup passes to deal with stale index entries. The project stalled and was effectively abandoned. InnoDB, notably, doesn't escape this either — it keeps delete-marked secondary index entries around until purge threads remove them. Undo logs move the garbage; they don't eliminate it.

OrioleDB's founder, Postgres committer Alexander Korotkov, drew the obvious-in-hindsight conclusion: you can't fix MVCC at the table layer alone. You have to make the indexes version-aware too.

Versioning the key space

OrioleDB throws out the heap entirely. Tables are index-organized — rows live in the leaf pages of the primary key B-tree, like InnoDB or SQL Server clustered indexes. On top of that it runs two kinds of undo: row-level undo to reconstruct old tuple values, and page-level undo to reconstruct what a B-tree leaf — including its key ranges — looked like at an older snapshot. That second one is the novel piece. A scan at an old snapshot can walk the tree as it existed then, which is what finally makes "no VACUUM" a coherent claim rather than a slogan.

The rest of the architecture follows from taking that seriously: copy-on-write checkpoints instead of full-page writes, row-level WAL (secondary indexes are rebuilt from primary-key changes during recovery, so they don't need their own logging), and a dual-pointer scheme where in-memory pages link directly to each other, bypassing the shared buffer mapping table that's a known scalability chokepoint on big machines. Supabase's benchmarks claim roughly 5x throughput over heap on TPC-C-style workloads — a vendor number, so salt accordingly, but the architectural reasons to expect large wins on update-heavy churn are real.

What it costs

Nothing here is free. Native support covers B-tree indexes only. GIN, GiST, BRIN — and by extension things like pgvector's HNSW — go through "bridge indexes" that map a synthetic identifier to the primary key. That means an extra hop per lookup and, ironically, the return of stale index entries needing cleanup for exactly those index types. If your workload leans hard on full-text search or vector indexes, OrioleDB's core advantage partially evaporates.

The bigger catch for anyone wanting to try this: OrioleDB is not a plain extension. It requires a patched Postgres, because the stock table access method API — the thing zheap helped shape — still can't express undo-based MVCC, alternative WAL, or index-organized storage cleanly. The patches are submitted upstream and under review, but until they land, you run OrioleDB's builds or images, not your distro's Postgres, and certainly not RDS or Cloud SQL.

The current release, beta16 (June 2026), supports Postgres 16, 17, and 18 and finally added SERIALIZABLE isolation — a reminder of how much surface area a storage engine has to re-earn. The project's own guidance is still experiments and benchmarking, not production.

Who should care, and when

If you're running an update-heavy OLTP workload where autovacuum tuning is a recurring incident theme — queue tables, counters, session state, high-churn SaaS tenants — this is worth an afternoon on a load-test box. Adoption surface is genuinely small: pull the Docker image, CREATE EXTENSION orioledb, then CREATE TABLE ... USING orioledb per table, so you can A/B a single hot table against heap with your real write mix. Watch for anything that assumes heap internals — ctid-based tricks, pg_repack, some logical decoding setups.

My read: this is a genuine architectural shift, not hype — the first credible answer to Postgres's oldest structural weakness, precisely because it stopped pretending the problem could be fixed one layer at a time. But production use for most teams is realistically a couple of years out, gated on the upstream patches and on managed platforms doing the operational vetting. Supabase, which acquired OrioleDB in 2024 and pledged its patent to the community, is the obvious first venue and the clearest winner if this works.

The quieter prize is upstream. Even if you never type USING orioledb, those pluggable-storage patches — battle-tested by a real engine instead of designed by committee — are what could finally make Postgres a database where the storage engine is a choice rather than a destiny. That's the InnoDB moment Postgres has never had, and it's closer than it's ever been.

Sources & further reading

  1. OrioleDB Multi-Version Concurrency Control — dev.to
  2. OrioleDB Architecture Overview — orioledb.com
  3. OrioleDB Releases — github.com
  4. Oriole joins Supabase — supabase.com
Ji-ho Choi
Written by
Ji-ho Choi · Security & Cloud Editor

Ji-ho covers the increasingly tangled overlap between cloud architecture and security, drawing on a background as a penetration tester to keep his reporting grounded in real-world attack paths. He never lets a vendor claim go unquestioned and insists that every buzzword come with a proof of concept.

Discussion 5

Join the discussion

Sign in or create an account to comment and vote.

Greg Tanaka @golang_greg · 13 hours ago

indexes are the real hard problem here. postgres just punt it with bloat, orioledb actually solves it. of course it costs everything else.

Russ Holloway @devops_dadjokes · 5 hours ago

we burned a month on this exact thing during our migration to OrioleDB. greg's right that it solves it, but the cost isn't abstract — we had to rewrite all our custom index logic because the versioning model changed beneath it. worth it in the end, but yeah, it's the kind of technical debt that looks free until you're knee-deep in it.

Dee Robinson @data_eng_dee · 3 hours ago

yeah this tracks. the sneaky part is that versioning the keyspace makes backfills way simpler downstream, but getting there feels like rewriting everything twice. curious how your custom indexes ended up performing post-migration.

Oleg Petrov @db_nerd_oleg · 15 hours ago

yeah, this is the thing that bit us hard when we tried optimizing a write-heavy workload last year. we thought we could defer index maintenance to a background job, but then queries at older snapshots would start mysteriously missing rows because the index entries didn't exist yet. ended up rolling back and just accepting the index churn. the constraint that every version needs to be findable via index scans is sneaky—it's not a performance problem you can optimize away without fundamentally changing how snapshots work.

Hal Mercer @greybeard_unix · 9 hours ago

yep, that's the trap. we had a similar disaster in 2008 trying to batch index updates—suddenly you're chasing phantom reads that only show up under concurrent load at 3am. the real kicker is that 'background job' sounds cheap until you realize you've just invented a new consistency model nobody understands, and now you're debugging it forever. postgres's brute-force approach is ugly but at least the semantics are obvious: write the tuple, write the index, done.

Related Reading