Records on Disk
One row as the engine writes it: a header, a NULL bitmap, the fixed-size columns at computed positions, offsets to the variable-size columns, then their bytes. Then the two real layouts — PostgreSQL's heap tuple with t_xmin/t_xmax/t_ctid, and InnoDB's row with its hidden DB_TRX_ID and DB_ROLL_PTR.
Why this exists
The mechanism as the answer to a problem — read this before the name.
- Problem
A row of mixed types — a number, two strings, some of them possibly NULL — must be written as bytes so that any one column can be read back without parsing the others, and the next row can be found without reading this one.
↓ - Naive solution
Concatenate the values with a delimiter:
42,Alice,alice@example.com\n. Empty field means NULL.↓ - Why it breaks
A comma inside a value breaks the split, so now you need escaping, and escaping means every byte must be inspected. Reading column 3 means scanning columns 1 and 2. An empty string and NULL are indistinguishable. Nothing says how long the row is until you find the newline.
↓ - Better idea
Describe the row instead of delimiting it: put lengths and positions in a fixed-shape prefix, so reaching column k is arithmetic for fixed-size types and one offset read for variable-size ones. Represent NULL as a bit, not a value.
↓ - Internal mechanism
Header (record length, column count, flags) → NULL bitmap (one bit per column) → fixed-size columns at positions known from the schema → one offset per variable-size column → the variable bytes, each with a length word. 77 bytes for
(42, 'Alice', 'alice@example.com').↓ - Trade-offs
The header and offsets are overhead: 32–40 bytes on a row whose data is 30 bytes. Alignment padding wastes more. In exchange, column access is O(1) or one indirection, NULL is free, and records are skippable by length.
↓ - Real database
PostgreSQL heap tuples carry a 23-byte header with transaction ids (
t_xmin,t_xmax), a self-pointer (t_ctid), flag words andt_hoff, then a null bitmap and the data. InnoDB rows put the variable-length sizes and NULL flags in front of a 5-byte header, then hiddenDB_TRX_IDandDB_ROLL_PTR, then the columns.
Choose your depth
The same mechanism at four altitudes. Start where you are; come back deeper.
The engine does not store "Alice" and "42" as text in a line. It stores a short header saying how long the row is, a bitmap saying which columns are empty, then the values packed tightly with numbers stored as raw bytes.
Because of the header, the engine can skip a row without reading it, and because of the bitmap, an empty column costs nothing.
The conceptual record
This is a conceptual representation: the general shape every row-store uses, drawn with round numbers. Real engines move the parts around and size the header differently, but every one has these five regions in some form. The record is (42, 'Alice', 'alice@example.com') from users (id INT, name TEXT, email TEXT).
Read it top to bottom as the engine would. The header gives the length — 77 bytes — so the next record is findable without parsing this one. The NULL bitmap is one byte for three columns; all bits clear. id is fixed-size, so it sits right after the bitmap at byte 33. name and email are variable, so the fixed part holds two offsets, 41 and 53, and only after them do the bytes appear, each with its own length word and padded to a multiple of four.
0 ┌────────────────────────────────────────────────┐
│ Header length=77 · ncols=3 · flags · txn │ 32 B
32 ├────────────────────────────────────────────────┤
│ NULL bitmap 0 0 0 (id · name · email) │ 1 B
33 ├────────────────────────────────────────────────┤
│ id = 42 2a 00 00 00 │ 4 B fixed
37 ├────────────────────────────────────────────────┤
│ name offset → 41 29 00 │ 2 B
39 │ email offset → 53 35 00 │ 2 B
41 ├────────────────────────────────────────────────┤
│ Alice 05 00 00 00 41 6c 69 63 65 │ 12 B (padded)
53 ├────────────────────────────────────────────────┤
│ alice@example.com 11 00 00 00 61 6c 69 63 … │ 24 B (padded)
77 └────────────────────────────────────────────────┘What NULL and a longer string do
Set email to NULL and three things happen, none of them in the data area: bit 3 of the bitmap flips to 1, the email offset becomes meaningless (the engine never reads it), and the record ends after name — 53 bytes instead of 77. NULL did not write a sentinel value; it removed a region. This is why NULL is cheap to store and why a column that is "usually NULL" costs almost nothing — and why a nullable column cannot be found by arithmetic: the bitmap must be consulted first, every time.
Change the name to Alice Margaret Fairweather and the name region grows from 12 to 32 bytes. The email offset moves from 53 to 73. Nothing before the name moved: the header, bitmap, id and both offset slots are exactly where they were, because everything up to the first variable-size column has a fixed shape. That fixed prefix is what makes the layout fast: the engine can reach any fixed column, and the offset of any variable column, without looking at a single data byte.
The lesson for schema design follows directly. Put fixed-size, frequently-read columns first; group nullable columns; and remember that an UPDATE which lengthens a string produces a *different-sized* record, which is the whole reason Slotted Pages exist.
- NULL → bitmap bit set, no bytes, offsets after it shift down.
- Longer variable value → its region grows, every later offset shifts up, nothing earlier moves.
- Fixed-size columns are addressed by arithmetic; variable ones by one offset read; NULL by one bit test.
Row header: what the engine keeps about a row
The header is where the engine stores facts *about* the row that it needs before, or instead of, reading the row. The record length and column count are the minimum. Flags follow: has NULLs (so the bitmap exists), has variable-width columns, is this a deleted or moved record. Then whatever the concurrency design needs: in an MVCC engine, the ids of the transactions that created and deleted this version, so visibility can be decided from the header alone (A Transaction, Inside the Engine).
This is why a "row header" of 24 bytes is normal and why a table of tiny rows — (int, int) — is mostly header: 8 bytes of data, 24 of header, 4 of slot. Narrow tables are proportionally the most expensive to store, which is a real argument for arrays or JSONB in the few cases where thousands of tiny related values are always read together.
PostgreSQL: the heap tuple
A PostgreSQL row version is a heap tuple: a 23-byte HeapTupleHeaderData, padded to the next 8-byte boundary, an optional null bitmap t_bits, an optional object id, then the data. The data starts at t_hoff, which is the header's way of saying "here is where the columns begin, whatever came before". There are no per-column offsets: PostgreSQL walks the columns in order, adding fixed sizes and reading varlena length words, and caches the running offsets for the fixed prefix of the row (attcacheoff) so the common case is arithmetic.
t_xmin is the transaction that inserted this version; t_xmax is the transaction that deleted or updated it (0 while it is live). Together with the commit log and the reader's snapshot they decide whether a reader may see this tuple — MVCC is stored in the header of every row, which is why PostgreSQL has no undo log and why an UPDATE writes a whole new tuple. t_ctid is the tuple's own address (page, slot); after an update it points forward to the new version, so an index entry that still names the old location can follow the chain. t_infomask caches facts such as "xmin is known committed" (hint bits) so the commit log is not consulted twice; t_infomask2 holds the column count and the HOT flags.
t_xmin 4 inserting transaction id t_xmax 4 deleting / updating transaction id (0 = live) t_cid|t_xvac 4 command id within the transaction t_ctid 6 (block number, offset number) — self, or the newer version t_infomask2 2 number of attributes · HOT flags t_infomask 2 HEAP_HASNULL · HEAP_HASVARWIDTH · XMIN_COMMITTED · XMAX_INVALID … t_hoff 1 offset from tuple start to the first column [t_bits] ⌈ncols/8⌉ null bitmap, only if HEAP_HASNULL … padding to MAXALIGN (8) … data columns in order: fixed by size, varlena by length word (1 or 4 B)
InnoDB: the row format
InnoDB stores rows inside the leaf pages of the table's clustered index, ordered by primary key, in the COMPACT / DYNAMIC row formats. The layout runs *backwards* from the header: first a list of the lengths of the variable-length columns, in reverse column order, one or two bytes each; then the NULL flags, one bit per nullable column; then a 5-byte record header. The header's most important field is next_record, an offset to the next row in key order — rows on an InnoDB page form a singly linked list, and the page directory only points at every 4th–8th of them.
After the header come three hidden columns. DB_ROW_ID (6 bytes) exists only when the table has no usable primary key. DB_TRX_ID (6 bytes) is the id of the last transaction that changed the row — InnoDB's counterpart of t_xmax, but there is no t_xmin because old versions do not live in the table. DB_ROLL_PTR (7 bytes) points into the undo log, where the previous version of the row was copied before the change. A reader whose snapshot is older than DB_TRX_ID follows the roll pointer backwards until it finds a version it may see. Same MVCC problem as PostgreSQL, opposite placement: the current version in the table, the history elsewhere.
Because the lengths list is in front, InnoDB reaches variable-size column k by summing the first k lengths — no separate offset table, at the cost of a small loop. Long values (past what fits with the row in a 16 KB page) go to overflow pages, with a 20-byte pointer left in the row in DYNAMIC format.
var-length sizes 1–2 B per variable column, reverse order NULL flags ⌈nullable/8⌉ B record header 5 B: delete flag · min-rec · n_owned · heap_no · type · next_record ───────────────── origin ───────────────── DB_ROW_ID 6 B only when there is no primary key DB_TRX_ID 6 B last transaction that changed this row DB_ROLL_PTR 7 B → undo log entry holding the previous version columns primary key first, then the rest, no per-column offsets
Same problem, two answers
Both engines start from the same conceptual record and diverge on one design choice: where old versions live. PostgreSQL keeps them in the table, so its header needs both xmin and xmax, and its ctid must be able to forward. InnoDB keeps them in the undo log, so its header needs one transaction id and a pointer backwards. Everything downstream — VACUUM versus purge, table bloat versus undo growth, heap-only tuples versus in-place updates — follows from that one choice. Physical Layouts Compared: Heap + Secondary Index vs Clustered Index draws the consequences side by side.
| Region | Conceptual | PostgreSQL heap tuple | InnoDB row |
|---|---|---|---|
| Header | 32 B: length, ncols, flags | 23 B: xmin, xmax, cid, ctid, infomask, infomask2, hoff | 5 B header + 13 B hidden columns (TRX_ID, ROLL_PTR); lengths and NULL flags before the header |
| NULL | 1 bit per column in a bitmap | t_bits, present only if HEAP_HASNULL | NULL flags, 1 bit per nullable column |
| Fixed columns | By arithmetic | By arithmetic, cached in attcacheoff | By arithmetic after the hidden columns |
| Variable columns | Offset table in the fixed part | Walk varlena length words in order | Sum the lengths list in front |
| Old versions | — | Stay in the heap; ctid forwards to the new one | Copied to undo; ROLL_PTR points back |
| Oversized values | — | TOAST: compressed / moved out-of-line beyond ~2 KB | Overflow pages with a 20-byte pointer |
Key points
- A record is header → NULL bitmap → fixed-size columns → offsets → variable data. Every part is either fixed-shape or reachable by one length or offset; nothing is delimited.
- NULL sets a bit and removes a region; offsets after it shift. A longer string grows its region; offsets after it shift. Nothing before the first variable column ever moves.
- The row header carries what the engine needs without reading the row: length, flags, and in MVCC engines the transaction ids that decide visibility.
- PostgreSQL: 23-byte header with t_xmin, t_xmax, t_ctid, infomask, hoff; old versions stay in the heap.
- InnoDB: lengths and NULL flags before a 5-byte header, then DB_TRX_ID and DB_ROLL_PTR; old versions go to the undo log.
- Header overhead is real: a two-integer row is 8 bytes of data and ~28 of bookkeeping. Column order and nullability are storage decisions.
Record inspector
Record length, column count, flags, and per-engine bookkeeping (in PostgreSQL: xmin, xmax, ctid, infomask). Fixed size, so the engine knows where the bitmap starts without parsing anything.
When to use — and when not
- The header-bitmap-offsets record fits when rows are read whole or by a few columns, updated in place, and must carry per-row transaction state — every row-store OLTP engine.
- Per-row transaction ids in the header fit when visibility must be decided cheaply for one row at a time, as in MVCC.
- It does not fit analytical scans that read one column across millions of rows; a columnar layout stores each column contiguously and skips the header entirely.
- It does not fit values that dwarf the page (documents, blobs); those need out-of-line storage (TOAST, overflow pages) or a different store.
Failure modes
- Believing NULL is stored as a value:
WHERE col = NULLmatches nothing, and a nullable column cannot be reached by arithmetic. - Ignoring alignment:
(bool, bigint, bool, bigint)pads to 32 bytes where(bigint, bigint, bool, bool)needs 18; on a billion rows that is 14 GB. - Forgetting the header: tables of tiny rows are mostly header, and "add a column, it is only 4 bytes" is 4 bytes plus its share of the padding.
- Assuming an UPDATE edits bytes in place: in PostgreSQL it writes a new tuple with a new xmin; in InnoDB it copies the old row to undo first.
Where you meet this
Back up to the practical layer, and across to the rest of Engineer Atlas.
- DSAStruct layout and pointer arithmetic → Fixed columns at computed positionsA record is a struct: field k is at the sum of the sizes before it. Variable fields need an extra indirection, exactly like a pointer inside a struct.
- CompilersStruct padding and alignment → Column order changes row sizeThe same alignment rules a C compiler applies to a struct apply to a tuple; an 8-byte value after a 1-byte value costs 7 bytes of padding.