Internals · StorageAtlasDB V1 · Recordsrecordtuplenull bitmapoffsetsheap tuple

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.

▶ InteractiveInterview question
Progress

Why this exists

The mechanism as the answer to a problem — read this before the name.

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

  2. Naive solution

    Concatenate the values with a delimiter: 42,Alice,alice@example.com\n. Empty field means NULL.

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

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

  5. 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').

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

  7. Real database

    PostgreSQL heap tuples carry a 23-byte header with transaction ids (t_xmin, t_xmax), a self-pointer (t_ctid), flag words and t_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 hidden DB_TRX_ID and DB_ROLL_PTR, then the columns.

Choose your depth

The same mechanism at four altitudes. Start where you are; come back deeper.

A row is a small labelled byte string

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.

Conceptual record layout — header 32 B, bitmap 1 B, id 4 B, two 2-byte offsets, then variable data. Left column: byte offset within the record.
 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

PostgreSQL implementation

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.

PostgreSQL HeapTupleHeaderData (23 bytes) followed by the tuple. Field sizes in bytes.
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

MySQL / InnoDB implementation

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.

InnoDB COMPACT / DYNAMIC record. The origin (offset 0) is the start of the hidden columns; everything before it is addressed with negative offsets.
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.

The conceptual record and its two implementations
RegionConceptualPostgreSQL heap tupleInnoDB row
Header32 B: length, ncols, flags23 B: xmin, xmax, cid, ctid, infomask, infomask2, hoff5 B header + 13 B hidden columns (TRX_ID, ROLL_PTR); lengths and NULL flags before the header
NULL1 bit per column in a bitmapt_bits, present only if HEAP_HASNULLNULL flags, 1 bit per nullable column
Fixed columnsBy arithmeticBy arithmetic, cached in attcacheoffBy arithmetic after the hidden columns
Variable columnsOffset table in the fixed partWalk varlena length words in orderSum the lengths list in front
Old versionsStay in the heap; ctid forwards to the new oneCopied to undo; ROLL_PTR points back
Oversized valuesTOAST: compressed / moved out-of-line beyond ~2 KBOverflow 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

One record, byte by byte
users row (42, 'Alice', 'alice@example.com') as the engine writes it. Click a field; flip the switches and watch the bitmap and the offsets react.
General database conceptconceptual representation — real engines differ in header contents and alignment
Header32 B0NULL bit…1 B32id = 424 B33name off…2 B37email of…2 B39'Alice'12 B41'alice@example.com'24 B5377
Bytes (hex) — click a byte to select its field
NULL bitmap
000 id·name·email
name offset
41
email offset
53
Header
Byte range
0–31 (32 B)
Value
77 bytes, 3 columns

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.

Conceptual record — the 32-byte header, 2-byte offsets and 4-byte length words are a teaching layout. PostgreSQL uses a 23-byte tuple header and varlena values; InnoDB stores a variable-length field list before the header.
record = 77 bytes

When to use — and when not

Use it when
  • 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.
Avoid it when
  • 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 = NULL matches 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.