Every MVCC Engine Fails. Postgres Just Fails Out Loud
The garbage bill for concurrent reads and writes is conserved — each database only chooses who pays, when, and how it breaks.
"Postgres MVCC is broken" is practically its own genre of engineering blog post. Uber's 2016 migration-to-MySQL write-up built the template; Andy Pavlo's 2023 "the part of PostgreSQL we hate the most" essay gave it academic cover. Radim Marek's new piece on boringSQL runs the argument in reverse — and lands on something the genre has mostly ignored: the costs of multi-version concurrency control are conserved. Every engine that lets readers and writers coexist generates garbage at write speed. The only real design decisions are where the garbage lives, who cleans it up, and what breaks when cleanup falls behind.
That framing holds up under scrutiny, and it's worth internalizing before your next "should we leave Postgres" conversation.
The same four questions, different bills
Every MVCC implementation answers the same questions: where old row versions live, which direction version chains point, whether indexes reference physical locations or logical keys, and whether cleanup is a background job or the writer's problem. PostgreSQL answers: in the table, old-to-new, physical pointers, background vacuum. That yields its famous pathologies — dead tuples bloating the heap, updates touching every secondary index unless a HOT update fires, one idle REPEATABLE READ session holding back the xmin horizon for the whole cluster, and the 32-bit transaction ID wraparound that took Sentry's hosted service down in 2015 despite aggressive autovacuum tuning.
Marek's own benchmarks put numbers on the sore spots: updating 100,000 rows cost about 3 WAL records per row on a lightly indexed table and 7.1 with four secondary indexes; a rolled-back million-row update doubled a table from 89 MB to 178 MB. Rolled back — the work was worthless, the garbage stayed.
But run the same audit on the alternatives and the bill doesn't disappear; it moves. Oracle and InnoDB keep versions in undo segments, which keeps tables compact and secondary indexes untouched — and in exchange, rollback costs roughly as much as the forward work (Postgres aborts in microseconds), long reports get slower as they chase undo chains, and an idle snapshot produces ORA-01555: snapshot too old or a ballooning undo tablespace. SQL Server's optional row versioning parks versions in tempdb, where one forgotten transaction can fill the disk and start failing queries instance-wide. WiredTiger keeps versions in RAM until cache pressure recruits your application threads into eviction work — a latency smear instead of an error. LSM-based systems like CockroachDB write timestamped versions and compact later, which is vacuum by another name: overwritten values used to survive 25 hours by default (now 4, since v23.1), and heavy delete churn produces the classic antipattern of a queue table that gets slower the emptier it is. Even etcd — a tiny consensus store — ships manual compaction, a defrag command that is VACUUM FULL in a trench coat, and a quota that blocks Kubernetes control-plane writes when garbage wins.
The most delicious data point: SQL Server 2019's Accelerated Database Recovery, a significant engineering effort whose headline achievement was instant rollback — the one property Postgres's much-maligned design has had for free since day one.
Where the argument overreaches
Conservation is the right frame, but it shouldn't become absolution, because the costs are conserved in aggregate — not per workload. Write amplification is the charge that bites hardest and evens out least. On an update-heavy table with several secondary indexes, Postgres genuinely does more physical work than an undo-log engine, and HOT updates are a probabilistic mitigation, not a guarantee: Marek measured only 42% of updates going HOT with default page packing, 66% at steady state. Uber's complaint was real. If your workload is "millions of small updates to wide, heavily indexed rows," Postgres makes you pay retail.
The honest conclusion isn't "every engine is equally fine." It's that switching engines swaps failure modes, and you should pick the failure mode you can operate. Postgres fails loudly and observably: dead tuples are countable in pg_stat_user_tables, wraparound distance is a query away, bloat sits on disk where you can see it. Undo-log and version-store engines fail quietly and then suddenly — a report that worked for months starts throwing snapshot too old, or tempdb fills at 3 a.m. Loud failure modes are monitorable failure modes. For most teams, that's the better trade.
What to actually do about it
If you run Postgres, the entire pathology chain starts with one thing: something holding the xmin horizon back. Watch it directly:
SELECT pid, state, age(backend_xmin) AS xmin_age
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY xmin_age DESC;
Set idle_in_transaction_session_timeout in production — there's no good reason for an idle-in-transaction session to live forever. For hot update tables, drop fillfactor to ~70 to give HOT updates room, and avoid indexing columns you rewrite constantly, since any indexed-column change disqualifies HOT entirely. For queue tables, stop fighting MVCC: DELETE is the most expensive way to make a table smaller, and partition-drop or TRUNCATE-based rotation sidesteps the tombstone treadmill that afflicts Postgres and LSM engines alike.
Then watch the roadmap, because the escape hatches are finally materializing. PostgreSQL 19 (beta since June) ships REPACK, which folds VACUUM FULL and CLUSTER into one command with a CONCURRENTLY option — online bloat recovery without the access-exclusive lock, in core, no pg_repack extension required. OrioleDB, the undo-log storage engine Supabase acquired, is the credible successor to the stalled zheap effort: no vacuum, undo-based versioning, index-organized tables — but it's still beta and still needs core patches, so treat it as a preview of Postgres circa 2028, not a migration target. The 64-bit transaction ID patchset that would retire wraparound outright remains one of the community's longest-running works in progress.
The verdict
Marek's piece deserves to end the "just switch engines" reflex. The garbage bill is a law of nature for concurrent databases; engines only choose the currency. Postgres charges you in ops discipline, paid in advance, with dashboards. Its competitors charge you in runtime errors, paid at the worst possible moment. If your workload is the pathological one — narrow, hot, over-indexed rows updated millions of times an hour — fix the schema first, and only then price out an engine whose failure modes you understand as well as you now understand vacuum. That's a much shorter list than the blog posts suggest.
Sources & further reading
- PostgreSQL's MVCC is bad. So is everyone else's. — boringsql.com
- Transaction ID Wraparound in Postgres — blog.sentry.io
- Oriole joins Supabase — supabase.com
- Lower default GC TTL to 4h — github.com
- Looking Forward to Postgres 19: The New REPACK Command — pgedge.com
Lenn writes about cloud platforms, Kubernetes internals, and the infrastructure decisions that quietly make or break engineering organizations. Based in Berlin's vibrant tech scene, they have a talent for turning dense platform-engineering topics into prose that people actually finish reading.
Discussion 0
No comments yet
Be the first to weigh in.