One of the most persistent misconceptions about PostgreSQL MVCC is that old row versions accumulate in a chain until VACUUM removes them, making reads increasingly expensive as updates pile up.
That's not how PostgreSQL works. Space amplification is common in MVCC databases because they need to access multiple versions over time, but this doesn't necessarily lead to read amplification. Databases are built to read only the data they need from a larger dataset.
In this article, I'll demonstrate three important facts:
- Scans don't walk version chains across pages — Seq Scans examine heap tuples directly and skip invisible ones; Index Scans follow the HOT chain only within a single page; Bitmap Scans behave like one or the other depending on bitmap losiness.
- Making space reusable in heap and indexes doesn't wait for vacuum — normal reads perform maintenance with hint bits and opportunistic heap pruning, even with autovacuum disabled.
- Index scans pay the visibility cost once, and mark dead entries LP_DEAD to skip future heap visits.
As a result, PostgreSQL can build up dead tuples and index entries, but read amplification doesn't increase proportionally, and space can be reused over time.
Setup
PostgreSQL MVCC is known for space amplification (called bloat), but it doesn't accumulate old versions forever. Some garbage collection (called vacuum) happens in the background. However, this article focuses on read amplification before vacuum. For the purpose of the demo, I created a table and disabled auto-vacuum:
drop table if exists mvcc_demo;
create table mvcc_demo (
id int,
a int,
b int,
filler text default repeat('x',1000)
);
alter table mvcc_demo set (autovacuum_enabled = off);
create index on mvcc_demo ( a );
create index on mvcc_demo ( b );
insert into mvcc_demo
select n,n,n
from generate_series(1,8) n;
vacuum analyze;
To inspect heap pages, I prepare a helper query that lists all line pointers in a page except those with length zero, which are only small stubs:
create extension if not exists pageinspect;
prepare show_tuples(int,int) as
select page, lp, t_xmin, t_xmax, t_ctid,
regexp_replace(t_data::text,'^(\\x)(.{8})(.{8})(.{8})(.{8}).*$','id=\\x\2 a=\\x\3 b=\\x\4 filler=\\x\5...'),
t_infomask, t_infomask2
from generate_series($1,$2) page, lateral (
select * from heap_page_items(get_raw_page('mvcc_demo', page)) where lp_len>0
) order by page, lp
;
execute show_tuples(0,1)
;
The initial state shows eight tuples:
page | lp | t_xmin | t_xmax | t_ctid | t_infomask | regexp_replace
------+----+--------+--------+--------+------------+--------------------------------------------------------------
0 | 1 | 697 | 0 | (0,1) | 2306 | id=\x01000000 a=\x01000000 b=\x01000000 filler=\xb00f0000...
0 | 2 | 697 | 0 | (0,2) | 2306 | id=\x02000000 a=\x02000000 b=\x02000000 filler=\xb00f0000...
0 | 3 | 697 | 0 | (0,3) | 2306 | id=\x03000000 a=\x03000000 b=\x03000000 filler=\xb00f0000...
0 | 4 | 697 | 0 | (0,4) | 2306 | id=\x04000000 a=\x04000000 b=\x04000000 filler=\xb00f0000...
0 | 5 | 697 | 0 | (0,5) | 2306 | id=\x05000000 a=\x05000000 b=\x05000000 filler=\xb00f0000...
0 | 6 | 697 | 0 | (0,6) | 2306 | id=\x06000000 a=\x06000000 b=\x06000000 filler=\xb00f0000...
0 | 7 | 697 | 0 | (0,7) | 2306 | id=\x07000000 a=\x07000000 b=\x07000000 filler=\xb00f0000...
1 | 1 | 697 | 0 | (1,1) | 2306 | id=\x08000000 a=\x08000000 b=\x08000000 filler=\xb00f0000...
(8 rows)
The value 0 of t_xmax and the t_ctid pointing to itself indicate that the tuple is the current version of the row.
Creating a version chain
I'll update one row several times with the following statement:
postgres=# update mvcc_demo
set a=a+1
where id=1
;
UPDATE 1
The first update created a new row version on another page because there was no space on the same page. The original tuple is updated with t_ctid storing the address of the next version, and receives its end of visibility in xmax:
page | lp | t_xmin | t_xmax | t_ctid | t_infomask | regexp_replace
------+----+--------+--------+--------+------------+--------------------------------------------------------------
0 | 1 | 697 | 740 | (1,2) | 258 | id=\x01000000 a=\x01000000 b=\x01000000 filler=\xb00f0000...
0 | 2 | 697 | 0 | (0,2) | 2306 | id=\x02000000 a=\x02000000 b=\x02000000 filler=\xb00f0000...
0 | 3 | 697 | 0 | (0,3) | 2306 | id=\x03000000 a=\x03000000 b=\x03000000 filler=\xb00f0000...
0 | 4 | 697 | 0 | (0,4) | 2306 | id=\x04000000 a=\x04000000 b=\x04000000 filler=\xb00f0000...
0 | 5 | 697 | 0 | (0,5) | 2306 | id=\x05000000 a=\x05000000 b=\x05000000 filler=\xb00f0000...
0 | 6 | 697 | 0 | (0,6) | 2306 | id=\x06000000 a=\x06000000 b=\x06000000 filler=\xb00f0000...
0 | 7 | 697 | 0 | (0,7) | 2306 | id=\x07000000 a=\x07000000 b=\x07000000 filler=\xb00f0000...
1 | 1 | 697 | 0 | (1,1) | 2306 | id=\x08000000 a=\x08000000 b=\x08000000 filler=\xb00f0000...
1 | 2 | 740 | 0 | (1,2) | 10242 | id=\x01000000 a=\x02000000 b=\x01000000 filler=\xb00f0000...
(9 rows)
This t_ctid is what makes people think every read must follow a growing chain of versions. Ordinary visibility checks don't work that way because:
- if the query's read snapshot is between
xmin and xmax, this is the right row, and there's no need to get another one
- if the tuple is not visible to the snapshot, it is skipped. The scan continues normally and may encounter another version of the same logical row elsewhere in the heap.
Here, the row with id=1 (\x01000000) has two versions in two pages - it's not a HOT (heap-only tuple) update. The new version was inserted on a page with free space.
After a second update, the same happens, but there is free space on the same page, so the new version is inserted there, with two versions of id=1 (\x01000000) on page 1:
page | lp | t_xmin | t_xmax | t_ctid | t_infomask | regexp_replace
------+----+--------+--------+--------+------------+--------------------------------------------------------------
0 | 2 | 697 | 0 | (0,2) | 2306 | id=\x02000000 a=\x02000000 b=\x02000000 filler=\xb00f0000...
0 | 3 | 697 | 0 | (0,3) | 2306 | id=\x03000000 a=\x03000000 b=\x03000000 filler=\xb00f0000...
0 | 4 | 697 | 0 | (0,4) | 2306 | id=\x04000000 a=\x04000000 b=\x04000000 filler=\xb00f0000...
0 | 5 | 697 | 0 | (0,5) | 2306 | id=\x05000000 a=\x05000000 b=\x05000000 filler=\xb00f0000...
0 | 6 | 697 | 0 | (0,6) | 2306 | id=\x06000000 a=\x06000000 b=\x06000000 filler=\xb00f0000...
0 | 7 | 697 | 0 | (0,7) | 2306 | id=\x07000000 a=\x07000000 b=\x07000000 filler=\xb00f0000...
1 | 1 | 697 | 0 | (1,1) | 2306 | id=\x08000000 a=\x08000000 b=\x08000000 filler=\xb00f0000...
1 | 2 | 740 | 741 | (1,3) | 8450 | id=\x01000000 a=\x02000000 b=\x01000000 filler=\xb00f0000...
1 | 3 | 741 | 0 | (1,3) | 10242 | id=\x01000000 a=\x03000000 b=\x01000000 filler=\xb00f0000...
(9 rows)
Actually, all versions of id=1 (\x01000000) are on the same page because the initial version has disappeared from the first page even without vacuum, proof that free space is released even before vacuum runs. What happened is that the update has read the first page and did some cleanup while the buffer was pinned.
This is proof that garbage collection can happen without vacuum, simply when UPDATE, DELETE, or SELECT reads after the update. It is called opportunistic pruning: pruning is attempted whenever a page's free space heuristically looks low, or a page previously failed to fit an updated tuple. Space reclamation happens during tuple retrieval when the page is full or nearly full (<10% free or fillfactor target) and a buffer cleanup lock can be acquired.
Additionally, when the UPDATE has to move a new version to a different page because there isn't room, it flags the old page as full. Here, there was space to place the new version on the same page.
Here is a third update that adds another version:
page | lp | t_xmin | t_xmax | t_ctid | t_infomask | regexp_replace
------+----+--------+--------+--------+------------+--------------------------------------------------------------
0 | 2 | 697 | 0 | (0,2) | 2306 | id=\x02000000 a=\x02000000 b=\x02000000 filler=\xb00f0000...
0 | 3 | 697 | 0 | (0,3) | 2306 | id=\x03000000 a=\x03000000 b=\x03000000 filler=\xb00f0000...
0 | 4 | 697 | 0 | (0,4) | 2306 | id=\x04000000 a=\x04000000 b=\x04000000 filler=\xb00f0000...
0 | 5 | 697 | 0 | (0,5) | 2306 | id=\x05000000 a=\x05000000 b=\x05000000 filler=\xb00f0000...
0 | 6 | 697 | 0 | (0,6) | 2306 | id=\x06000000 a=\x06000000 b=\x06000000 filler=\xb00f0000...
0 | 7 | 697 | 0 | (0,7) | 2306 | id=\x07000000 a=\x07000000 b=\x07000000 filler=\xb00f0000...
1 | 1 | 697 | 0 | (1,1) | 2306 | id=\x08000000 a=\x08000000 b=\x08000000 filler=\xb00f0000...
1 | 2 | 740 | 741 | (1,3) | 9474 | id=\x01000000 a=\x02000000 b=\x01000000 filler=\xb00f0000...
1 | 3 | 741 | 742 | (1,4) | 8450 | id=\x01000000 a=\x03000000 b=\x01000000 filler=\xb00f0000...
1 | 4 | 742 | 0 | (1,4) | 10242 | id=\x01000000 a=\x04000000 b=\x01000000 filler=\xb00f0000...
(10 rows)
The row id=1 (\x01000000) started with a=1 (\x01000000), then updated to a=2 (\x02000000), a=3 (\x03000000), and a=4 (\x04000000). Because no open transactions need to read those old values, Postgres cleans them up when possible to free space on the page.
In this example, I update an indexed column, so even if the new version lands on the same page, this isn't a HOT update—a separate index entry is created. Because the old version might still be referenced by an index entry, ordinary read-triggered pruning cannot fully discard its line pointer. It is still there with a length of zero, which I filter out with lp_len>0 - so that id=1 (\x01000000), a=1 (\x01000000) disappeared.
However, page defragmentation reclaimed the tuple storage even though the line pointer is retained as a stub, and this space can be reused before any VACUUM runs and removes the line pointer. I update another row, id=2 (\x02000000) in the first page, and the new version fits there in a new line pointer of the same page:
postgres=# update mvcc_demo
set a=a+1
where id=2
;
UPDATE 1
postgres=# execute show_tuples(0,1)
;
page | lp | t_xmin | t_xmax | t_ctid | t_infomask | regexp_replace
------+----+--------+--------+--------+------------+--------------------------------------------------------------
0 | 2 | 697 | 743 | (0,8) | 258 | id=\x02000000 a=\x02000000 b=\x02000000 filler=\xb00f0000...
0 | 3 | 697 | 0 | (0,3) | 2306 | id=\x03000000 a=\x03000000 b=\x03000000 filler=\xb00f0000...
0 | 4 | 697 | 0 | (0,4) | 2306 | id=\x04000000 a=\x04000000 b=\x04000000 filler=\xb00f0000...
0 | 5 | 697 | 0 | (0,5) | 2306 | id=\x05000000 a=\x05000000 b=\x05000000 filler=\xb00f0000...
0 | 6 | 697 | 0 | (0,6) | 2306 | id=\x06000000 a=\x06000000 b=\x06000000 filler=\xb00f0000...
0 | 7 | 697 | 0 | (0,7) | 2306 | id=\x07000000 a=\x07000000 b=\x07000000 filler=\xb00f0000...
0 | 8 | 743 | 0 | (0,8) | 10242 | id=\x02000000 a=\x03000000 b=\x02000000 filler=\xb00f0000...
1 | 1 | 697 | 0 | (1,1) | 2306 | id=\x08000000 a=\x08000000 b=\x08000000 filler=\xb00f0000...
1 | 2 | 740 | 741 | (1,3) | 9474 | id=\x01000000 a=\x02000000 b=\x01000000 filler=\xb00f0000...
1 | 3 | 741 | 742 | (1,4) | 9474 | id=\x01000000 a=\x03000000 b=\x01000000 filler=\xb00f0000...
1 | 4 | 742 | 0 | (1,4) | 10498 | id=\x01000000 a=\x04000000 b=\x01000000 filler=\xb00f0000...
(11 rows)
The two versions of id=2 (\x02000000) are on the same page. This page is now full again, and another update, on id =3 (\ x03000000), will need to insert its new version on another page:
postgres=# update mvcc_demo
set a=a+1
where id=3
;
UPDATE 1
postgres=# execute show_tuples(0,1)
;
page | lp | t_xmin | t_xmax | t_ctid | t_infomask | regexp_replace
------+----+--------+--------+--------+------------+--------------------------------------------------------------
0 | 2 | 697 | 743 | (0,8) | 1282 | id=\x02000000 a=\x02000000 b=\x02000000 filler=\xb00f0000...
0 | 3 | 697 | 744 | (1,
by Franck Pachot
PlanetScale Blog
Neki, sharded Postgres by PlanetScale, is now available in platform preview.
by Nick Van Wiggeren
PlanetScale Blog
Follow a SQL query through the router, across four Postgres shards, and back.
by PlanetScale