Chapter 3: How Data Lives on Disk — Files, Pages, and Blocks

Before we learn SQL and schema design, let's understand what's happening at the lowest level. As a C programmer, you'll appreciate knowing what the database is actually doing with your data on disk.

The Fundamental Constraint: Disk is Slow

Everything in database design traces back to one physical reality:

OperationLatencyRelative
L1 cache access~1 ns1 second
RAM access~100 ns100 seconds
SSD random read~100 μs~1 day
HDD random read~10 ms~4 months
HDD sequential read (1MB)~10 ms~4 months

RAM is 1000x faster than SSD, and 100,000x faster than HDD. Every database optimization exists to minimize disk I/O.

Pages: The Unit of I/O

Databases don't read individual rows from disk. They read pages (also called blocks) — fixed-size chunks, typically 8 KB (PostgreSQL) or 16 KB (MySQL/InnoDB).

Disk File (e.g., "users" table): ┌────────────┬────────────┬────────────┬────────────┬─── │ Page 0 │ Page 1 │ Page 2 │ Page 3 │ ... │ (8 KB) │ (8 KB) │ (8 KB) │ (8 KB) │ │ │ │ │ │ │ Row 1 │ Row 15 │ Row 29 │ Row 43 │ │ Row 2 │ Row 16 │ Row 30 │ Row 44 │ │ ... │ ... │ ... │ ... │ │ Row 14 │ Row 28 │ Row 42 │ Row 56 │ └────────────┴────────────┴────────────┴────────────┴─── To read Row 30, the DB must read ALL of Page 2 (8 KB) into RAM. Even if Row 30 is only 100 bytes.

Why pages?

Inside a Page

A typical page layout (PostgreSQL style):

┌─────────────────────────────────────────────────┐ ← Page Start │ Page Header │ (24 bytes) │ • Page size, free space pointers │ │ • LSN (Log Sequence Number for WAL) │ ├─────────────────────────────────────────────────┤ │ Item Pointers (Line Pointers) │ (4 bytes each) │ [offset,len] [offset,len] [offset,len] ... │ │ ↓ Points to actual tuples below │ ├─────────────────────────────────────────────────┤ │ │ │ Free Space │ │ (grows from both ends) │ │ │ ├─────────────────────────────────────────────────┤ │ Tuple 3 (Row Data) │ ← Grows upward │ Tuple 2 (Row Data) │ │ Tuple 1 (Row Data) │ └─────────────────────────────────────────────────┘ ← Page End Item pointers grow DOWN ↓ Tuples grow UP ↑ They meet in the middle when the page is full.
💡 C Analogy

Think of a page like a memory arena allocator. You have a fixed 8KB buffer. A header at the top tracks metadata. An array of pointers (like an index) grows downward. Actual data grows upward from the bottom. When they meet, the page is full and you allocate a new one.

Row Storage (Heap)

In a heap (the default storage for a table), rows are stored in no particular order. New rows go wherever there's free space.

// Conceptual C representation of a row (tuple) on disk:
struct TupleHeader {
    uint32_t xmin;        // Transaction that created this row
    uint32_t xmax;        // Transaction that deleted this row (0 = alive)
    uint16_t infomask;    // Flags (null bitmap, visibility)
    uint16_t natts;       // Number of attributes
    uint8_t  null_bitmap[]; // Which columns are NULL
};
// Followed by actual column data (packed, variable-length)

The Buffer Pool (Page Cache)

Reading from disk every time would be impossibly slow. Databases maintain a buffer pool — a large region of RAM that caches frequently-accessed pages.

┌─────────────────────────────────────────────────────┐ │ Buffer Pool (RAM) │ │ │ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │ │Page 0│ │Page 2│ │Page 7│ │Page 1│ │Page 5│ │ │ │users │ │users │ │orders│ │idx_pk│ │orders│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │DIRTY │ │clean │ │clean │ │DIRTY │ │clean │ │ │ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ │ │ │ │ When full → evict least-recently-used clean page │ │ Dirty pages → must write to disk before evicting │ └─────────────────────────────────────────────────────┘ ↕ read/write pages as needed ┌─────────────────────────────────────────────────────┐ │ Disk Files │ └─────────────────────────────────────────────────────┘

Key concepts:

ℹ️ Typical Sizes

PostgreSQL's shared_buffers is typically set to 25% of system RAM. A server with 64GB RAM might have 16GB of buffer pool = 2 million cached pages. If your entire database fits in the buffer pool, almost everything is served from RAM.

Row-Oriented vs Column-Oriented Storage

ROW-ORIENTED (PostgreSQL, MySQL — OLTP): Page contains complete rows: ┌─────────────────────────────────────────┐ │ [id=1, name="Alice", email="a@x", age=30] │ │ [id=2, name="Bob", email="b@x", age=25] │ │ [id=3, name="Charlie",email="c@x",age=35] │ └─────────────────────────────────────────┘ Good for: SELECT * FROM users WHERE id = 1 (get one full row) COLUMN-ORIENTED (ClickHouse, Parquet — OLAP): Page contains one column for many rows: ┌──────────────┐ ┌──────────────────────┐ ┌──────────┐ │ id: 1,2,3,4, │ │ name: Alice,Bob, │ │ age: 30, │ │ 5,6,7,8... │ │ Charlie,Dave... │ │ 25,35... │ └──────────────┘ └──────────────────────┘ └──────────┘ Good for: SELECT AVG(age) FROM users (read only age column)
AspectRow-OrientedColumn-Oriented
Best forOLTP (transactions, single-row ops)OLAP (analytics, aggregations)
Read patternFull rows (SELECT *)Few columns across many rows
Write patternInsert/update single rowsBulk inserts, rarely update
CompressionModerateExcellent (same-type data compresses well)
ExamplesPostgreSQL, MySQLClickHouse, Redshift, Parquet

File Layout on Disk

A PostgreSQL database on disk looks like:

# PostgreSQL data directory
/var/lib/postgresql/data/
├── base/                    # Database files
│   └── 16384/              # One directory per database (OID)
│       ├── 16385           # Table "users" — main data file (heap)
│       ├── 16385_fsm       # Free Space Map (which pages have room)
│       ├── 16385_vm        # Visibility Map (which pages are all-visible)
│       ├── 16386           # Index on users.id
│       └── ...
├── pg_wal/                 # Write-Ahead Log (transaction log)
│   ├── 000000010000000000000001
│   └── ...
└── postgresql.conf         # Configuration

Why This Matters

Understanding physical storage explains:

Key Takeaways