
postgres image is plenty).At the end of the last article we left a question hanging: when the optimizer estimates cost, why does it count "reading the whole table" (Seq Scan) as so expensive?
Because a database spends most of its time waiting on disk.
Look at a few orders of magnitude (approximate):
Main memory (RAM) latency: ~80ns
SSD random-read latency: ~0.1ms (100,000ns)
Mechanical disk random read: ~10ms (10,000,000ns)
A mechanical disk is roughly 100,000 times slower than memory. Even with an SSD, you're still more than a thousand times off. A database may process thousands of queries per second, and the first step of almost every query is "move data from disk into memory."
In one sentence: in a database, reading disk is the single most expensive thing you can do — and nearly every optimization is really about reading less disk.
Think of a library. If you know roughly where a book lives, you walk over and pull it out. If you don't, you have to sweep through the shelves row by row. The disk is that shelf rack — every row you sweep costs time.
Last article we built up the concept of a "table." Now the question: what does a table look like on disk?
You might guess: "It's a big file like books.json, with rows lined up one after another." You're in the right direction, but it's not quite that — a database doesn't treat data as one unstructured text file. It organizes storage into several layers.
In PostgreSQL, the main body of a table lives in a heap file. "Heap" means "append records to the file without guaranteeing any order." Think of dropping notes into a box: what goes in first sits in front, what comes later sits behind — no sorting, no pattern.
The heap file of table books (physically just a file)
─────────────────────────────
| record | record | record | ... | ← appended in order, unsorted
─────────────────────────────
16384;16384.1, 16384.2, and so on — multiple physical files, one logical table.You can find the physical file for a table with one query:
SELECT pg_relation_filepath('books');
-- output similar to: base/5/16384base/5 is the database directory; 16384 is the table file.
The file holds one row after another, but a row is not just a naked list of column values. Each row in PostgreSQL (called a tuple) starts with a tuple header that carries metadata — such as which transaction created or deleted it (this is deeply tied to transactions and MVCC, which we'll cover later; for now just make a mental note):
┌─────────────────────────┬───────────────────────────┐
│ tuple header │ column data (field values)│
│ (size, tx info, ...) │ ("Database Internals", ...)│
└─────────────────────────┴───────────────────────────┘
You don't need to memorize every field of the header. The takeaway: database storage is structured, not raw text.
Here's the crucial question: since a table is "a bunch of records," does a query read "exactly the rows it needs, row by row"?
No. A database reads and writes disk in pages — a page is a few kilobytes, and every I/O grabs at least one page.
The slowest part of a disk is seeking — a mechanical disk has to move its read head, and an SSD has to locate flash cells. If every read of a 30-byte row paid a full seek cost, performance would be unusable. So operating systems and databases batch disk I/O into larger chunks: read one chunk at a time and use every useful row inside it.
It's like reading a book: you don't open the book once per character — you open a page and read all the words on it.
PostgreSQL's default page size is 8KB (8192 bytes); MySQL InnoDB defaults to 16KB. The layout of a page is roughly:
┌─────────────────────────── Page (8KB) ───────────────────────────┐
│ page header (24B) │ item pointers │ free space │ row data │ special │
│ (page number, ...) │ ← grows forward│ │ ↑ grows backward │
└──────────────────────────────────────────────────────────────────────┘
How many rows fit in one 8KB page depends on row size — anywhere from a few hundred to a couple of thousand.
Since everything is page-based, how do you point at "a specific row"? PostgreSQL gives every row an internal address called a TID, formatted as "page number + offset within page":
TID = (page, offset)
e.g. (3, 5): the 5th item in page 3
This TID matters a lot — in the next article you'll see that an index doesn't store the data itself; it stores TIDs, and it's the TID that lets you pull a row out of the heap.
You've now internalized "reading disk is expensive." What's a database to do? The answer is humble: keep the pages you read often in memory.
A database reserves a chunk of memory at startup just to cache data pages. In PostgreSQL it's called shared_buffers (default 128MB); in MySQL it's the Buffer Pool. The read path goes like this:
query needs page N
↓
look it up in the buffer pool? ──found──→ use it, no disk touch
↓ not found
read the whole page from disk into the pool → return
So you'll observe: the same query is slow the first time and fast the second. The first run copies the page from disk into memory; the second run hits memory directly.
What if the pool is full? Evict the least-recently-used pages (roughly an LRU policy) to make room. This is the same "trade space for time" idea as HTTP caching in the browser.
And there's the complete answer to "why is Seq Scan so expensive" from last article: it's not that one read is costly — it's how many pages you have to read. Scanning a whole table means touching every page (at least the first time); a primary-key lookup touches a single page. The difference isn't the cost of "reading a page," it's the number of pages.
Now the write path. You might assume "writing" means: modify the page, write it back to disk. But two problems hide here:
UPDATE/INSERT can touch a different page, and random writes are far slower than sequential writes;PostgreSQL's answer is the WAL (Write-Ahead Log):
writing one record:
① modify the data page in memory (fast)
② append a "description" of this change to the WAL file (sequential write, fast)
③ on commit, force the WAL to disk (fsync — the log hits disk first)
④ the data page itself is written back lazily, at times like a checkpoint
The key point: the data page's disk write can be deferred, but the WAL must hit disk first. If the database crashes, it replays the WAL (redo) to restore any changes that hadn't yet been written to the data pages, returning to a consistent state.
Why is this actually faster? Because WAL is sequential append — the log always writes to the end of the file, using contiguous disk regions far more efficiently than random rewrites of data pages. Small cost, huge payoff: crash recovery for free.
Finally, a unifying concept: the storage engine — the layer responsible for "how data is organized on disk and in memory."
"A table" is just a logical idea. The same table can have totally different physical organization across databases:
This has a direct consequence: an InnoDB table must have a primary key (otherwise it silently creates a hidden one), because the primary key is its physical arrangement; a PostgreSQL table can get by without a primary key and still store data.
This distinction will be central to the next article — when we discuss how indexes work, the two databases behave differently because of this storage organization. Seeds planted.
Everything so far is row storage: all the columns of a record live next to each other. This suits "fetch a whole record by its key" workloads (OLTP — placing an order, viewing a detail page).
One scenario prefers the opposite: analytical queries often want "one column, scanning many rows" (OLAP — computing an average price). If you store by column, all the values of one column sit contiguously, so scanning only needs to read that column's data — a big drop in I/O. This is columnar storage, used by systems like ClickHouse. Keep it in mind as a direction; this series focuses on row-oriented relational databases.
docker run --name disk-io -e POSTGRES_PASSWORD=postgres -d -p 5432:5432 postgres:16
docker exec -it disk-io psql -U postgresCreate a table, load tens of thousands of rows, then:
CREATE TABLE books (id BIGSERIAL PRIMARY KEY, title VARCHAR(200), price INT);
INSERT INTO books (title, price)
SELECT 'Book ' || g, (random() * 200)::int
FROM generate_series(1, 50000) g;
SELECT pg_relation_filepath('books'); -- the physical file
SELECT pg_size_pretty(pg_table_size('books')); -- how much space it takespg_table_size tells you "how many pages it takes at minimum" — divide by 8KB and you'll know the page count.
Run the same query twice with EXPLAIN ANALYZE:
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM books WHERE price < 50;
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM books WHERE price < 50;Compare the two Execution Time and Buffers values — the second run is usually faster, because the pages are already in shared_buffers. That's the buffer pool at work.
Install the pageinspect extension and look at a page directly:
CREATE EXTENSION pageinspect;
SELECT * FROM heap_page_items(get_raw_page('books', 0));You'll see the offset of each item within the page — the physical embodiment of the "item pointer → row data" structure we described.
Keywords: disk I/O、random read / sequential write、page、heap file、TID、buffer pool (shared_buffers)、WAL、checkpoint、storage engine、row storage、PostgreSQL、InnoDB
This article nailed down where data lives and how a database reads it: disk reads are expensive, so we read by page, cache pages, and write the log first. One puzzle remains — reading by page is the how; the which pages to read decision sits with the optimizer, and what does the optimizer lean on to "read fewer pages"?
Next we enter the index principle: B+ trees. You'll find that an index is a map for locating a target row within a few pages — and the pages and TIDs from this article are exactly the coordinates on that map.