Before touching a single index type, you need a mental model of what a database does when it executes a query without help — and why that behavior becomes unacceptable at scale. This file builds that model: full table scans, what an index actually is, index scans, the seek-vs-scan distinction, and how the query optimizer decides between them.
By the end of this file, you will be able to:
An e-commerce platform with 40 million orders runs this query on every customer service call:
SELECT * FROM orders WHERE customer_id = 88291;
Without an index, the database has no way to know where rows belonging to
customer 88291 live. It must read every one of the 40 million rows,
check each one, and discard the ones that don’t match. On spinning disk or
even network-attached SSD, that’s not a 2ms query — it’s a multi-second
query, run concurrently by every support agent, every hour, every day.
Indexing isn’t an optimization you add later. It’s the difference between a query that scales and one that takes an outage down with it.
Relational databases store rows in heap or clustered files with no
guaranteed useful order for an arbitrary WHERE clause. A table ordered by
order_id is useless for a query filtering on customer_id unless a
second, purpose-built structure exists that is ordered by customer_id.
That second structure is an index. It trades additional storage and write
cost for dramatically faster reads on the columns it covers.
WHERE created_at BETWEEN ... on dashboards)orders.customer_id → customers.id at
join time)A table’s rows live in a heap (or, in MySQL’s InnoDB, a table structured as a clustered index on the primary key — covered in File 02). An index is a separate structure, stored alongside the table, that maps column values to row locations. Conceptually:
Table (heap, no useful order for customer_id):
row 1: order_id=101, customer_id=88291, ...
row 2: order_id=102, customer_id=4471, ...
row 3: order_id=103, customer_id=88291, ...
...
Index on customer_id (ordered):
4471 -> row 2
88291 -> row 1, row 3
...
The index is sorted; the table underneath it is not. That sort order is what turns “check every row” into “binary search to the right spot.”
-- Create a basic index
CREATE INDEX idx_orders_customer_id
ON orders (customer_id);
-- Drop it
DROP INDEX idx_orders_customer_id ON orders; -- MySQL syntax
-- Inspect existing indexes on a table
SHOW INDEX FROM orders; -- MySQL
CREATE INDEX <name> ON <table> (<column>) — the name is your
identifier for later maintenance (DROP, ALTER); pick a convention
and hold to it (this handbook uses idx_<table>_<column(s)>).DROP INDEX requires the table name because index names are
scoped per-table, not global (unlike PostgreSQL, where index names are
schema-global — see the PostgreSQL Notes below).Full table scan (no index) — every row is read:
[row1][row2][row3][row4][row5][row6][row7][row8] ... [row40,000,000]
✗ ✗ ✓ ✗ ✗ ✓ ✗ ✗ ✗
every single row is checked, most are discarded
Index seek (with an index on the filtered column) — the engine jumps directly to matching entries:
Index (sorted): Table:
[4471 -> row2]
[12003 -> row9]
[88291 -> row1] ─────► row1 ✓
[88291 -> row3] ─────► row3 ✓
[91002 -> row5]
WHERE customer_id = 88291
│
┌────────────┴────────────┐
│ Does an index exist │
│ on customer_id? │
└────────────┬────────────┘
NO ┌───────┴───────┐ YES
▼ ▼
FULL TABLE SCAN INDEX SEEK
read all rows jump to matching
O(n) entries, O(log n)
An index does not make a query fast by existing — it makes it fast when the optimizer chooses it, and the optimizer only chooses it when its cost estimate says it’s cheaper than the alternative. This is the most common conceptual gap for engineers new to indexing: they add an index, see no improvement, and assume indexes “don’t work” — when in fact the optimizer correctly rejected it. File 07 covers why.
Every index is additional data written to disk. A table with five indexes
on it is not five times the write cost of zero indexes, but every INSERT
and UPDATE that touches an indexed column must update that index too.
Indexes are a read/write trade-off, not a free performance upgrade —
covered in depth in File 06.
The optimizer’s decision is based on estimated cost, derived from
table and index statistics (row counts, value distribution). Statistics
can go stale after large data changes, causing the optimizer to make a
now-wrong decision — this is why ANALYZE TABLE (MySQL/PostgreSQL) exists
and matters operationally, not just academically.
Indexes are not part of the ANSI SQL standard. Standard SQL defines what a query returns, not how the engine retrieves it — index creation syntax is entirely vendor-specific, which is why every example in this module calls out per-engine behavior explicitly.
SHOW INDEX FROM <table> and EXPLAIN are your primary inspection
tools.(page, offset) location called a TID.DROP INDEX
idx_name alone is valid (no table name required).is_active column
rarely helps, because roughly half the table matches either value — the
index seek ends up reading nearly as many rows as a scan would, plus the
overhead of the index lookup itself.WHERE, JOIN ON, and ORDER BY clauses
on large, frequently queried tables.EXPLAIN — never assume it’s being used.EXPLAIN.A full table scan reads every row and is O(n). An index is a separate, ordered structure that lets the engine jump to matching rows instead of reading everything. Whether the optimizer uses an available index is a cost decision, not a guarantee — small tables and low-selectivity filters are the two most common cases where a full scan wins despite an index existing. Everything in the rest of this module builds on this seek-vs-scan, cost-based foundation.
See 12_PRACTICE_PROBLEMS.md, Beginner section, Problems 1–4.
See resources/documentation.md for the official MySQL, PostgreSQL, SQL Server, and Oracle indexing documentation.