Pages: The Unit of Everything
Why the engine never reads a row: the file is Page 0, Page 1, …, page N is at byte N × 8192, and a record is addressed as (page, slot). Page reads, page writes, torn writes and checksums — and the cost model that follows: the page is the unit of I/O and of the buffer pool.
Why this exists
The mechanism as the answer to a problem — read this before the name.
- Problem
Records of different lengths in one file, and a device that only transfers 4 KB blocks: how do you find record number 3,000,000, replace it, and know the next write did not corrupt its neighbours?
↓ - Naive solution
Store each record's byte offset in an index: record 3,000,000 is at byte 231,004,113. Seek there, read its length, read it.
↓ - Why it breaks
Every record grows or shrinks on update, so every byte offset after it changes and the index must be rewritten. A record straddling two 4 KB device blocks is written in two operations; a crash in between leaves neither the old nor the new record. And the OS transfers whole blocks anyway, so "read 77 bytes" is a lie.
↓ - Better idea
Make the unit of storage the same as the unit of transfer, and make it fixed-size: the file is an array of equal pages, page N at byte N × size. Address a record by (page, slot) and let the page decide where inside it the bytes are.
↓ - Internal mechanism
An 8192-byte page with a 24-byte header (checksum, LSN, lower, upper), a slot directory, free space and records. Read = one
preadof 8192 bytes at N × 8192; write = onepwriteof the same. Locating a record: read the page, index the slot directory, follow the offset.↓ - Trade-offs
A page is the smallest thing that moves, so touching one byte costs 8 KB in and 8 KB out; large pages amortize headers and scan well, small pages waste less on random access. A page is still two device blocks, so torn writes remain possible and need a checksum to detect and a log to repair.
↓ - Real database
PostgreSQL:
BLCKSZ= 8192,pd_checksum(enabled withinitdb --data-checksums),full_page_writesto survive torn pages,Buffers: shared hit=… read=…in EXPLAIN. InnoDB: 16 KB pages, CRC-32C in the FIL header and trailer, the doublewrite buffer.
Choose your depth
The same mechanism at four altitudes. Start where you are; come back deeper.
A table file is a row of equal-sized boxes called pages. To get any row, the engine fetches the box it is in — the whole box, always. To change a row, it changes the box in memory and later writes the whole box back.
So the cost of a query is roughly the number of boxes it opens, not the number of rows it looks at.
The file is an array of pages
Open a PostgreSQL table file and there is nothing to see: no magic number, no directory, no record separators. It is Page 0, Page 1, Page 2, …, each exactly 8192 bytes, and the only way to know anything about it is to read a page and interpret its header. That emptiness is the design. A page id is an address — page 4821 starts at byte 4821 × 8192 = 39,493,632 — so getting to a page costs one multiplication and one pread, and appending a page costs writing 8192 bytes at the end. The file grows in page-sized steps and never has partial pages.
Because pages are the same size, the engine can plan around them without reading them: a table of 12,800 pages is 100 MB and a full scan is 12,800 sequential reads, before a single byte is fetched. That number, relpages, is in the catalog and is the first thing the planner looks at.
base/16384/24576 (relfilenode 24576, 12 pages, 98,304 bytes)
byte 0 8192 16384 24576 32768 … 90112
┌──────────┬──────────┬──────────┬──────────┬───────┬──────────┐
│ Page 0 │ Page 1 │ Page 2 │ Page 3 │ … │ Page 11 │
└──────────┴──────────┴──────────┴──────────┴───────┴──────────┘
8192 B 8192 B 8192 B 8192 B 8192 B
page N → byte N × 8192 append → write 8192 B at byte 12 × 8192Locating a record: (page id, slot)
A record's address in the rest of the engine is never a byte offset in the file. It is a pair: page id and slot number — PostgreSQL's ctid, printed as (4821,3). To resolve it: compute the page's byte offset, read the page (or find it already in the buffer pool), read slot 3 of the slot directory to get the record's offset *within the page*, and follow it. Three steps, one I/O at most, and no searching.
The indirection through the slot is deliberate. If an index stored byte offsets, every record that moved — because a neighbour grew, because the page was compacted — would invalidate index entries all over the tree. With (page, slot), the page is free to shuffle bytes internally as long as slot 3 keeps pointing at the right record. Slotted Pages is entirely about that freedom.
Page reads and page writes
The engine has exactly two operations against the file: read page N, write page N. A page read is a request for 8192 bytes at N × 8192 into a buffer-pool frame; after it, every record on the page is in memory whether it was wanted or not. SELECT name FROM users WHERE id = 42 reads one page and ignores ~100 records on it. A page write is the reverse — the whole frame back to N × 8192 — and is issued not when a row changes but when the buffer pool decides the dirty page must go (eviction, checkpoint). Ten updates to ten rows on the same page are one write.
This has a consequence people find counter-intuitive: the engine never reads a single row and never writes a single row. Cost is paid per page. A query that returns 5 rows from 5 pages costs the same I/O as one that returns 500 rows from 5 pages. And "small" updates are not small: changing one byte dirties 8192 and, after a checkpoint, logs all 8192 to the WAL as well.
1read_page(file, n) -> pread(fd, buf, 8192, n * 8192) # into a buffer-pool frame2write_page(file, n) -> pwrite(fd, frame, 8192, n * 8192)3append_page(file) -> write_page(file, page_count); page_count += 14 5# locate a record6frame = buffer_pool.get(file, page_id) # hit: pointer · miss: read_page7off, len = frame.slot_directory[slot]8record = frame[off : off + len]Torn writes and checksums
The device does not know what a page is. An 8 KB page is two 4 KB blocks, and the storage stack promises atomicity per block at best. Lose power between the two halves and the page on disk is the new first half glued to the old second half: a torn page. Its slot directory may point at records that are not there any more; a record may be half of two versions. Nothing in the bytes says so.
A checksum in the page header is the detector. Computed over the page when it is written, verified when it is read, it turns silent corruption into a loud error — WARNING: page verification failed — at the cost of a few microseconds per I/O. PostgreSQL stores a 16-bit checksum in pd_checksum (opt-in at initdb --data-checksums, or pg_checksums later); InnoDB stores CRC-32C in the FIL header and repeats it in the trailer, so a torn page fails the trailer comparison even before the CRC.
Detection is not repair. To make a torn page whole again, the engine needs a complete good copy from somewhere: PostgreSQL writes a full-page image into the WAL the first time a page is modified after each checkpoint, and recovery restores that image before replaying the smaller changes; InnoDB writes each page to a doublewrite buffer and fsyncs it before writing it in place, so one of the two copies is always intact. Both are page-sized costs paid to survive a block-sized guarantee. Crash Recovery walks through the restart.
intended page (v2) on disk after the crash
┌──────────────┐ 0 ┌──────────────┐ 0
│ header v2 │ │ header v2 │ ← new: checksum, LSN, lower/upper for v2
│ slots v2 │ │ slots v2 │ ← new: slot 7 says "record at 5120"
│ free │ │ free │
├──────────────┤ 4096 ├──────────────┤ 4096 ─── block boundary ───
│ records v2 │ │ records v1 │ ← old: nothing at 5120
└──────────────┘ 8192 └──────────────┘ 8192
checksum ≠ pd_checksum → "page verification failed"The cost model: page = unit of I/O = unit of the buffer pool
Once the page is the unit of I/O it becomes the unit of everything built on I/O. The planner estimates cost in page reads: seq_page_cost (1.0) for pages read in order, random_page_cost (4.0) for pages fetched by pointer, plus small per-row CPU terms. A sequential scan of 12,800 pages costs ~12,800; an index lookup costs ~4 random pages, ~16 units. Rows only enter as multipliers. EXPLAIN (ANALYZE, BUFFERS) reports the truth in the same unit: Buffers: shared hit=3 read=1 means four pages, three already in memory.
The The Buffer Pool is a cache *of pages*: a hash table from (file, page id) to a frame, with an eviction policy over frames. Its hit ratio is a ratio of pages. Its capacity — shared_buffers, innodb_buffer_pool_size — is a number of pages. The WAL records changes by (page id, offset, bytes). Locks on data are page latches around a tuple lock. Understanding the page means understanding what every one of those numbers counts.
| Page size | Good at | Bad at | Who |
|---|---|---|---|
| 4 KB | Random point reads on small rows; matches the device block, no torn-page window | Scans (more headers, more I/O calls), fewer keys per index page → taller trees | SQLite default; Oracle default |
| 8 KB | Balance: ~100 narrow rows or ~200 index keys per page, two device blocks | Torn-page handling needed; wide rows waste the tail of the page | PostgreSQL, SQL Server |
| 16 KB | Scans and wide rows; ~400 index keys per page → shorter trees; fewer I/O calls | Point reads move 4× more bytes than a 4 KB page; larger buffer-pool granularity | InnoDB |
Key points
- A table file is
Page 0, Page 1, …with no separators; page N is at byte N × page size, so a page id is an address. - A record is addressed as (page id, slot); the slot directory turns that into a byte offset inside the page, so records can move within a page without invalidating references.
- The engine has two file operations: read page N, write page N. It never reads or writes a single row.
- A page is two or four device blocks; a crash between them tears it. Checksums detect torn pages; full-page writes (PostgreSQL) or the doublewrite buffer (InnoDB) repair them.
- The page is the unit of the planner's cost model, of the buffer pool, of the WAL and of latching. Every performance number is ultimately a page count.
A database file is an array of pages
When to use — and when not
- Fixed-size pages fit any engine that needs random access to rows, in-place updates, and a single unit for caching, logging and locking — every OLTP row-store.
- Larger pages (16 KB) fit scan-heavy tables and wide rows; smaller pages fit point-read workloads on small rows where moving 8 KB for 80 bytes is the waste.
- Page-at-a-time I/O does not fit append-only logs or immutable sorted runs, where the unit can be a whole file written once and never updated in place (SSTables, SSTables: The Immutable Sorted File).
- It does not fit workloads that read one column across the whole table; columnar blocks of one column each read far fewer bytes.
Failure modes
- Counting rows instead of pages when estimating a query: 50 rows on 50 pages is 50 random reads, which is slower than a sequential scan of 200 pages.
- Running without checksums and discovering a torn page as "invalid page in block 4821" months after the power event that caused it.
- Disabling
full_page_writesto save WAL volume on storage that does not guarantee atomic 8 KB writes. - Assuming a small UPDATE is cheap: it dirties a whole page, logs a full page image after a checkpoint, and forces a whole-page write at the next checkpoint.
Where you meet this
Back up to the practical layer, and across to the rest of Engineer Atlas.
- DSAArray indexing → Page id → byte offsetA file of pages is an array of 8 KB elements; page N is at N × 8192, computed in one multiplication.
- Operating SystemsBlock I/O and the page cache → Page reads and writesThe kernel moves blocks and caches them; the database chooses a page size that is a small multiple of the block and caches pages itself.