PostgreSQLVI · Storage, Pages and TOASTStorage
Pages and tuples
What you'll learn
- Describe the layout of an 8 KiB heap page
- Read a tuple header and interpret t_xmin, t_xmax and t_ctid
- Explain what an UPDATE does at the page level
- Relate row width to the I/O cost of scanning a table
Prerequisites
Verified against PostgreSQL 18.x · PostgreSQL (comparison targets) 17.11, 16.15 · PostgreSQL (support calendar) 18, 17, 16, 15, 14 supported · pgBackRest 2.59.1 · PgBouncer 1.25.2 · Patroni 4.1.5 · Ubuntu (host baseline) 26.04 LTS · 2026-08-27
Everything PostgreSQL reads or writes is an 8 KiB page. Understanding its layout is what makes the behaviour of MVCC, vacuum and bloat predictable rather than mysterious, and all three are ahead in this course.
The layout
flowchart LR
subgraph P["One 8 KiB heap page"]
H["Page header\n24 bytes"] --> L["Line pointers\ngrow forward -->"]
L --> F["Free space\nin the middle"]
F --> T["<-- Tuples\ngrow backward"]
end
The two ends grow toward each other and the free space is what remains
between them. pageinspect shows exactly that:
$ psql -U postgres -c 'SELECT lower, upper, special, pagesize, lsn FROM page_header(get_raw_page('narrow', 0))' lower | upper | special | pagesize | lsn
-------+-------+---------+----------+-----------
928 | 960 | 8192 | 8192 | 0/32A1F98
(1 row)lower = 928 and upper = 960 means 32 bytes of free space remain
between the line-pointer array and the tuples: this page is essentially
full. lsn is the write-ahead log position of the last change to this
page, which is the link between a page and the log record that
describes it.
$ psql -U postgres -c 'SELECT lp, lp_off, lp_len, t_xmin, t_xmax, t_ctid FROM heap_page_items(get_raw_page('narrow', 0)) WHERE lp <= 5' lp | lp_off | lp_len | t_xmin | t_xmax | t_ctid
----+--------+--------+--------+--------+--------
1 | 8160 | 32 | 754 | 0 | (0,1)
2 | 8128 | 32 | 754 | 0 | (0,2)
3 | 8096 | 32 | 754 | 0 | (0,3)
4 | 8064 | 32 | 754 | 0 | (0,4)
5 | 8032 | 32 | 754 | 0 | (0,5)
(5 rows)Each tuple is 32 bytes for a table of two integers — 24 bytes of tuple
header plus the data, rounded to an alignment boundary. All five carry
t_xmin = 754, the transaction that inserted them, and t_xmax = 0,
meaning no transaction has deleted or superseded them.
$ psql -U postgres -c 'SELECT count(*) AS tuples_on_page_0 FROM heap_page_items(get_raw_page('narrow',0)) WHERE lp_len > 0' tuples_on_page_0
------------------
226
(1 row)That number is the bridge between schema design and I/O cost. Reading a million rows of this table costs about 4,425 page reads. Double the row width and it costs twice as much, for exactly the same rows.
What an UPDATE actually does
This is the observation the rest of the course is built on. A table
with one row, before and after a single UPDATE:
$ psql -U postgres -c 'SELECT lp, t_xmin, t_xmax, t_ctid FROM heap_page_items(get_raw_page('mvcc_demo',0))' lp | t_xmin | t_xmax | t_ctid
----+--------+--------+--------
1 | 777 | 0 | (0,1)
(1 row)$ psql -U postgres -c 'SELECT lp, t_xmin, t_xmax, t_ctid FROM heap_page_items(get_raw_page('mvcc_demo',0))' lp | t_xmin | t_xmax | t_ctid
----+--------+--------+--------
1 | 777 | 778 | (0,2)
2 | 778 | 0 | (0,2)
(2 rows)Read the change carefully, because every subsequent part of this course depends on it.
The original tuple was not modified in place. Its data is
untouched. What changed is its header: t_xmax is now 778, the
transaction that superseded it, and its t_ctid now points at (0,2)
rather than at itself.
A second tuple appeared at line pointer 2, with t_xmin = 778.
That is the new version.
The page now holds two tuples where the table holds one row. The old version remains until vacuum determines that no transaction can still need to see it.
The tuple header
24 bytes on every tuple, whatever the row contains. The fields an operator reads:
| Field | Meaning |
|---|---|
t_xmin | Transaction that inserted this version |
t_xmax | Transaction that deleted or superseded it; 0 if live |
t_ctid | Location of this tuple, or of its successor after an update |
t_infomask | Flags: committed, aborted, frozen, HOT-updated, and more |
t_ctid doing double duty is worth noting: for a current tuple it
points at itself, and for a superseded one it points at the next
version. Following the chain is how a query that started before an
update finds the version it should see.
These are visible without pageinspect as system columns:
SELECT ctid, xmin, xmax, * FROM mvcc_demo;
ctid is a physical address — page number and line pointer — and it
changes whenever the row moves. It is emphatically not a stable row
identifier, and using it as one is a mistake that survives testing and
fails after the first VACUUM FULL.
Production discipline
- Read an
UPDATEas an insert plus a stamp. Nothing is overwritten, which is why update-heavy tables grow. - Expect
DELETEto free nothing until vacuum, and then only for reuse within the table. - Treat row width as an I/O multiplier. 226 narrow rows per page against a handful of wide ones is the difference between a cheap scan and an expensive one.
- Never use
ctidas a row identifier. It is a physical address that changes whenever the row moves. - Check
n_tup_hot_updagainstn_tup_updon hot tables. A low ratio means every update is touching every index. - Consider
fillfactoronly for narrow, heavily updated tables whose updates avoid indexed columns; elsewhere it wastes space.
Cross-course references
- Linux for Production Sysadmins — Part XLI (Disk performance) covers measuring the read cost that row width multiplies.
- Ceph & Distributed Storage — Part II (Storage performance) covers what an 8 KiB random read costs on a distributed platform, which is the unit this lesson describes.
- Observability for Production Sysadmins — Part LIX (Database observability) covers exporting the HOT-update ratio, which is a leading indicator of update cost.
Quiz
Knowledge check · 6 questions
Q1. After a single UPDATE of one row, heap_page_items shows two tuples: line pointer 1 with t_xmax set and t_ctid pointing at (0,2), and line pointer 2 with t_xmax of 0. What happened?
Q2. Which two conditions must both hold for an update to be a HOT update?
Q3. Which statements about heap pages are correct? Select all that apply.
Q4. Using ctid as a stable row identifier is safe as long as the application never runs VACUUM FULL.
Q5. Explain why splitting a hot, narrow set of columns away from a wide, static set can reduce write cost.
Q6. Diagnose the write amplification and give the options.
A table of user sessions has 22 columns including three large JSON fields, and six indexes. It receives roughly 900 updates per second, each touching only a last_seen timestamp column, which is not indexed. The table has grown to 340 GB, WAL generation is 4 GB per hour, and autovacuum is running on it almost continuously. pg_stat_user_tables shows n_tup_upd of 41 million and n_tup_hot_upd of 300 thousand.
Passing score: 75%. Answers are checked in this browser.