Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free intro, Pro: 4 of 20 minutes free
Free: Searching instead of scanning. With a pass: the other 10 sections and 3 interview answers, about 16 minutes.

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 Python · first/first_index.py
# 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

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:

A B-tree lookup: the root page leads to a branch page, which leads to the leaf with user 42 entries, which point to table rows.Root pagesplits the user ids into ranges;only the range with 42 is followedBranch pagehundreds of separator keys,each pointing to a leaf pageLeaf pageentries sorted by user_id, then created_at(42, 10:02) → row 81,234(42, 18:40) → row 93,017orders tablethe full row, found by its row id42 falls in this range42 falls in this leafone lookup per matching entry

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.

  1. 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.
  2. A branch page holds hundreds of separator keys, each pointing to a leaf page. The lookup again follows the range that contains 42.
  3. 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.
  4. 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:

Interactive · step by step User 42's latest orders, found page by page Step through one lookup in the index on (user_id, created_at). Each step reads one page or follows one entry, and the counts say how much work the search has done.
User 42's latest orders, found page by page, step 6 of 6. Showing Root page (1 page read), Branch page (2 pages read), Leaf page (3 pages read: 14 entries), orders table (14 rows read). In focus: Root page, Branch page, Leaf page.42 is in this range42 is in this leafrow idRoot page1 page readBranch page2 pages readLeaf page3 pages read: 14 entriesorders table14 rows read

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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 WHERE clause 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

  1. Question 1 of 2 SQLite's plan for a query says SCAN orders, then USE TEMP B-TREE FOR ORDER BY. What is the database doing?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Reading every row of the table, then sorting the matches in a separate step

    Without an index, the first program's plan read all 100,000 rows and sorted the 14 matches. With the index on (user_id, created_at) it searched instead and read 14 rows, already in time order.

  2. Question 2 of 2 A B-tree page holds about 300 entries. How many levels does the tree need to reach 20 million entries?

    Type a number.

    A hint

    Two levels reach 300 × 300 entries; how far do three reach?

    Show the answer: Question 2 of 2

    Answer: 3 levels

    Two levels reach 300 × 300 = 90,000 entries, too few; three reach 300³ = 27 million, enough. A lookup therefore reads about three pages, and the top ones are almost always in memory already.

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.

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).

Read a complete sample first: Design a unique ID generator

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.