Indexes: Speeding Up Reads
Add B-tree indexes on the columns queries actually filter and join on.
Indexes turn scans into seeks
An index is a sorted secondary structure (a B-tree) that lets the database seek directly to
matching rows instead of scanning every row. On a filter like WHERE customer_id = 42, an index
on customer_id turns an O(n) table scan into an O(log n) seek. The columns worth indexing are
exactly the ones queries filter, join, and sort on:
- FK columns: you join on them constantly.
WHEREpredicate columns: the selective filters.ORDER BY/GROUP BYcolumns: an index can supply pre-sorted rows.
What is auto-indexed for free. A UNIQUE constraint and every PRIMARY KEY except a
single-column INTEGER PRIMARY KEY create an index automatically, so you rarely index those
yourself. The exception matters: a single-column INTEGER PRIMARY KEY is the table's rowid, so
seeking by it is already fast and SQLite creates no separate index object for it (pragma_index_list
shows nothing). Either way you index the other hot columns, especially FKs, which are not
auto-indexed in SQLite.
Worked example
-- fact_sales is joined to dim_customer on customer_sk all day long
CREATE INDEX idx_fact_sales_customer ON fact_sales(customer_sk);
-- a mart repeatedly filters events by date
CREATE INDEX idx_events_date ON raw_events(event_date);
Anatomy:
CREATE INDEX idx_fact_sales_customer ON fact_sales (customer_sk)
│ │ │
index name (convention: table indexed column(s)
idx_<table>_<col>) (multi-col = composite index)
A composite index (a, b) speeds filters on a and on a, b together (the leftmost-prefix
rule), but not on b alone.
The trade-off: indexes cost writes
Every index must be updated on every INSERT / UPDATE / DELETE. More indexes = faster reads,
slower writes, more storage. So index selectively: the FK and filter columns queries actually
use, not every column "just in case."
In the warehouse this differs, a lot. Columnar warehouses (Snowflake, BigQuery) generally don't have traditional B-tree indexes at all; they rely on columnar storage, partitioning, clustering keys, and micro-partition pruning.
CREATE INDEXis a row-store (SQLite / Postgres / MySQL) concept. The principle (help the engine skip data instead of scanning) carries over, but the mechanism is partition/cluster design, not indexes.
Common pitfall: over-indexing. An index on a column no query filters on is pure write-cost with zero read benefit. Before adding one, name the query it helps. The second pitfall: forgetting FKs aren't auto-indexed in SQLite. An unindexed FK makes every join scan. Leave a comment on non-obvious indexes explaining the query they serve.
Recap: indexes turn scans into seeks on filter/join/sort columns; UNIQUE and most PRIMARY KEYs
are auto-indexed (a single-column INTEGER PRIMARY KEY needs none, it is the rowid) but FKs are
not: index those, index selectively because every index taxes writes, and remember warehouses use
partitioning/clustering instead.
Execution mode: you write a multi-statement script. It runs against a fresh in-memory SQLite DB,
then hidden assertion queries inspect the indexes you created via pragma_index_list /
pragma_index_info.
CREATE TABLE orders_unindexed (order_id INTEGER PRIMARY KEY, customer_id INTEGER, total_cents INTEGER);
CREATE TABLE orders_indexed (order_id INTEGER PRIMARY KEY, customer_id INTEGER, total_cents INTEGER);
INSERT INTO orders_unindexed (customer_id, total_cents) VALUES (42, 1000), (7, 500), (42, 250);
INSERT INTO orders_indexed (customer_id, total_cents) VALUES (42, 1000), (7, 500), (42, 250);
CREATE INDEX idx_orders_indexed_customer ON orders_indexed(customer_id);-- The SAME filter against two identical tables. Only orders_indexed has an index on customer_id.
-- Read the detail column: the unindexed table SCANs every row, the indexed one SEARCHes straight to
-- the match. The COMPOUND QUERY / UNION ALL rows are just the scaffolding that stacks the two.
EXPLAIN QUERY PLAN
SELECT * FROM orders_unindexed WHERE customer_id = 42
UNION ALL
SELECT * FROM orders_indexed WHERE customer_id = 42;Apply
Your turn
The task this lesson builds to.
A fact_sales table is joined to dim_customer on customer_sk in every mart, but
customer_sk is a plain column, not the primary key, so SQLite has not auto-indexed it and the
join scans all 500 rows. Add the one index that turns that join into a seek.
You only need to create the index. The seed already loaded the 500 rows, and the grader checks
that an index exists on the right column of fact_sales.
3 hints and 2 automated checks are waiting in the workspace.
Practice
Make it stick
A second problem on the same idea, so it survives past today.
You're given a slow three-table mart (seeded for you). The dashboard runs a
fact_sales → dim_customer → dim_product join, filtered by dim_product.category and sorted by
order_date. Add the two indexes that matter and deliberately skip the ones that don't:
- Add an index on
fact_sales(customer_sk): an FK join column. - Add an index on
fact_sales(product_sk): an FK join column. - Do not index
revenue_cents(never filtered or joined) ordim_product.product_sk(already the PK, auto-indexed). - Add a SQL
--comment noting each index taxes everyINSERT/UPDATE/DELETE, which is why you index selectively.
4 hints and 3 automated checks are waiting in the workspace.