VillageSQL is a drop-in replacement for MySQL with extensions.
All examples in this guide work on VillageSQL. Install Now →
The Mental Model
Think of a book with 800 pages. To find every mention of “TCP/IP” you could read every page — that’s a full table scan. Or you could check the index at the back, find “TCP/IP — pages 42, 107, 391,” and go directly to those pages. That’s what a database index does. MySQL’s default index structure is a B-tree (balanced tree). It keeps values sorted, which enables fast lookups (binary search), range scans (BETWEEN, >, <), and sorted output (ORDER BY) — all without reading the entire table.
How MySQL Uses an Index
When you run a query, MySQL’s optimizer checks whether an index can narrow down the rows it needs to read. It uses an index when your query filters or sorts on an indexed column.EXPLAIN: See what MySQL is actually doing
EXPLAIN shows how MySQL plans to execute a query. The key column tells you which index it used (or NULL if none), and rows estimates how many rows it had to examine.
type: ref means MySQL used an index lookup. type: ALL means a full table scan — usually what you’re trying to avoid.
Composite Indexes: Column Order Matters
A composite index covers multiple columns. The column order determines when the index is usable.WHERE user_id = 42— yes, leftmost columnWHERE user_id = 42 AND status = 'shipped'— yes, both columnsWHERE status = 'shipped'— no, skips the leftmost column
(user_id, status) and (status, user_id) are different indexes with different uses. Design composite indexes around your most common query patterns.
Covering Indexes: Serving Queries from the Index Alone
A covering index includes all the columns a query needs. MySQL can answer the query entirely from the index without reading the actual row data — a significant speedup on large tables.EXPLAIN shows Extra: Using index. No row lookups needed.
When NOT to Add an Index
More indexes aren’t always better. Every index costs:- Write overhead — every INSERT, UPDATE, and DELETE has to update every index on the table
- Storage — indexes can be as large as the table itself on write-heavy data
- Optimizer complexity — too many indexes and the optimizer sometimes picks the wrong one
- The column has high cardinality (many distinct values) —
user_id,email,order_id - The query filters a small fraction of the table
- Low cardinality —
statuswith 3 possible values, booleanis_active - The table is small enough that a full scan is fast anyway
- The column is write-heavy and rarely queried
Primary Key Is Always an Index
InnoDB tables are organized around the primary key — it’s not a separate index, it’s the physical storage order of rows. This means:- Primary key lookups are the fastest possible query
- Rows are physically adjacent when primary key values are sequential
- Random primary keys (like UUID v4) cause fragmentation on insert — see UUIDs in MySQL
Common Index Mistakes
Using a function on an indexed column — MySQL can’t use an index when you wrap the column in a function:%:
OR conditions, but it’s unreliable. On large tables, UNION is more predictable:
Finding Missing Indexes
The slow query log captures queries that exceed a time threshold — a good starting point for finding what to index:EXPLAIN on the slow queries. Look for type: ALL and rows in the millions.
Summary
- B-tree indexes keep values sorted for fast lookups and range scans
- MySQL uses an index when your query filters or sorts on the leading indexed column(s)
- Composite index column order matters — design around your most common queries
- Covering indexes are the fastest — the query is answered entirely from the index
- Indexes cost on writes; only add them where the read benefit justifies it
- Wrap a column in a function and you’ve disabled its index — use range conditions instead

