System Design (High-Level Design) Module 6 – Data modelling and storage engines
Database indexes: how they work, when they help
See how a B-tree index finds rows, why column order matters in a composite index, how to read a query plan, and what each index costs on every write.
What you will learn
- Explain how a B-tree index finds rows, and the leftmost-prefix rule of composite indexes
- Read a query plan and spot full scans, temporary sorts and covering indexes
- Design composite, covering and partial indexes for a written list of queries
- Weigh each index's read speed-up against its cost on writes and storage
Before you start
What you’ll learn · Before you start
What you will learn
- Explain how a B-tree index finds rows, and the leftmost-prefix rule of composite indexes
- Read a query plan and spot full scans, temporary sorts and covering indexes
- Design composite, covering and partial indexes for a written list of queries
- Weigh each index's read speed-up against its cost on writes and storage
Before you start
On this page
Asked for one customer’s 20 latest orders, a table of 100,000 orders made SQLite read every row and sort the matches; with one index it read only that customer’s 14 orders, already in order. A database index is a separate, sorted copy of one or more columns in which every entry points to its row, usually kept in a B-tree: A balanced tree of fixed-size pages, each holding many sorted keys, in which every lookup walks from the root page down to a leaf. Most databases keep their indexes, and many their tables, in one., so the database can jump to the matching rows in a few page reads instead of reading the whole table, and hand them back already sorted.
An index helps the queries that filter, join or sort on its leading columns. It costs disk space and extra work on every insert, update and delete, which is why you add indexes for the queries you run, not for the ones you might.
Searching instead of scanning
Take that table of 100,000 orders and the query behind a customer’s order history: their 20 latest orders. Without an
index, the database must read every row, a full table scan: A query plan that reads every row of a table to find the ones it wants, because no index leads to them. Its cost grows in step with the number of rows. More in “Latency numbers, measured on real hardware”, keep the matches and
sort them. With an index on (user_id, created_at), it walks down the B-tree to the first entry for that user and
reads only that user’s entries, already in time order. The script counts the rows each plan reads:
# One query, before and after an index: a user's 20 latest orders in a table of 100,000. The function touch() is
# called once for every row SQLite reads, so the count shows how much work each plan does.
import random
import sqlite3
rng = random.Random(1)
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, created_at INTEGER, status TEXT, total INTEGER)")
db.executemany(
"INSERT INTO orders VALUES (?, ?, ?, ?, ?)",
((i, rng.randrange(1, 10_001), 1_750_000_000 + 30 * i, rng.choice(("delivered",) * 8 + ("cancelled", "pending")),
rng.randrange(100, 2_000)) for i in range(1, 100_001)),
)
rows_read = 0
def touch(_):
global rows_read
rows_read += 1
return 1
db.create_function("touch", 1, touch)
QUERY = ("SELECT id, created_at, total FROM orders WHERE touch(id) AND user_id = ? "
"ORDER BY created_at DESC LIMIT 20")
def run(title):
global rows_read
print(title)
for row in db.execute("EXPLAIN QUERY PLAN " + QUERY, (42,)):
print(f" plan: {row[3]}")
rows_read = 0
found = db.execute(QUERY, (42,)).fetchall()
print(f" rows read: {rows_read:,}; orders returned: {len(found)}")
print("The 20 latest orders of user 42, in a table of 100,000 orders\n")
run("Without an index")
db.execute("CREATE INDEX orders_user_time ON orders (user_id, created_at)")
run("\nWith CREATE INDEX orders_user_time ON orders (user_id, created_at)") Output
The 20 latest orders of user 42, in a table of 100,000 orders Without an index plan: SCAN orders plan: USE TEMP B-TREE FOR ORDER BY rows read: 100,000; orders returned: 14 With CREATE INDEX orders_user_time ON orders (user_id, created_at) plan: SEARCH orders USING INDEX orders_user_time (user_id=?) rows read: 14; orders returned: 14
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 first_index.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
SQLite’s EXPLAIN QUERY PLAN says SCAN for a full pass over a table and SEARCH when only some rows are visited,
and USE TEMP B-TREE marks a separate sort (SQLite). The index turned 100,000 rows
read plus a sort into 14 rows read in order. The diagram shows the pages such a lookup reads:
Finding user 42's orders through a B-tree index
Text description of the diagram
The diagram shows the pages an index lookup reads, from top to bottom.
- The root page holds separator keys that split the user ids into ranges. The lookup follows the pointer whose range contains user 42; the other branches are not read.
- A branch page holds hundreds of separator keys, each pointing to a leaf page. The lookup again follows the range that contains 42.
- The leaf page holds index entries sorted by user_id and then created_at, each with the row id of its table row, for example user 42 at 10:02 pointing to row 81,234.
- For each matching entry, the database looks the full row up in the orders table by its row id.
Step through the same lookup, one page at a time:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The root page. The query wants the 20 latest orders of user 42. The search reads the root page, whose separator keys split the user ids into ranges, and follows the one range that holds 42. The other branches are never read.
- A branch page. The branch page holds hundreds of separators, each pointing to a leaf page. The search again follows the range that holds 42.
- The leaf page. In the leaf, entries are sorted by user_id and then created_at, so user 42's 14 entries sit side by side, oldest first. The search lands on the first of them.
- Newest first, no sort. ORDER BY created_at DESC reads the same entries from the newest back, so no separate sort is needed. User 42 has 14 orders, fewer than the LIMIT of 20, so the walk ends when the user's entries do.
- The rows themselves. The index holds user_id and created_at, but the query also asks for id and total, so each entry's row id leads to its row in the table. 14 rows read, against 100,000 without the index.
- Why it stays shallow. If a page holds about 300 entries, three levels reach 300 × 300 × 300 = 27 million entries and four reach 8.1 billion, so a lookup reads a handful of pages whatever the size of the table.
The tree stays shallow because each page holds hundreds of entries. If a page holds about 300, three levels reach 300 × 300 × 300 = 27 million entries and four levels reach 8.1 billion, so a lookup reads a handful of pages whatever the table’s size, and the top levels are almost always already in memory. That is the logarithmic cost SQLite’s documentation describes for a search, against a cost that grows with every row for a scan (query planning).
Whether an index helps a given query follows from a few rules, each in one line here:
- The leftmost-prefix rule: a composite index: An index on several columns, sorted by the first column and, within each value of it, by the next. It can narrow a search only when the query constrains its leading columns. helps only queries that constrain its first columns.
- Equality first, range last: put the columns compared with
=before the column compared as a range. - covering index: An index that holds every column a query needs, so the database answers the query from the index alone, without reading the table. hold every column a query needs, so the table is never read.
- Partial indexes hold only the rows a
WHEREclause selects, so they stay small; a query whose own condition implies that clause can use one. - Write cost: every index adds work to every insert, update and delete.
- Query plans in SQLite and PostgreSQL show which index, if any, a query really uses.
Checkpoint
Check: How a B-tree index searches
The leftmost-prefix rule, and the scan hiding behind an index Pro: opens with a Pro, Premium or Ultimate pass
In this part:
- Equality first, range last: rows read, measured
- Covering indexes skip the table
- What every index costs on writes, measured
- Partial indexes stay small
- Reading plans in PostgreSQL
- A procedure for choosing indexes
- Practise choosing indexes
- Choose three indexes that turn scans into searches
- Interview questions
- The "my orders" endpoint returns a user's 20 newest orders with their totals and takes 900 ms at p99 on a table of 300 million orders. Walk through how you find the cause and fix it.
- A teammate proposes an index on every column of the orders table "so that any query is fast". The table takes 5,000 inserts a second. Make the case for and against, with numbers.
- A query filters orders by status = 'pending' and by created_at within the last day, 2,000 times a second. Would you index (status, created_at) or (created_at, status)? Defend the choice and say what else you would consider.
- Key takeaways
2 diagrams · 3 runnable examples (Python) · 1 exercise with sample tests · 3 interview answers · about 42 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Terms in this lesson
- B-tree
- A balanced tree of fixed-size pages, each holding many sorted keys, in which every lookup walks from the root page down to a leaf. Most databases keep their indexes, and many their tables, in one.
- composite index
- An index on several columns, sorted by the first column and, within each value of it, by the next. It can narrow a search only when the query constrains its leading columns.
- covering index
- An index that holds every column a query needs, so the database answers the query from the index alone, without reading the table.
- partial index
- An index of only the rows that match a WHERE condition, such as the pending orders, so it stays small. A query can use it when its own condition implies the index's.
- query plan
- The steps a database chooses for running a query, such as which index it searches, whether it scans the whole table and whether it sorts. EXPLAIN shows it.
References
- EXPLAIN QUERY PLAN (SQLite documentation) (SQLite)
- Query planning (SQLite documentation) (SQLite)
- The SQLite query optimizer overview (skip-scan) (SQLite)
- SQLite release history (3.44.0, partial index scans) (SQLite)
- SQLite partial indexes (SQLite)
- PostgreSQL documentation: Multicolumn indexes (The PostgreSQL Global Development Group)
- PostgreSQL documentation: Index-only scans and covering indexes (The PostgreSQL Global Development Group)
- PostgreSQL documentation: Routine vacuuming (The PostgreSQL Global Development Group)
- PostgreSQL documentation: Partial indexes (The PostgreSQL Global Development Group)
- PostgreSQL documentation: Using EXPLAIN (The PostgreSQL Global Development Group)
- PostgreSQL documentation: The cumulative statistics system (The PostgreSQL Global Development Group)
- PostgreSQL 18 release notes (The PostgreSQL Global Development Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress