MENU
Indexes
An index is a sorted copy of some columns that lets MySQL jump to matching rows instead of reading the whole table. It speeds up filters, joins, sorting and grouping, and slows every write. To show it, shop gets a million deterministic orders from a recursive CTE (Section 3.6.8) joined to itself:CREATE TABLE big_orders (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, customer_id INT UNSIGNED NOT NULL,
status ENUM('pending','paid','shipped','cancelled') NOT NULL,
ordered_at DATETIME NOT NULL, total DECIMAL(8,2) NOT NULL
);
INSERT INTO big_orders (customer_id, status, ordered_at, total)
WITH RECURSIVE s (n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM s WHERE n < 1000),
seq (n) AS (SELECT (a.n - 1) * 1000 + b.n FROM s AS a CROSS JOIN s AS b)
SELECT 1 + CRC32(CONCAT('c', n)) % 50000,
CASE WHEN n % 100 < 2 THEN 'pending' WHEN n % 100 < 6 THEN 'cancelled'
WHEN n % 100 < 26 THEN 'paid' ELSE 'shipped' END,
'2024-01-01' + INTERVAL n * 84 SECOND,
5 + CRC32(CONCAT('t', n)) % 20000 / 100
FROM seq ORDER BY n;
Query OK, 0 rows affected (0.049 sec)
Query OK, 1000000 rows affected (7.611 sec)
Records: 1000000 Duplicates: 0 Warnings: 0
3.11.1 How a B+Tree Index Works in InnoDB
InnoDB stores every index, the table included, as a B+tree of 16 KiB pages. Leaves hold entries in key order, linked both ways; internal pages hold a separator key and a child page number per entry. A lookup reads one page per level; with hundreds of children per page the tree stays shallow. A range descends once, then walks the leaves.
Figure 3.9: The PRIMARY B+tree of big_orders, and the path a lookup
of id 567890 takes (keys schematic)SELECT ... INTO @var keeps results off the screen, so only the
timings print:SELECT total INTO @t FROM big_orders WHERE id = 567890;
SELECT COUNT(*), SUM(total) INTO @n, @s FROM big_orders WHERE customer_id = 4242;
Query OK, 1 row affected (0.000 sec)
Query OK, 1 row affected (0.166 sec)
3.11.2 Clustered Indexes and Secondary Index Lookups
In InnoDB the table is its primary key B+tree, the clustered index. Without a primary key, InnoDB clusters on the first all-NOT NULL UNIQUE index, or on a hidden 6-byte row id (GEN_CLUST_INDEX; Section 3.4.2). Every other index is secondary: its entries hold the indexed columns plus the primary key, and reaching the row takes a second descent.
Figure 3.10: A secondary index lookup: find the primary keys, then
fetch each row from the clustered index (values schematic)CREATE INDEX idx_customer ON big_orders (customer_id);
SELECT COUNT(*), SUM(total) INTO @n, @s FROM big_orders WHERE customer_id = 4242;
SELECT stat_name, stat_value, stat_description FROM mysql.innodb_index_stats
WHERE database_name = 'shop' AND table_name = 'big_orders'
AND index_name = 'idx_customer' AND stat_name LIKE 'n_diff%';
SELECT COUNT(*), SUM(total) INTO @n, @s FROM big_orders FORCE INDEX (idx_customer)
WHERE customer_id <= 5000;
Query OK, 0 rows affected (2.439 sec)
Records: 0 Duplicates: 0 Warnings: 0
Query OK, 1 row affected (0.000 sec)
+--------------+------------+------------------+
| stat_name | stat_value | stat_description |
+--------------+------------+------------------+
| n_diff_pfx01 | 49961 | customer_id |
| n_diff_pfx02 | 1000064 | customer_id,id |
+--------------+------------+------------------+
2 rows in set (0.000 sec)
Query OK, 1 row affected (0.224 sec)
3.11.3 Creating Primary, Unique, and Plain Indexes
A table has one PRIMARY KEY (never NULL), any number of UNIQUE indexes (no duplicates, many NULLs allowed) and plain INDEXes (synonym KEY). Declare them in CREATE TABLE or add them with ALTER TABLE ... ADD INDEX or CREATE INDEX; ALGORITHM = INPLACE, LOCK = NONE demands an online build (Section 3.4.6). Foreign keys get an index automatically.INSERT INTO customers (email, name, country) VALUES ('chen@example.com', 'Chen Two', 'MY');
EXPLAIN FORMAT=TRADITIONAL SELECT id FROM customers WHERE email = 'chen@example.com'\G
ERROR 1062 (23000): Duplicate entry 'chen@example.com' for key 'customers.email'
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: customers
partitions: NULL
type: const
possible_keys: email
key: email
key_len: 1022
ref: const
rows: 1
filtered: 100.00
Extra: Using index
1 row in set, 1 warning (0.000 sec)
3.11.4 Composite Indexes and Column Order
A composite index sorts by its first column, then the second, like a phone book, and seeks on a leftmost prefix only: (customer_id, status, ordered_at) serves customer_id, then status, then ordered_at, never status alone. Put equality columns first and one range or sort column last; a range or a gap stops the narrowing.ALTER TABLE big_orders DROP INDEX idx_customer,
ADD INDEX idx_cust_status_date (customer_id, status, ordered_at);
EXPLAIN SELECT * FROM big_orders WHERE customer_id = 4242 AND ordered_at >= '2026-01-01'\G
CREATE INDEX idx_status_date ON big_orders (status, ordered_at);
SELECT COUNT(*) INTO @n FROM big_orders WHERE ordered_at >= '2026-08-29';
SELECT /*+ NO_SKIP_SCAN(big_orders) */ COUNT(*) INTO @n FROM big_orders
WHERE ordered_at >= '2026-08-29';
Query OK, 0 rows affected (2.380 sec)
Records: 0 Duplicates: 0 Warnings: 0
*************************** 1. row ***************************
EXPLAIN: -> Index lookup on big_orders using idx_cust_status_date (customer_id = 4242), with
index condition: (big_orders.ordered_at >= TIMESTAMP'2026-01-01 00:00:00') (cost=1.25
rows=15)
1 row in set (0.001 sec)
Query OK, 0 rows affected (2.068 sec)
Records: 0 Duplicates: 0 Warnings: 0
Query OK, 1 row affected (0.001 sec)
Query OK, 1 row affected (0.221 sec)
3.11.5 Covering Indexes
An index covers a query that uses only its columns, skipping the second descent; plans say Using index or "Covering index". Every entry holds the primary key, so (customer_id, status, ordered_at) already covers SELECT id, ordered_at, and appending id duplicates it.SELECT COUNT(*), SUM(total) INTO @n, @s FROM big_orders WHERE customer_id <= 5000;
SELECT COUNT(*), MAX(ordered_at) INTO @n, @m FROM big_orders WHERE customer_id <= 5000;
Query OK, 1 row affected (0.216 sec)
Query OK, 1 row affected (0.021 sec)
3.11.6 Cardinality, Selectivity, and Index Statistics
Cardinality counts distinct values in an index prefix; selectivity divides it by the rows. InnoDB estimates it from 20 random pages per index (innodb_stats_persistent_sample_pages) and refreshes it after 10% of rows change; ANALYZE TABLE refreshes it now. SHOW INDEX and information_schema.STATISTICS report it, and information_schema.TABLES the sizes.ANALYZE TABLE big_orders;
SELECT index_name, column_name, cardinality FROM information_schema.STATISTICS
WHERE table_schema = 'shop' AND table_name = 'big_orders' AND seq_in_index = 1;
SELECT data_length DIV 1048576 AS data_mib, index_length DIV 1048576 AS index_mib
FROM information_schema.TABLES WHERE table_schema = 'shop' AND table_name = 'big_orders';
SELECT SUM(total) INTO @s FROM big_orders WHERE status = 'pending';
SELECT SUM(total) INTO @s FROM big_orders IGNORE INDEX (idx_status_date)
WHERE status = 'pending';
SELECT SUM(total) INTO @s FROM big_orders WHERE status = 'shipped';
SELECT SUM(total) INTO @s FROM big_orders IGNORE INDEX (idx_status_date)
WHERE status = 'shipped';
+--------------------+---------+----------+----------+
| Table | Op | Msg_type | Msg_text |
+--------------------+---------+----------+----------+
| shop.big_orders | analyze | status | OK |
+--------------------+---------+----------+----------+
1 row in set (0.036 sec)
+----------------------+-------------+-------------+
| INDEX_NAME | COLUMN_NAME | CARDINALITY |
+----------------------+-------------+-------------+
| idx_cust_status_date | customer_id | 50742 |
| idx_status_date | status | 3 |
| PRIMARY | id | 997984 |
+----------------------+-------------+-------------+
3 rows in set (0.001 sec)
+----------+-----------+
| data_mib | index_mib |
+----------+-----------+
| 37 | 39 |
+----------+-----------+
1 row in set (0.001 sec)
Query OK, 1 row affected (0.039 sec)
Query OK, 1 row affected (0.196 sec)
Query OK, 1 row affected (1.147 sec)
Query OK, 1 row affected (0.295 sec)
3.11.7 Prefix, Descending, and Invisible Indexes
A prefix index, (body(20)), stores the first n characters, as TEXT and BLOB require; pick the shortest n whose COUNT(DISTINCT LEFT(col, n)) nears the full count, and expect no covering or sorting from it. A descending index (honored since 8.0) serves mixed sort directions. An invisible index is maintained but ignored, a reversible trial of a drop.CREATE INDEX idx_body ON reviews (body);
SELECT id INTO @i FROM big_orders ORDER BY customer_id, ordered_at DESC LIMIT 1;
CREATE INDEX idx_cust_date_desc ON big_orders (customer_id, ordered_at DESC);
SELECT id INTO @i FROM big_orders ORDER BY customer_id, ordered_at DESC LIMIT 1;
ALTER TABLE big_orders ALTER INDEX idx_cust_date_desc INVISIBLE;
ERROR 1170 (42000): BLOB/TEXT column 'body' used in key specification without a key length
Query OK, 1 row affected (0.223 sec)
Query OK, 0 rows affected (2.590 sec)
Records: 0 Duplicates: 0 Warnings: 0
Query OK, 1 row affected (0.003 sec)
Query OK, 0 rows affected (0.019 sec)
Records: 0 Duplicates: 0 Warnings: 0
3.11.8 Hash Indexes and the Adaptive Hash Index
A hash index maps a key to a bucket: one probe for =, but no order, so no ranges, prefixes or sorting. Only MEMORY and NDB tables build them:CREATE TABLE email_lookup (email VARCHAR(255) NOT NULL, id INT UNSIGNED NOT NULL,
INDEX USING HASH (email)) ENGINE = MEMORY;
INSERT INTO email_lookup SELECT email, id FROM customers;
EXPLAIN SELECT id FROM email_lookup WHERE email LIKE 'chen%'\G
Query OK, 0 rows affected (0.014 sec)
Query OK, 8 rows affected (0.007 sec)
Records: 8 Duplicates: 0 Warnings: 0
*************************** 1. row ***************************
EXPLAIN: -> Filter: (email_lookup.email like 'chen%') (cost=3.4 rows=1)
-> Table scan on email_lookup (cost=3.4 rows=8)
1 row in set (0.000 sec)
3.11.9 When an Index Costs More Than It Saves
Each secondary index is one more B+tree for every INSERT, DELETE and relevant UPDATE to change, plus disk and buffer-pool memory, invisible or not:CREATE TABLE ins_lean (PRIMARY KEY (id)) SELECT * FROM big_orders WHERE id <= 500000;
CREATE TABLE ins_heavy LIKE big_orders;
INSERT INTO ins_heavy SELECT * FROM big_orders WHERE id <= 500000;
Query OK, 500000 rows affected (3.086 sec)
Records: 500000 Duplicates: 0 Warnings: 0
Query OK, 0 rows affected (0.202 sec)
Query OK, 500000 rows affected (8.733 sec)
Records: 500000 Duplicates: 0 Warnings: 0
sys.schema_redundant_indexes flags an index like idx_customer once another starts with its column; exact duplicates draw warning 1831.
sys.schema_unused_indexes counters restart with the server and each ALTER TABLE.
No index can seek on YEAR(ordered_at) = 2026 or LIKE '%.com'; rewrite the predicate (Section 3.12.9) or index the expression (Section 3.4.8).