Topic 23

Tuples, Headers, and Line Pointers

Row Layout

A row on disk is a fixed header of 23 bytes, rounded up to 24, followed by its column data. Four of the header's fields carry the entire basis of multiversion concurrency control: t_xmin, t_xmax, t_ctid and the info-mask flag bits. Everything the first chapter asserted about row versions is written in those bytes, and they can be read.

This topic stays strictly on the physical side. You will watch an UPDATE in one session change three fields of a tuple in another session's output, and you will see the old version still sitting on the page afterwards. Which of the two versions a given reader is entitled to see is a different question, decided by the snapshot rules Chapter 6 sets out. Here the interest is only that both are on disk at once.

The Header, Field by Field

The header is seven fields, and their sizes add to 23 on most machines. The user data does not start there, though: t_hoff has to be a multiple of the platform's maximum alignment, which is 8 bytes on any 64-bit build, so the header occupies 24 and the first column begins at byte 24.

The heap tuple header, field by field
t_xmin       4 bytes   the transaction that created this version
t_xmax       4 bytes   the transaction that expired it, or 0
t_cid        4 bytes   command id within that transaction
t_ctid       6 bytes   this tuple's own address -- or its successor's
t_infomask2  2 bytes   number of attributes, plus flag bits
t_infomask   2 bytes   commit hints, HOT, xmax-is-a-multixact, has-nulls
t_hoff       1 byte    offset at which the column data starts
--------------------
            23 bytes   -> 24 once t_hoff is aligned

t_xmin and t_xmax are the version's birth and death stamps, and they are transaction ids rather than timestamps. t_cid separates statements inside one transaction, so a query can avoid seeing rows its own later commands wrote. t_ctid normally points at the tuple itself, and after an update it points at the newer version instead, which turns a chain of versions into something walkable. The two info-mask words hold flag bits: whether the row has nulls, whether it is a heap-only tuple, whether t_xmax holds a multixact id rather than a plain transaction id, and cached answers about whether the creating and expiring transactions committed. Decoding those bits by hand is a waste of an afternoon — pageinspect ships heap_tuple_infomask_flags(), which turns both words into a list of names.

The Overhead per Row

A null bitmap follows the header, but only when the tuple actually contains a null — the has-nulls flag in t_infomask decides. It takes one bit per column, and up to eight columns it is free, because the padding needed to align t_hoff to 24 was already being paid. From nine columns onward a null anywhere in the row pushes the data start to 32.

The consequence is that narrow tables are dominated by bookkeeping. A table of two int columns spends 24 bytes of header plus 4 bytes of line pointer to store 8 bytes of data: 36 bytes on the page for 8 bytes you asked for. Alignment padding between columns adds to this whenever a narrow column precedes a wide one, which is the sizing consequence of the column-order argument from Chapter 3. An inventory row — a bigint and two int columns — is exactly 40 bytes, of which 16 are yours.

An UPDATE, Physically

An UPDATE does not modify the row it names. It writes a complete new tuple — every column, not just the changed one — normally onto the same page if there is room for it, and then it goes back and edits two fields of the old tuple: t_xmax becomes the id of the updating transaction, and t_ctid is repointed at the address of the new version. Both tuples are now on the page, both occupy their bytes, and neither of them has been erased.

Session A updates the strawberries; session B watches the page
-- session A
UPDATE inventory SET on_hand = 0
 WHERE product_id = 4471 AND warehouse_id = 2;   -- xid 8417502

-- session B, before and after that statement
SELECT lp, lp_off, lp_flags, lp_len, t_xmin, t_xmax, t_ctid
  FROM heap_page_items(get_raw_page('inventory', 91))
 WHERE lp IN (17, 18);

 lp | lp_off | lp_flags | lp_len | t_xmin  | t_xmax  | t_ctid     -- before
----+--------+----------+--------+---------+---------+---------
 17 |   7512 |        1 |     40 | 8417329 |       0 | (91,17)

 lp | lp_off | lp_flags | lp_len | t_xmin  | t_xmax  | t_ctid     -- after
----+--------+----------+--------+---------+---------+---------
 17 |   7512 |        1 |     40 | 8417329 | 8417502 | (91,17)
 18 |   7472 |        1 |     40 | 8417502 |       0 | (91,18)

Slot 17 has not moved: same offset, same length, same t_xmin from whichever transaction inserted it. What changed is that its t_xmax now names transaction 8417502. Slot 18 did not exist before and now holds a second 40-byte tuple, written 40 bytes further down the page, whose t_xmin is that same transaction and whose t_xmax is still zero. Two versions of one logical row, forty bytes apart, both real.

Changing a 4-byte integer cost a whole new tuple plus the edits to the old one, so an update-heavy table grows far faster than its row count suggests. The old version is not garbage yet either. Transactions that started before 8417502 are still entitled to it, Chapter 6 supplies the rule that decides which of them, and until the last one has finished the forty bytes stay exactly where they are.

One UPDATE, and what is on the page afterwards
The old tuple stays putslot 17, offset 7512, 40 bytes
A complete new tuple is writtenslot 18, forty bytes further down
Two fields of the old tuple are editedt_xmax stamped, t_ctid repointed
Both versions are on the pageneither has been erased

A DELETE, Physically

A DELETE does even less. It stamps t_xmax on the tuple and stops. The row stays at exactly the same offset in exactly the same page, still 40 bytes wide, still counted by every sequential scan that passes over it, until vacuum comes along and reclaims the space — and even then the space comes back for reuse by that same table rather than being returned to the operating system. Deleting every row from a table and then measuring it with pg_relation_size() returns the same number it returned before. That is not a bug report; that is the design. What that design costs a table that is updated a few thousand times a minute, and how the cost is paid down, is Chapter 7's subject.

A non-zero t_xmax therefore does not mean "this row is gone". It means some transaction claimed this version. A SELECT … FOR UPDATE sets it to record a row lock. An aborted transaction leaves it set with the abort recorded in the info-mask. When several sessions hold locks on the same row at once, the field holds a multixact id — the identity of a set of transactions — flagged as such in t_infomask.

The Four Line Pointer States

The lp_flags column carries a slot's state, and there are exactly four. Normal means the slot holds an offset and a length and there is a tuple there. Redirect means the slot points at another slot on the same page instead of at any tuple of its own. Dead means the tuple is gone but the slot must be kept, because an index entry may still point at it. Unused means the slot is free and the next insert on this page can take it.

The redirect state is the one that repays study. When a row is updated repeatedly on one page and Postgres prunes the intermediate versions, the original slot's tuple data is removed and its identifier is converted into a redirect pointing at the oldest version that may still be visible — and the index entries aimed at that original slot, which the previous topic showed addressing slots rather than bytes, all stay correct without being rewritten. Going the other way is not free. A dead slot can only become unused once every index entry that might reference it has been removed — vacuum therefore walks the indexes and not just the heap.

The four states a line pointer can be in
Normallp_flags: a live slot
The slot holds an offset and a length, and there is a tuple there.
Redirectthe indirection paying off
The slot points at another slot on the same page rather than at a tuple of its own. Every index entry in the database still points at the original slot, and every one of them stays correct without being rewritten.
Deadkept on purpose
The tuple is gone but the slot must be kept, because an index entry may still point at it. Freeing it means walking the indexes first, which is why vacuum cannot stop at the heap.
Unusedfree
The slot is free, and the next insert on this page can take it.

Hint Bits and the Second Write

A tuple header records which transaction created the version, not whether that transaction committed. The first reader to arrive after the transaction has ended looks the outcome up in the commit log, and then writes the answer back into the tuple's info-mask so the lookup is never repeated. Those are the hint bits, and they are the reason a plain SELECT can dirty pages and produce write I/O on a table that has not been modified. It is also why the first scan after a bulk load is measurably slower than the second: the first one is paying to annotate every tuple it touches.

Since 18 that costs a little more than it used to, because initdb now enables data checksums by default. A hint-bit write changes the page and therefore changes its checksum, so with checksums on those updates are always WAL-logged. The practical rule has not changed: after loading a large table, scan it once — or let vacuum do the pass for you — before you record any benchmark number from it, or you will be measuring the annotation rather than the query. The wal_log_hints parameter that used to control the behaviour is simply ignored.

Common Mistakes
  • Reading a non-zero t_xmax as "this row was deleted" — a row lock sets it, an aborted transaction leaves it set, and when several sessions lock the row it holds a multixact id instead of a transaction id.
  • Expecting a DELETE to free disk space — the tuple stays where it is until vacuum reclaims it, and the reclaimed space is then reused by that table rather than returned to the operating system.
  • Filing write I/O on a read-only workload as an unexplained anomaly — hint bits and page pruning make a SELECT dirty pages, which is expected behaviour and not a sign of a rogue writer.
  • Storing a ctid in an application table as a row reference — it changes on every update and again after any rewrite such as VACUUM FULL or CLUSTER, so the pointer starts addressing a different row with nothing to announce it.
  • Estimating a table's size by adding up its column widths — 24 bytes of header, alignment padding between columns and a 4-byte line pointer per row put the real figure far above the naive sum on any narrow table.
  • Benchmarking a table immediately after a bulk load — the first scan pays for hint bits on every tuple, and on 18 it pays WAL for them too because checksums are on.
Best Practices
  • Run heap_page_items() once on a scratch table while a second session updates a row in it, and watch t_xmax and t_ctid change, before trusting any mental model of what an UPDATE does.
  • Budget 24 bytes of header plus padding plus a 4-byte line pointer for every row when sizing a table, and add the per-page 24-byte header on top.
  • Use the table's real primary key wherever a row must be referenced, and treat ctid as valid only within the statement that read it.
  • Decode t_infomask and t_infomask2 with heap_tuple_infomask_flags() rather than by hand, so the flag names come from the server you are running.
  • Warm a freshly loaded table with one full scan before measuring anything on it, so the hint-bit pass is not counted as query time.
  • Say "row version" out loud when you read an UPDATE in a migration or a hot path, because the count of versions written is the number that predicts the table's growth.
Comparable toolsInnoDB one current row, old versions in the undo logOracle undo segments and read-consistent blocksSQL Server a version store in tempdb for snapshot isolationCockroachDB timestamped versions in a key-value store, garbage-collected

Knowledge Check

Which tuple header fields carry the information MVCC actually runs on?

  • t_xmin, t_xmax, t_ctid and the flag bits of the two info-mask words
  • t_hoff and a commit timestamp stored beside the column data
  • The null bitmap and t_cid, which together order the row versions
  • The line pointer's offset and length, which encode row visibility

A single-column UPDATE runs against one row. What physically happens to the tuple that was already there?

  • The changed column is rewritten in place and its length is adjusted
  • Its t_xmax is stamped and its t_ctid is repointed at the new version
  • It is removed from the page and its line pointer is marked unused
  • It is copied into an undo area so the page can be reused at once

You delete 11,000 of a table's 12,000 rows and immediately measure pg_relation_size(). What do you get?

  • Roughly one twelfth of the previous size, as the space is freed at commit
  • The same size as before, since the tuples are still on their own pages
  • The same size until autovacuum runs, which then returns it to the OS
  • A larger size, since each deleted row leaves a replacement tuple behind

A dashboard runs only SELECT statements, yet the server reports write I/O on its tables. What is the most likely explanation?

  • Reading pages into shared_buffers marks them dirty and forces a write
  • The readers are writing commit hint bits into the tuples they examine
  • Each query writes its plan statistics back into the pages it scanned
  • Every SELECT records a shared row lock in each tuple's t_xmax field

What problem does a redirecting line pointer solve?

  • It lets a tuple that outgrew its page continue on the following page
  • Index entries pointing at the old slot stay valid after pruning
  • It records which version of the row the current reader should see
  • It forwards a lookup to the page a moved row was relocated to

You got correct