Module A-1·34 min read

Where your data actually lives and why every abstraction above this layer has a cost.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

Module 1 — The Storage Engine: Pages, Heaps, and the True Cost of a Row

What this module covers: You will leave this module able to answer questions that most senior engineers cannot: Why does an UPDATE on a single column grow a table? Why does SELECT * on a freshly-loaded 500GB table miss the index? Why does storing a JSONB column with 10KB documents quietly make every query 3x slower? These are not configuration problems. They are storage engine problems — and they all trace back to what you are about to learn.


The 8KB Page: The Fundamental Unit of Everything

Postgres does not read individual rows from disk. It does not even read individual columns. Every I/O operation — whether you are fetching one row or one million — happens in units of 8KB pages.

This has a consequence that surprises most engineers: a query that touches a single 100-byte row reads 8,192 bytes from disk. The other 8,092 bytes come along for free. Understanding what is in those 8,092 bytes is the foundation of storage engine literacy.

Anatomy of a page

Every 8KB page in a Postgres heap file has the same structure:

text

PageHeaderData is 24 bytes containing:

  • pd_lsn — the Log Sequence Number of the last WAL record that modified this page. Used for crash recovery.
  • pd_checksum — checksum of the page contents (if data_checksums is enabled at cluster init time).
  • pd_flags — whether the page has free space, all-visible (all tuples are visible to all transactions), all-frozen.
  • pd_lower — byte offset to the end of the ItemId array.
  • pd_upper — byte offset to the start of the tuple data (which grows from the bottom).
  • pd_special — byte offset to the start of special space (used by index pages, always equal to page size for heap pages).

The free space in a page is pd_upper - pd_lower. When you insert a row, Postgres:

  1. Writes the tuple data starting at pd_upper and decrements pd_upper.
  2. Adds an ItemId entry to the ItemId array and increments pd_lower.

When pd_lower >= pd_upper, the page is full.

Inspecting a real page

Install pageinspect and examine your transactions table:

sql
text

Free space: upper - lower = 2104 - 160 = 1944 bytes. This page has roughly 1.9KB of free space remaining.

To see the individual item pointers:

sql
text

Each row on this page is 232 bytes. Note the offsets count backward from 8192 — tuples are stored from the bottom of the page upward.


The Heap File Layout: Where Your Table Actually Lives

A Postgres table is stored in a directory named by the database OID and consists of one or more files named by the table's OID (called a relfilenode).

sql

The file base/16384/24601 is the main fork — the heap data. But it is not the only file associated with your table.

The four forks

Every table has up to four file forks:

ForkFilename suffixContents
Main(none)The actual row data
FSM_fsmFree Space Map
VM_vmVisibility Map
Init_initUnlogged table init fork (empty)

Run ls -la on your data directory to see them:

bash

File segments

Postgres caps each segment at 1GB by default. When your table exceeds 1GB, a second segment is created: 24601.1, then 24601.2, and so on. This means a 10TB table is stored across ~10,000 files.

This has implications for filesystem limits: if you configure your filesystem with an insufficient inode count and have many large tables, you can hit inode exhaustion even when disk space is available.


How a Row Is Physically Encoded

Understanding the wire format of a heap tuple explains a surprising amount of Postgres behavior.

HeapTupleHeaderData

Every row on disk starts with a HeapTupleHeaderData — a fixed overhead before the actual column values:

c

The minimum header size is 23 bytes. If the table has nullable columns, a null bitmap is appended (1 bit per column, rounded up to a byte boundary).

Key fields to understand:

  • t_xmin — the XID of the transaction that inserted this row version. Used by MVCC to determine visibility.
  • t_xmax — the XID of the transaction that deleted or locked this row. Zero for live, undeleted rows. Non-zero for rows deleted by UPDATE or DELETE.
  • t_ctid — a self-pointer for the current version. For a live row, this points to itself. After an UPDATE, the old version's t_ctid points to the new version (forming a chain). This chain is what MVCC uses to find the current version.

Column alignment and padding

This is the detail that surprises engineers the most.

Postgres aligns each column value to its natural alignment requirement. An 8-byte type (bigint, float8, timestamp) must start at a byte offset that is a multiple of 8. A 4-byte type (int, float4) must align to 4. A 2-byte type (smallint) to 2. Variable-length types (text, varchar, bytea) align to 4.

When column ordering causes misalignment, Postgres inserts padding bytes — wasted space — between columns.

Consider this schema:

sql

The physical row layout:

| a (2B) | pad (6B) | b (8B) | c (4B) | = 20 bytes of data

Now reorder the columns:

sql
| b (8B) | c (4B) | a (2B) | = 14 bytes of data

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.