How Is Database Data Physically Stored?
A table is a description; what exists on disk is a file of fixed-size pages, each holding records made of a header, a NULL bitmap, fixed-size values, offsets and variable-size bytes. Seven layers from SELECT to the SSD, and why the page in the middle is the unit everything else is measured in.
Why this exists
The mechanism as the answer to a problem — read this before the name.
- Problem
A table must survive power loss and be found again tomorrow, and a query for one row must not cost more than necessary. Memory forgets; disks remember but only deal in fixed-size blocks.
↓ - Naive solution
Write every row to a text file, one line per row, comma-separated.
fsyncafter each write. This is where every engine, and AtlasDB V0, begins.↓ - Why it breaks
To find row 4,000,000 you read the 3,999,999 lines before it — there is no way to jump. To change a name from
AltoAliceyou rewrite everything after it. A crash mid-rewrite leaves half a file, and no line tells you where the next one starts if a value contains a comma or a newline.↓ - Better idea
Carve the file into fixed-size units addressed by number, so unit N is always at byte N × size and can be read or replaced in place without touching its neighbours. Inside each unit, describe every record with lengths and offsets instead of delimiters.
↓ - Internal mechanism
A page: 8192 bytes with a header (checksum, free-space pointers), a slot directory and the records. A record is a header, a NULL bitmap, fixed-size columns at known positions, offsets to variable-size columns, then their bytes. A file is
Page 0, Page 1, …; a row lives at (page, slot).↓ - Trade-offs
A page is read whole even for one 77-byte row, so a 100-row page costs the same as a 1-row page. Fixed size means a record cannot exceed a page without special handling, and a half-empty page wastes space. In exchange: O(1) addressing, in-place updates, atomic-ish writes and a natural unit for caching and locking.
↓ - Real database
PostgreSQL: 8 KB heap pages in
base/<db>/<relfilenode>, 24-byte page header, 4-byte line pointers, 23-byte tuple headers. InnoDB: 16 KB pages in the.ibdfile, records inside the clustered index leaves. SQLite: 4 KB pages by default, one file, B-tree leaf cells.
Choose your depth
The same mechanism at four altitudes. Start where you are; come back deeper.
The database keeps each table in a file. The file is not a list of lines; it is a sequence of equal-sized pages, usually 8 or 16 kilobytes. Each page holds many rows, packed as bytes.
When you ask for one row, the engine reads the whole page that contains it. When you change one row, it writes the whole page back. Everything the engine does — caching, locking, logging — is done in pages.
Seven layers, seven units
SQL sees a table of rows. The engine sees records — rows encoded as bytes — grouped into fixed-size pages, stored in a database file that is nothing but an array of pages. Below the engine, the file system and the device speak in storage blocks (typically 4 KB), and the SSD stores those in NAND pages and erase blocks that its controller remaps freely. Each layer has its own unit, and the confusion in most performance conversations comes from mixing them: "the query touched 100 rows" says nothing about cost until you know how many pages those rows were on.
Nothing at the top of the hierarchy has bytes of its own. A table is a catalog entry: a name, a column list, a pointer to the file. A row is what a query *returns*; it is assembled from a record at the moment the page is in memory. The first physical thing is the record, and the first thing the engine can actually read is the page.
- Table — catalog entry: columns, types, constraints, which file. No bytes.
- Row — a tuple of typed values as SQL sees it. Assembled from a record on demand.
- Record — the row as bytes: header, NULL bitmap, fixed columns, offsets, variable data.
- Page — 8 KB (PostgreSQL) or 16 KB (InnoDB): header, slot directory, free space, records. The unit of I/O, caching, locking, logging.
- Database file —
Page 0, Page 1, …; page N at byte N × page size; cut into 1 GB segments. - Storage block — 4 KB, what the file system and device promise to write atomically. One page = two blocks.
- SSD / disk — NAND pages and erase blocks behind a translation layer; or platters, heads and seeks.
From a value to bytes: encoding
Every value has to become a byte string of known length, or of a length that can be discovered without guessing. An INT is four bytes, little-endian on x86; a BIGINT eight; a TIMESTAMP eight (microseconds since 2000 in PostgreSQL); a BOOLEAN one. These fixed-size types are the easy case: their position inside a record is known from the table definition alone, so the engine reaches them by arithmetic, header + bitmap + 0 for the first, + 4 for the next.
TEXT, VARCHAR, BYTEA, JSONB and NUMERIC are variable-size. Their bytes are preceded by a length word (PostgreSQL's varlena header: 1 or 4 bytes), and because they push everything after them around, the fixed part of the record carries an offset to each one — or the engine walks the lengths in order, which is what PostgreSQL does and why a column at the end of a wide row is a little more expensive to reach than one at the front.
NULL is not a value and gets no bytes. A NULL bitmap near the head of the record has one bit per column; a set bit means "skip this column entirely, there is nothing here". That is why NULL costs almost nothing in storage and why WHERE col IS NULL cannot be answered by looking at the data area at all — the NULL-semantics interview question covers what it does to comparisons.
1read_column(record, k):2 if bitmap_bit(record, k) == 1: return NULL3 if column[k].fixed_size: return bytes(record, fixed_pos[k], column[k].size)4 off = read_u16(record, offset_slot[k]) # where this value starts inside the record5 len = read_u32(record, off) # its length word6 return bytes(record, off + 4, len)The record: header, bitmap, fixed, offsets, variable
Put together, a record for users (id INT, name TEXT, email TEXT) is a row header (its total length, its column count, flags such as "has NULLs", and whatever the engine needs for transactions), the NULL bitmap, the fixed-size id, an offset for name and one for email, then the two strings. The next lesson, Records on Disk, takes this apart byte by byte; here the point is only that every part is either fixed-size or reachable through a length or an offset. Nothing is delimited, nothing is scanned character by character, nothing needs escaping.
The header is what makes a record self-describing enough to be skipped: read its length word and you know where the next record would start, without understanding its contents. That is the property the naive text file lacked.
0 ┌──────────────────────────────────────────────┐
│ Header (32 B): length=77, ncols=3, flags, txn │
32 ├──────────────────────────────────────────────┤
│ NULL bitmap (1 B): 000 (id, name, email) │
33 ├──────────────────────────────────────────────┤
│ id = 42 (INT, 4 B, fixed) │
37 ├──────────────────────────────────────────────┤
│ name offset → 41 (2 B) │
39 │ email offset → 53 (2 B) │
41 ├──────────────────────────────────────────────┤
│ [len=5] 'Alice' (12 B, padded) │
53 ├──────────────────────────────────────────────┤
│ [len=17] 'alice@example.com' (24 B, padded) │
77 └──────────────────────────────────────────────┘The page: header, free space, records, checksum
Records are packed into pages of a fixed size chosen when the cluster is created — 8192 bytes in PostgreSQL, 16384 in InnoDB. A page opens with a page header: a checksum or CRC over the page contents so a torn or corrupted page is detected on read rather than silently trusted; the LSN of the last WAL record that changed it, which is how recovery decides whether a logged change is already on disk; and two pointers, lower and upper, that bound the free space. Between the header and the records sits the slot directory, the subject of Slotted Pages; the records themselves fill the page from the end backwards.
Metadata about pages lives outside them: a free-space map records roughly how much room each page has so an insert can be sent to a page that will take it, and a visibility map (PostgreSQL) marks pages whose tuples are all visible to every transaction so index-only scans and VACUUM can skip them. The catalog holds the rest — which file, how many pages, what the columns mean.
The number that matters most on a page is not in any field: it is how many records fit. At ~77 bytes plus a 4-byte slot, an 8 KB page holds about a hundred of these users records; at 400 bytes, about twenty. Row width decides how many pages a scan touches and how many rows an index lookup gets for free with the one it wanted.
PAGE #4821 (8192 bytes) ┌──────────────────────────────────────────────┐ 0 │ header: checksum, LSN, lower, upper, flags │ ├──────────────────────────────────────────────┤ 24 │ slot directory: (offset, length) per record │ grows ↓ ├──────────────────────────────────────────────┤ lower │ │ │ free space │ │ │ ├──────────────────────────────────────────────┤ upper │ record 3 │ record 2 │ record 1 │ record 0 │ grows ↑ └──────────────────────────────────────────────┘ 8192
The file and the device
A database file is Page 0, Page 1, Page 2, … with no separators and no index of its own: page N is at byte N × 8192, so a page number is an address, not a search key. PostgreSQL keeps one such file per table (and one per index) at base/<database OID>/<relfilenode>, split into 1 GB segments named 24576, 24576.1, 24576.2. InnoDB keeps the table and all its indexes in one <table>.ibd file, itself an array of 16 KB pages grouped into extents of 64.
Below the file, the kernel and the device work in 4 KB blocks, and the device promises atomicity for one block at a time. An 8 KB page is two of them, so a power cut between the two writes produces a torn page: the first half new, the second half old, the checksum wrong. Engines defend against this above the device: PostgreSQL writes a full copy of each page into the WAL the first time it is modified after a checkpoint (full_page_writes), InnoDB writes every page twice through its doublewrite buffer. Write-Ahead Logging takes it from there.
Latency across the layers is the reason any of this matters. A page already in the buffer pool costs ~100 ns of pointer chasing; in the OS page cache, a few microseconds and a system call; on an SSD, 50–100 µs; on a spinning disk, 5–10 ms of seek. A query plan is, at bottom, a bet on how many pages will be at which level.
| Engine | Page size | Where rows live | Record header | File layout |
|---|---|---|---|---|
| PostgreSQL | 8 KB (compile-time; default) | Heap file, unordered; indexes are separate files | 23 B tuple header + optional null bitmap | base/<db>/<relfilenode> in 1 GB segments |
| MySQL / InnoDB | 16 KB (configurable 4–64 KB) | Leaf pages of the clustered (primary-key) index | 5 B header + 6 B DB_TRX_ID + 7 B DB_ROLL_PTR, variable-length list and NULL flags before it | One .ibd per table with all its indexes |
| SQLite | 4 KB default (512 B – 64 KB) | Leaf cells of a table B-tree keyed by rowid | varint record header with per-column type codes | A single file; page 1 holds the schema |
Key points
- A table is a catalog entry; the first physical thing is the record, and the first thing the engine can read is the page.
- Records encode rows as header + NULL bitmap + fixed-size columns + offsets + variable data. NULL costs a bit, not bytes.
- A page (8 KB PostgreSQL, 16 KB InnoDB) has a header with checksum, LSN and free-space pointers; records fill it from the end.
- A database file is an array of pages: page N is at byte N × page size. A row is addressed as (page, slot), never as a byte offset.
- The page is the unit of I/O, of the buffer pool, of locking and of the WAL. Reading one row costs one page.
- One 8 KB page is two 4 KB device blocks, so a crash can tear it; checksums detect it and full-page writes / doublewrite repair it.
From table to SSD: the storage hierarchy
The fixed-size unit the engine reads, writes, caches and locks. A page has a header (checksum, free-space pointers), a slot directory and the records. A record is addressed as (page number, slot).
Buffers: shared hit=…), a checkpoint writes pages, a lock protects a page. Reading one 77-byte record costs the same 8192-byte read as reading all hundred on the page.When to use — and when not
- The page-oriented layout fits when random access by position and in-place update of individual rows must be cheap — every OLTP engine.
- It fits when the same structure must serve as the unit of caching, locking and logging, so one abstraction carries the whole engine.
- The fixed-page-of-records design does not fit append-mostly, scan-everything workloads where columns are read one at a time; columnar files (Parquet, ORC) pack one column per block instead.
- It does not fit write-heavy key-value workloads on flash where in-place page rewrites amplify writes; LSM trees (LSM Trees: Why Some Engines Favour Writes) append sorted runs instead.
Failure modes
- Reasoning in rows instead of pages: "only 50 rows" that live on 50 different pages cost 50 random reads, and a 5% selectivity that is spread evenly touches every page.
- Wide rows: a 4 KB average row means two records per page, so every scan and every cache miss is twenty times more expensive than for an 80-byte row.
- Trusting the device: no checksums and no full-page writes means a torn page is discovered months later as "invalid page header".
- Treating the file as editable: opening a table file with a text tool, or copying it while the engine runs, produces pages from different moments.
Where you meet this
Back up to the practical layer, and across to the rest of Engineer Atlas.
- DSAArray (index → address arithmetic) → Page file (page N at byte N × 8192)A file of pages is an array whose elements are 8 KB; the address of element N is computed, never searched for.
- Operating SystemsBlock devices and the page cache → Database pages and the buffer poolThe OS caches 4 KB blocks; the database caches its own 8 KB pages on top, and each layer only knows its own unit.