Skip to main content
RunBook Academy

PostgreSQLVI · Storage, Pages and TOASTStorage

Pages and tuples

Advanced⏱ ~30 minpsqlpageinspect

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

Not yet marked complete on this device.

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:

Read-only / Safethe header of a real page
$ 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.

Read-only / Safethe line pointers and their tuples
$ 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.

Read-only / Safehow many rows fit in one page
$ 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:

Read-only / Safeone row, before the 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)
Read-only / Safethe same page after UPDATE ... SET v = 'changed'
$ 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:

FieldMeaning
t_xminTransaction that inserted this version
t_xmaxTransaction that deleted or superseded it; 0 if live
t_ctidLocation of this tuple, or of its successor after an update
t_infomaskFlags: 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

  1. Read an UPDATE as an insert plus a stamp. Nothing is overwritten, which is why update-heavy tables grow.
  2. Expect DELETE to free nothing until vacuum, and then only for reuse within the table.
  3. 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.
  4. Never use ctid as a row identifier. It is a physical address that changes whenever the row moves.
  5. Check n_tup_hot_upd against n_tup_upd on hot tables. A low ratio means every update is touching every index.
  6. Consider fillfactor only 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

  1. 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?

  2. Q2. Which two conditions must both hold for an update to be a HOT update?

  3. Q3. Which statements about heap pages are correct? Select all that apply.

  4. Q4. Using ctid as a stable row identifier is safe as long as the application never runs VACUUM FULL.

  5. Q5. Explain why splitting a hot, narrow set of columns away from a wide, static set can reduce write cost.

  6. 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.