The first thing you will probably learn about Postgres, if you follow people who don't like Postgres, is that MVCC is bad. The 40-year-old design mistake. It's signatures are everywhere. Bloated tables that double in size, 32-bit transaction counter limit, the never ending struggle with VACCUM, dead tuples nightmares. It comes with credentials, too: Uber measured the write amplification in 2016 and left for MySQL over it; Andy Pavlo's database group called MVCC the part of PostgreSQL they hate the most. It's a real thing. Postgres is as bad as it gets.

While none of this is exaggerated, it comes down to a real design choice. The bloat, the amplified writes, the vacuum babysitting: every charge traces to a decision, not a defect, and we reproduce each one below on a live PostgreSQL 19 beta2 instance, so you can watch the damage happen yourself. But the verdict that spreads from community to community always stops one question early: compared to what? What does every other engine do instead, and what does that cost?

Because MVCC is not optional. Any database that wants readers to not block writers has to keep multiple versions of rows somewhere, and every engine that does so answers the same four questions:

  1. Where do old versions live? In the table itself, or in a separate structure?
  2. Which way do version chains point? From old to new, or new to old?
  3. What do indexes point at? A physical row location, or a logical key?
  4. Who cleans up, and when? A background process later, or the transaction itself?

PostgreSQL's answers: in the table, old to new, physical location, background process later. Every cost the critics list follows from those four answers. And every alternative is a different set of answers with the bill sent to someone else, the writer, the reader of history, tempdb, the cache, the compactor. One of them spent years of engineering to buy the one property PostgreSQL's design has had for free since day one. All of them fail, differently, when a transaction stays open over lunch.

The charges against PostgreSQL

If you want the full mechanism, PostgreSQL MVCC, Byte by Byte walks through it with pageinspect. The short version: an UPDATE in PostgreSQL never modifies a row. It writes a complete new copy of the row into the heap, stamps the old version's t_xmax, and leaves both versions sitting on disk. Visibility is decided at read time, tuple by tuple. Cleanup is somebody else's problem, specifically VACUUM's.

Four charges follow, reproduced one at a time below.

Charge 1: write amplification

The heart of Uber's complaint. Because every index on the table points at the row's physical location (a page number plus a slot, the ctid), and an UPDATE creates a new physical row, every index needs a new entry pointing at the new location. Even indexes on columns you didn't touch.

Set up two copies of the same 1M-row table. One with just a primary key, one with four additional secondary indexes:

CREATE TABLE accounts (
  id bigint PRIMARY KEY,
  email text NOT NULL,
  status text NOT NULL,
  balance numeric NOT NULL,
  created_at timestamptz NOT NULL,
  last_seen timestamptz
);

INSERT INTO accounts
SELECT g, 'user' || g || '@example.com', 'active', 100, now(), now()
FROM generate_series(1, 1000000) g;

CREATE INDEX ON accounts (email);
CREATE INDEX ON accounts (status);
CREATE INDEX ON accounts (balance);
CREATE INDEX ON accounts (created_at);

CREATE TABLE accounts_lean (LIKE accounts);
ALTER TABLE accounts_lean ADD PRIMARY KEY (id);
INSERT INTO accounts_lean SELECT * FROM accounts;

Now update 100,000 rows, touching only last_seen. Note that last_seen is not in any index, on either table. Measure the WAL generated (pg_stat_wal, after a CHECKPOINT and pg_stat_reset_shared('wal') to get a clean window):

UPDATE accounts_lean SET last_seen = now()
WHERE id > 100000 AND id <= 200000;
 wal_records | wal_fpi | wal_bytes | wal_pretty
-------------+---------+-----------+------------
      302510 |    1419 |  38218531 | 36 MB
UPDATE accounts SET last_seen = now()
WHERE id > 100000 AND id <= 200000;
 wal_records | wal_fpi | wal_bytes | wal_pretty
-------------+---------+-----------+------------
      709440 |    1981 |  72394573 | 69 MB

Same logical change, 100,000 timestamps. The lean table produced about 3.0 WAL records per row. The indexed table produced 7.1: the heap update, plus one new entry in the primary key, plus one new entry in each of the four secondary indexes, none of which index the column we changed. They all got rewritten anyway, because the row moved and they all point at physical locations.

This is the amplification Uber measured, and it compounds: more WAL means more checkpoint work, more full-page writes, and more bytes shipped to every replica. A single-column update on a heavily indexed table is one of the most expensive things you can do per byte of useful change.

PostgreSQL's mitigation is the HOT update (Heap-Only Tuples): if no indexed column changed and the new version fits on the same page, indexes are left alone. Both conditions failed above; the pages were packed full, so every new version landed on a different page. Give the table breathing room and try again:

ALTER TABLE accounts SET (fillfactor = 70);
VACUUM FULL accounts;

UPDATE accounts SET last_seen = now()
WHERE id > 200000 AND id <= 300000;
 wal_records | wal_bytes | n_tup_hot_upd (this batch)
-------------+-----------+----------------------------
      455812 |     51 MB | 41,996 of 100,000

Better, but note the number: 42% HOT, not 100%. Each page has 30% free space and roughly 80 rows; once the first ~30 updates on a page consume the reserve, the rest of that page's rows spill elsewhere, indexes and all. Run the same batch a second time, after opportunistic pruning has recycled the dead versions in-page:

 wal_records | wal_bytes | n_tup_hot_upd (this batch)
-------------+-----------+----------------------------
      304990 |     22 MB | 66,314 of 100,000

66% HOT and 3.0 WAL records per row, nearly back to lean-table cost. That's the honest shape of the mitigation: HOT is opportunistic, arrives in steady state rather than on demand, and quietly costs you 30% of your table size in reserved space. It works, and OLTP tables in the wild routinely see 90%+ HOT ratios with well-chosen fillfactor. But the default behavior is the 7.1 records per row, and the critics are describing the default.

Charge 2: your table is an accident scene

Every UPDATE is an INSERT plus a deferred DELETE. Do nothing about it and the dead versions accumulate:

VACUUM (FULL, ANALYZE) accounts_lean;  -- reset: 89 MB, 0% dead

\timing on
BEGIN;
UPDATE accounts_lean SET balance = balance + 1;  -- all 1M rows
ROLLBACK;

SELECT pg_size_pretty(pg_relation_size('accounts_lean'));
SELECT tuple_count, dead_tuple_count, round(dead_tuple_percent)
FROM pgstattuple('accounts_lean');
BEGIN
Time: 0.032 ms
UPDATE 1000000
Time: 1590.112 ms (00:01.590)
ROLLBACK
Time: 0.124 ms

 pg_size_pretty
----------------
 178 MB

 tuple_count | dead_tuple_count | dead_pct
-------------+------------------+----------
     1000000 |          1000000 |       47

One statement, and a transaction that didn't even commit, doubled the table. A million dead tuples wait for vacuum, and as VACUUM Is a Lie covers, the indexes that grew along the way will never shrink back on their own. This is the bloat treadmill: the storage design guarantees garbage is created at write speed and collected at vacuum speed, and keeping those two rates matched is an operations job that PostgreSQL outsources to you, with autovacuum as a default-configured intern.

Charge 3: one idle transaction poisons the well

Keep the experiment going. In a second session, open a transaction and just... hold a snapshot:

-- session B
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM accounts_lean;
-- go to lunch

Back in session A, update 100,000 rows and vacuum:

INFO:  finished vacuuming "mvcc_critics.public.accounts_lean":
tuples: 14 removed, 1099945 remain,
        100000 are dead but not yet removable
removable cutoff: 718, which was 1 XIDs old when operation ended

"Dead but not yet removable." Session B's snapshot might still need those versions, so vacuum must preserve them, and not just in this table: the xmin horizon is held back for every table in the database, whether session B ever reads them or not. PostgreSQL will never cancel the reader and never make it fail.Strictly true at the MVCC level, but production setups routinely add that cancellation back in from outside: idle_in_transaction_session_timeout and statement_timeout exist specifically to kill the lunch-break transaction before it holds back the xmin horizon. It just silently stops collecting garbage, database-wide, for as long as the transaction idles. Every Postgres operator eventually learns to hunt long-running transactions and stale replication slots, usually the week after learning what n_dead_tup means.

Charge 4: the 32-bit debt

Every tuple header stores its creator transaction ID in 32 bits. Four billion transactions and the counter wraps, so PostgreSQL must periodically freeze old tuples, rewriting pages that haven't been touched in months, purely to reclaim XID space. Fall too far behind and the database stops accepting writes to protect itself. Ask Sentry, who wrote up their 2015 wraparound outage, or the several companies that have re-learned it since. But only PostgreSQL makes you periodically rewrite untouched pages to keep the clock from running out.

To be fair, Oracle's counter is finite too: the SCN was 48 bits for decades, and Oracle DBAs spent 2019 patching for SCN headroom after database links were found to jump counters forward. The eventual fix was widening the SCN to 64 bits (12.2 onward), bought at the price of a version-compatibility dance across database links.

So: charges stick. Amplified writes by default, garbage created at write speed and collected by a process you have to tune, cleanup held hostage by any idle snapshot, and a 32-bit clock that must be wound forever. PostgreSQL's MVCC is bad, in four specific and demonstrable ways.

The undo camp: Oracle and InnoDB

Oracle and MySQL's InnoDB answer the four questions the opposite way: versions live outside the table, chains point new to old, cleanup is partly the writer's problem. This is the design most "Postgres MVCC is bad" articles implicitly hold up as the fix, so it deserves the closest look.

An UPDATE in InnoDB modifies the row in place, inside the clustered index (in InnoDB, the table is a B-tree ordered by primary key). Before overwriting, it copies the old values into an undo log, a separate storage area, and stamps the row with a pointer to that undo record. Every row carries two hidden system columns for this: DB_TRX_ID, the last transaction to touch it, and DB_ROLL_PTR, the pointer into undo. Oracle's structure differs in detail (undo segments, block-level consistent reads) but the shape is identical: the table holds only the newest version, and history hangs off it in a chain of deltas.

A reader with an older snapshot finds the current row, notices DB_TRX_ID is too new, and walks the undo chain backwards, applying deltas until it reconstructs the version it's entitled to see. Oracle does this at block granularity, rebuilding a whole consistent image of the block in memory.

Look at what this buys, point by point against the four charges:

  • The table stays compact. One version per row, ever. No bloat treadmill, no pgstattuple showing 47% dead. Undo is recycled in a fixed-size ring (Oracle) or purged continuously (InnoDB).
  • Secondary indexes are spared. They point at the primary key, a logical reference, not a physical location. Update last_seen and the four secondary indexes from our experiment are untouched, because the row never moved and its key never changed. This single decision is most of the write-amplification gap Uber measured.
  • No vacuum. Cleanup is woven into normal operation: InnoDB's purge threads trim undo history continuously, Oracle recycles undo segments.
  • No wraparound rituals. InnoDB's transaction IDs are 48-bit and Oracle's SCN is 64-bit since 12.2 (the 48-bit era ended with the headroom scare from charge 4), wide enough that neither engine ever has to freeze a tuple.

Four for four. Case closed?

Here is the bill.

Rollback costs what the transaction cost. Remember our experiment: PostgreSQL rolled back a one-million-row UPDATE in 0.124 milliseconds, because abort in a heap-versioning system means "do nothing, the old versions are already in place." (It took 1.59 seconds to make the mess and 0.124 milliseconds to disown it. The mess remains, for vacuum, but your transaction is done.) In an undo-based engine, rollback means walking the undo chain and reversing every change, one row at a time, roughly as much work as the forward direction, sometimes more. Every MySQL operator eventually kills a long-running UPDATE and then watches the database spend longer rolling it back than it spent running, unkillably, because the undo must be applied. Crash mid-transaction and recovery inherits the same job before the system is fully healthy. It is precisely to escape this that SQL Server later grew a Postgres-style version store, but we're getting ahead of ourselves.

Readers pay for that history at read time, too. In PostgreSQL, an old row version is just sitting in the heap; reading it costs the same as reading anything else.The first reader of a recently committed tuple may take a detour through pg_xact to set its hint bits, a one-time tax per tuple, and then it's plain heap forever. In the undo camp, every version except the newest must be reconstructed, per read, by chasing pointers into a different part of storage and applying deltas. A long-running report against a hot table gets progressively slower as the gap between its snapshot and current state widens, and it pressures the buffer pool with undo pages while it's at it.

The idle-snapshot problem didn't go away. It changed its failure mode. Undo space is finite. When a long-running query needs a version whose undo has already been recycled, Oracle doesn't pause cleanup like PostgreSQL does. It kills the query: ORA-01555: snapshot too old, one of the most famous error codes in databases, decades of consulting hours in four digits. InnoDB makes the opposite choice within the same design: purge waits for the oldest read view, history piles up (watch the history list length climb in SHOW ENGINE INNODB STATUS), and undo tablespaces balloon.MySQL 8's automatic undo truncation does shrink tablespaces whose logs have gone inactive, but the long read that caused the growth is exactly what keeps the logs active, so the garbage stays put for as long as the reader does. That is bloat, relocated to a place pgstattuple can't see. Same fundamental tension, choose your poison: cancel the reader, or keep the garbage.

InnoDB's purge threads are, functionally, vacuum with better marketing: they scan undo history, remove delete-marked index records, and can fall behind under write bursts exactly the way autovacuum can. The war-story threads on the MySQL side (history list length in the millions, purge lag stalls) read like Postgres bloat threads with the nouns changed.

So the undo camp didn't eliminate MVCC's costs. It moved them: away from the future (background cleanup, table bloat) and onto the writer's critical path (rollback, in-place update bookkeeping) and the reader of old data (reconstruction). If your workload commits nearly always, updates heavily indexed rows, and rarely runs long reads against hot tables, that trade is genuinely better, that is, roughly, the workload Uber had. It's a real trade-off favoring a specific workload shape, not a universal upgrade.

SQL Server: versioning as an optional extra

SQL Server is the reminder that MVCC itself was a choice. Its default READ COMMITTED isn't versioned at all: readers take shared locks, writers take exclusive locks, and they block each other. The concurrency problems MVCC exists to solve are simply... allowed to happen, managed with lock hints and shorter transactions. For years the standard fix for "my readers are blocked" was WITH (NOLOCK), which is a formal apology for reading uncommitted garbage.

Since 2005, versioning is available by opt-in (READ_COMMITTED_SNAPSHOT or full SNAPSHOT isolation). Flip it on and SQL Server starts copying pre-update row versions into a version store that lives in tempdb, stamping each modified row with a version pointer.The pointer is 14 bytes, added to every versioned row, and used to chase that row's chain back through tempdb. It's an undo-style design bolted onto a locking engine, with one memorable operational property: the version store is shared, instance-wide, and lives in the same tempdb as everyone's sort spills and temp tables. One forgotten long-running snapshot transaction and the version store cannot be trimmed; tempdb grows until it runs out of disk, at which point the entire instance starts failing queries, including databases that had nothing to do with it. The idle-snapshot problem again, with the widest blast radius of any design in this post.

The most interesting SQL Server development is recent: Accelerated Database Recovery (2019, on by default in Azure SQL) added a persistent version store, and note where it lives: inside the user database, not tempdb. Row versions move into a per-database PVS, which both shrinks the instance-wide blast radius described above and makes rollback and crash recovery instant regardless of transaction size, reading old versions back instead of replaying undo. Read that again: the flagship locking-and-undo engine spent significant engineering effort to acquire the one property, constant-time abort, that PostgreSQL's much-maligned design has had for free since day one. Trade-offs run in both directions.

MongoDB: versions in the cache

MongoDB's WiredTiger engine answers the four questions with an accent you haven't seen yet: old versions live in RAM. A read runs against a snapshot taken when it starts; a writer appends its change as an in-memory delta hanging off the page's current image, and readers walk the chain back to the version their snapshot is entitled to see. Checkpoints periodically flush clean page images to disk, a journal provides durability, and, note this, coming out of the undo section, rollback is free: uncommitted versions were never on disk, so aborting a transaction means discarding memory. WiredTiger has had constant-time abort since day one, without Microsoft's engineering program.

The design buys real things: no dead tuples on disk, no undo tablespace to size, readers never blocked. The bill arrives through a single fact: the cache is finite, shared, and cannot evict a version that some snapshot still needs.

  • The oldest snapshot pins history. Versions pushed out of the cache while still visible to an old reader spill into a history store (WiredTigerHS.wt), which grows for exactly as long as the pinning reader lives. InnoDB's history list, reborn as a file: bloat, relocated to a place the MongoDB equivalent of pgstattuple has to know where to look.
  • Under pressure, everyone pays. Let the cache fill with dirty or pinned content and eviction can't keep up; WiredTiger then recruits application threads into eviction work, and the failure mode is not an error but a latency smear across every operation on the node, readers included. In the worst cases the engine starts rolling transactions back for you, cache full. The idle-snapshot problem, failing by slowdown instead of by bloat.
  • Snapshot too old, them too. Ask for a read at a timestamp older than the pinned history can serve and MongoDB answers with SnapshotTooOld. ORA-01555, again, third engine this post.
  • The physical-pointer charge survives. Documents are indexed by RecordId, a physical slot; grow a document past its space and it moves, and every secondary index updates to follow it. Uber's complaint, in a document store.

Cleanup is eviction plus checkpoints: vacuum, running continuously, in RAM, mostly invisible, until a snapshot stays open over lunch.

The LSM generation: garbage collection as a lifestyle

The distributed SQL newcomers, CockroachDB and YugabyteDB most prominently, store data in LSM trees (RocksDB or derivatives), and their MVCC answer is different again: a row version is simply a key, the logical key suffixed with a commit timestamp. Updates never touch anything in place, they just write a newer key. Reads scan for the newest version at or below their snapshot timestamp. Deletes write a tombstone key.

In one sense this is PostgreSQL's philosophy taken further: append versions, never modify, clean later. The cleanup story differs. Old versions are removed by compaction, the LSM's normal background rewriting of storage layers, governed by a garbage-collection window: versions younger than the TTL are kept for time-travel reads and follower reads; CockroachDB's default window has historically been measured in hours (originally 25, since reduced). No vacuum command exists, and the marketing will mention that. But compaction is vacuum, running continuously, paying the same deferred-cleanup tax as write amplification inside the storage engine, and the failure modes rhyme with everything above:

  • Read too far in the past, below the GC window, and the query fails. Snapshot too old, rediscovered.
  • Heavy update or delete churn on a narrow key range leaves layers of tombstones and stale versions that every scan must wade through until compaction catches up. The classic symptom is the queue table that gets slower the emptier it is, an antipattern LSM users know as intimately as Postgres users know bloated tables.

Nothing was solved. The four questions got new answers, and the costs moved into the compactor.

Outside the RDBMS entirely: etcd

Leave the relational world and the same four questions reappear, verbatim. etcd, the consensus-backed key-value store that holds the desired state of every Kubernetes cluster, is MVCC by name and by design. Every write bumps a cluster-wide revision counter, and values are stored under (key, revision) in a bbolt file. An update never overwrites; it appends a newer revision of the key. A read at a given revision gets the newest version at or below it. A delete writes a tombstone. If that sounds like the LSM section you just read, it should. And if it sounds like the four charges at the top of this post, keep reading.

  • Versions live in the store; cleanup is a separate job. Old revisions sit in the same backend file as current data until compaction removes them, run by hand (etcdctl compact) or on a schedule (--auto-compaction-retention). Skip it and the backend grows without bound: garbage created at write speed, collected at compaction speed. The bloat treadmill, minus the SQL.
  • Cleanup doesn't return the disk. bbolt, like PostgreSQL's heap, never shrinks its file; compacted pages go to an internal freelist for reuse. Getting the space back means etcdctl defrag, run member by member, each node briefly stalling while it rewrites: the VACUUM FULL ritual, performed in front of a quorum.
  • "Snapshot too old," rediscovered verbatim. Read or watch from a revision older than the compaction point and etcd fails you: etcdserver: mvcc: required revision has been compacted. ORA-01555 with four fewer digits, surfacing in every Kubernetes cluster whose controllers resume a watch from a stale resourceVersion.
  • Exceed the default 2GB backend quota and etcd raises NOSPACE and refuses writes, including the Kubernetes control plane's, until a human compacts, defrags, and disarms the alarm. It's the wraparound outage's closest non-relational cousin: a safety limit that turns a storage-hygiene chore into an outage, with the babysitting outsourced to the operator.

etcd answered the four questions almost exactly the way PostgreSQL did: append versions, never modify, clean later, never cancel a reader early. It inherited the same operations calendar. The questions predate the relational model's dominance and will outlive it; there is no escaping the bill, only choosing who pays it.

Its distributed-KV neighbor FoundationDB answers the lunch question more brutally than anyone in this post: transactions have a hard five-second lifetime, full stop. Hold a transaction open over lunch and you don't hold back a vacuum, a purge, or a compactor, your transaction is simply dead. It's the one design here that made the idle-transaction problem the client's problem, by fiat.

Fixing PostgreSQL itself

If the undo design were straightforwardly better, the obvious move would be to give PostgreSQL an undo-based storage engine. People tried. zheap, announced in 2018, was exactly that: in-place updates with undo logs, targeting the bloat problem head-on. It produced years of work at EnterpriseDB, a general undo framework proposed for core, a second life at CYBERTEC, and then stalled; the code is effectively dormant. The idea is not: when EDB's Álvaro Herrera sketched Postgres circa 2029 in his PGConf.DE 2026 keynote, the first in-core table access method on the wishlist was "zheap (no need for vacuum)". Note the verb tense, though: could have, in ten years. The honest lesson from zheap so far is that the heap's simplicity is load-bearing: once versions live in undo, every subsystem (recovery, replication, indexes, hot standby) inherits new invariants, and you end up rebuilding half the engine to change how the other half stores rows.

The attempt continues outside core. OrioleDB (Alexander Korotkov's engine, now under Supabase) combines undo-based versioning with index-organized tables and row-level WAL, and posts benchmark numbers that make the case that a modern rethink pays; it still requires patches to core PostgreSQL that are working their way upstream. The table access method API (PostgreSQL 12) exists precisely so that the heap stops being the only answer, and 64-bit transaction IDs, which would end wraparound outright, have a long-running patchset and already ship in at least one commercial fork.

Meanwhile, core PostgreSQL has spent fifteen years shrinking the constants without changing the design: HOT updates (8.3), the visibility map that lets vacuum skip clean pages (8.4/9.6), B-tree deduplication (13), bottom-up index deletion that fights version churn inside index pages (14), a rewritten vacuum TID store (17) that removed the memory limit that used to force multi-pass index vacuums, and a REPACK command in 19 that consolidates VACUUM FULL and CLUSTER, with a CONCURRENTLY mode that rebuilds without the exclusive lock. The four charges from the top of this post were all worse in 2010. The design held; the bill shrank.

The scorecard

Five storage designs (etcd maps onto the PostgreSQL column), the same four questions, and no column without a failure mode:

PostgreSQL heapOracle / InnoDB undoSQL Server + RCSIWiredTiger (MongoDB)LSM (CockroachDB etc.)
Old versions livein the tableundo segments/logstempdb version storein the cache, as delta chains (spill to history store)in the LSM, timestamped keys
Update writesfull new row + all index entries (unless HOT)in place + undo delta; unchanged secondary indexes untouchedin place + version copy to tempdbin-memory delta; page rewritten at checkpoint; moved documents update all indexesnew key version
Secondary indexes point atphysical ctid; every index follows every moved version (HOT excepted)logical PK (InnoDB), rowid (Oracle); stable under in-place updateclustering key / RID; rows gain a 14-byte version tagRecordId; a moved document rewrites every indexlogical keys; each version is a new key, timestamp-suffixed
Abort cost~zeroproportional to work done~zero with ADR (2019+); undo replay before~zero (uncommitted deltas discarded)~zero (intents removed lazily)
Long reader causesbloat, vacuum starvation, held xminORA-01555 or undo/history growthtempdb growth, instance-wide risk (ADR moves versions into the user DB)history store growth, cache pressure, eviction stallsGC-window errors, tombstone debt
Cleanupvacuum (yours to tune)purge/undo recycling (theirs, still stalls)version store cleanupeviction + checkpoints (continuous, in RAM)compaction (continuous)
Signature outagewraparound, bloathistory list explosion, day-long rollbacktempdb fullnode-wide latency smear; SnapshotTooOldtombstone-choked scans

Read the table by rows and a pattern falls out. The costs of multi-versioning, keeping history, reading history, discarding history, are conserved. Each engine only chooses who pays, when, and how it fails: PostgreSQL charges the future and fails by bloating; the undo camp charges the writer and the reader-of-history, and fails by cancelling queries or ballooning undo; SQL Server charges tempdb and fails instance-wide; WiredTiger charges the cache and fails by slowing everyone at once; the LSM engines charge the compactor and fail on time-travel and churn.

PostgreSQL's particular choice, viewed from this side of the table, is the one that never blocks a reader, never cancels a query with "snapshot too old" (unless you opt in, and even that was removed in 17 for lack of love), never makes you wait for a rollback, and never hides its garbage where you can't inspect it. Every dead tuple is sitting in an 8KB page you can open with pageinspect. The price is that the garbage is yours: visible, measurable, and on your maintenance calendar.

So yes, PostgreSQL's MVCC is bad. So is everyone else's. The mature version of the criticism isn't "Postgres got it wrong", it's "Postgres chose failure modes that are operationally loud and mitigations that are manually tuned." That's a real critique, and it's why zheap was attempted and why OrioleDB exists. But if someone tells you a database has solved multi-versioning, ask them the four questions: where do old versions live, which way do chains point, what do indexes point at, and who cleans up. Then ask what happens when a transaction stays open over lunch. There's always an answer, and it's never "nothing".