How a Database Index Finds a Row: the B-Tree, Step by Step
Follow one lookup through a million-row table, first without an index and then with one, using numbers from a real PostgreSQL 17 run.
- Step 1 of 14
One support ticket, one customer id
Picture a users table: a grid where each row is one customer and each column is one fact about them, such as the id or the email. This table has a million rows. A support agent types one customer id, 742001, and waits. We will follow that one lookup twice, first with no help and then with an index, using a real run of PostgreSQL 17, a widely used free database.
The drawing
The users table: a million rows over 9,336 pages, with no order PostgreSQL can rely on. or press Enter - Step 2 of 14
With no index, read everything
PostgreSQL stores a table in pages, fixed blocks of 8 KB. Our million rows fill 9,336 of them, in the order they arrived.
We left out the primary key on purpose, because a primary key builds an index for you. So nothing records where id 742001 lives, and nothing stops two rows from sharing it. The only way to answer is to check every row on every page.
Predict, then seeHow many rows does PostgreSQL read to find id 742001?
- Step 3 of 14
The phone book trick
Now think of a paper phone book. You never read it from page one. The names are sorted, so you open it near the middle. Too far? Jump back. Every glance throws away a big part of the book. An index gives the database the same trick: a separate, sorted copy of one column, where each entry also holds the address of its full row in the table.
- Step 4 of 14
Sorted is not enough on its own
A single sorted list of a million ids would already help. Look at the middle, throw away the wrong half, repeat. A million halved 20 times comes down to one, so any id takes about 20 steps.
But each step can land on a different page, and reading a page from disk is slow. The fix is to let each step choose between hundreds of options instead of two.
- Step 5 of 14
A tree of pages
A B-tree does exactly that, and it is the index PostgreSQL builds unless you ask for another kind. It is a tree made of pages. The top page, the root, holds a few dividing values, called keys, that split all the ids into ranges.
Each key points down to a page that splits its range more finely. The bottom pages, the leaves, hold every id in order, each with its row's address.
What happens next?The drawing's root holds three keys: 250k, 500k and 750k (k means thousand). Where does the search for 742001 go next?
- Step 6 of 14
Hundreds of keys per page
How many choices does one page give? We looked inside the index with pageinspect, a tool that ships with PostgreSQL. One leaf page held 367 ids and kept 808 bytes empty, because a new index fills its leaves only 90 percent, leaving room for later inserts.
So each level multiplies its reach by a few hundred, and the tree stays very short.
Predict, then seeThe index on our million ids fills 2,745 pages, nearly all of them leaves. A page above can point to a few hundred pages below. How many levels deep is the tree?
- Step 7 of 14
Following one key down
Now the lookup itself. PostgreSQL reads the root and compares 742001 with its keys to pick one page on the next level down. It reads that page and picks again. The third page is a leaf, where 742001 sits next to the address of its row: a page number in the table and a slot on that page. One more read fetches the row. Three pages of tree, one page of table.
- Step 8 of 14
4 pages instead of 9,336
We ran the query with the index on a freshly restarted server. The plan changed from Seq Scan to Index Scan. A second lookup in the same connection touched exactly 4 pages: root, middle, leaf and the table page.
The first lookup in a connection touched 7. The extra 3 went on PostgreSQL's own catalog, working out how to compare the number we typed with the bigint column. Typed as a bigint, even a first lookup touched 4. The full scan touched 9,336.
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT * FROM users WHERE id = 742003; -- Index Scan using users_id_idx on users (actual rows=1) -- Index Cond: (id = 742003) -- Buffers: shared hit=4
PostgreSQL 17.10, the second lookup in one connection. Timing columns removed.
- Step 9 of 14
Ten times the rows, same depth
What happens as the table grows? We built the same index on 100, 10,000, one million and ten million rows. The trees were 1, 2, 3 and 3 levels deep.
Three levels at a few hundred keys a page have room for tens of millions of ids, so ten million still fit. Only when that room runs out does the root split and the tree grow a level on top.
Quick checkThe table grows to 100 million rows. What happens to the depth of the tree?
- Step 10 of 14
Ranges come almost free
The leaves are linked to their neighbours, left to right. For a range such as ids 500000 to 500099, PostgreSQL walks down once to the first id, then moves right along the leaves until it passes the last one. Those 100 rows cost 8 pages in our run. Greater than, less than, between and ORDER BY on the column all use the same sorted leaves.
- Step 11 of 14
When the index cannot help
An index stores each value exactly as written. Now ask for
lower(email) = 'user742001@example.com', wherelower()turns the email into small letters before comparing. The index holds the original emails, not lowercased ones, so it cannot help.The planner, the part of PostgreSQL that decides how to run a query, went back to reading the whole table. On the plain email column, the index worked: 4 pages.
Spot the problemThere is an index on created_at, a timestamp. Why does this query read the whole table?
SELECT * FROM users WHERE date(created_at) = '2025-06-01';
- Step 12 of 14
The bill arrives on every write
Indexes are not free. Every insert adds an entry to each index that covers the row, and an update usually does too, unless no indexed column changed and the new row fits on the same page. A full leaf page splits in two and adds a key to the page above.
In the lab behind our Database Indexing lesson, the same 200,000 inserts took 160 ms with no index and 751 ms with five.
- Step 13 of 14
When reading everything is right
An index wins when a query wants a small share of the rows. For a large share it loses: rows found through the tree can sit on many different pages, and reading every page once, in order, is cheaper.
In our indexing lab, on two million rows stored in random order, PostgreSQL used the index up to about 20 percent and read the whole table by 40 percent.
Quick checkA status column holds 'active' on 95% of rows and 'banned' on 5%. It has an index. Which query is the index likely to help?
- Step 14 of 14
Pages, a short tree, and a bill
Rows live in 8 KB pages in whatever order they arrived. A B-tree index is a sorted copy of one column, kept as a short tree of pages with hundreds of keys on each leaf.
A lookup reads one page per level, then the row: 4 pages for one row out of a million. Wrap the column in a function and the tree is skipped, and writes pay to keep it current.
What to remember
- No index means reading every page: 9,336 pages to return one row.
- A B-tree is a short tree of sorted pages. A million rows sat 3 levels deep, and a lookup touched 4 pages.
- It helps narrow searches on the column as stored, and every write pays to keep it current.
Lookups by email on a 50-million-row users table are slow. What do you do, and what does it cost?
First I confirm the cause with EXPLAIN (ANALYZE, BUFFERS) on the real query. A Seq Scan with millions of rows removed by the filter usually means there is no usable index for that condition.
Then I add a B-tree index on email, built with CREATE INDEX CONCURRENTLY so writes are not blocked while it builds. At this size the tree is 3 or 4 levels deep, so a lookup becomes a handful of page reads instead of a full scan.
I check that the query matches the index. If it searches lower(email), I index the expression lower(email) or store emails already lowercased, because an index on the plain column would be skipped.
The cost: every insert, and most updates, now also write the index, and it takes disk and cache. It pays off because this is a narrow, frequent lookup. I would not index a column that no query filters on, or one where the usual value matches most of the table.
Follow-up: Why not index every column?
Each index slows inserts and most updates, and it takes disk and cache. An index the planner never picks is pure cost.
Follow-up: The index exists but the plan still shows a Seq Scan. Why?
The query may hide the column inside a function or a type cast, or match a large share of the table, or the statistics may be stale after a bulk load. Run ANALYZE, then read EXPLAIN again.
Follow-up: How deep would the tree be?
With a few hundred keys per page, millions of rows fit in 3 or 4 levels. Each extra level multiplies the capacity by a few hundred, so depth barely moves.
Go deeper
Database Indexing (26 min)
On two million rows, the full lesson finds where the planner gives up on an index, and shows one composite index answering in 0.054 ms or 37.269 ms depending only on column order.
Read the full Database Indexing lessonWhat this concept covers
- One support ticket, one customer id
- With no index, read everything
- The phone book trick
- Sorted is not enough on its own
- A tree of pages
- Hundreds of keys per page
- Following one key down
- 4 pages instead of 9,336
- Ten times the rows, same depth
- Ranges come almost free
- When the index cannot help
- The bill arrives on every write
- When reading everything is right
- Pages, a short tree, and a bill
Questions people ask
- What is a B-tree index in simple words?
- A sorted copy of one column, stored as a short tree of fixed-size pages. Each page narrows the search to one page on the level below, so a lookup reads one page per level and then fetches the row.
- Why is a B-tree faster than a full table scan?
- A full scan reads every page of the table. In our PostgreSQL run that was 9,336 pages for one row, while the same lookup through a 3-level B-tree touched 4 pages.
- How many levels does a B-tree index have?
- Very few. Our index was 1 level deep at 100 rows, 2 at 10,000, and 3 at both one million and ten million rows, because each leaf held a few hundred keys.
- When will PostgreSQL not use an index?
- When the query wraps the column in a function or cast the index does not match, or when it asks for a large share of the table, where reading the table straight through is cheaper.
Where the facts come from
Every number on this page was measured on a real run (scripts/labs/concepts/btree-lookup.sh in our repository) or is quoted from the official documentation below.
- PostgreSQL documentation, 65.1 B-Tree Indexes (structure, page splits, operators)
- PostgreSQL documentation, 11.2 Index Types (B-tree is the default)
- PostgreSQL documentation, CREATE INDEX (B-tree default fillfactor of 90)
- PostgreSQL documentation, pageinspect (bt_metap, bt_page_stats)
- Our B-Trees lesson, built on the same pageinspect measurements