Database NotesRohit’s interview study guide
Chapter 09

Indexes and Query Performance

How InnoDB indexes are built, how the optimizer chooses them, and how to read EXPLAIN, fix slow queries and paginate fast — measured on a 500,000-row table.

13 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#What an index is

Added

An index is a second, sorted copy of some of a table's columns, with a pointer back to each full row. Without one, the only way to answer WHERE customer_id = 4242 is to read every row and check it — a full table scan. With one, MySQL can jump straight to the entries for 4242, because they sit next to each other in sorted order.

The mental model is the index at the back of a book. To find every page that mentions "deadlock" you don't read the whole book; you look the word up in the sorted index, which lists page numbers, and turn to those pages. The index costs something: extra pages to print, and every new edition has to update it. Database indexes have the same two costs — disk space, and slower writes, because every INSERT, UPDATE of an indexed column and DELETE must also update every index.

The sample schema from chapter 01 is far too small for indexes to matter (the optimizer happily scans eight rows). So this chapter uses two generated tables in the ch09 database: big_customers (50,000 rows) and big_orders (500,000 rows). Every timing below comes from them.

Now the main example: the same lookup before and after adding an index. EXPLAIN ANALYZE (covered fully in its own topic) runs the query and reports what each step really did and how long it took.

SQL
EXPLAIN ANALYZE
SELECT id, status, ordered_at, total FROM big_orders WHERE customer_id = 4242\G

CREATE INDEX idx_customer ON big_orders (customer_id);

EXPLAIN ANALYZE
SELECT id, status, ordered_at, total FROM big_orders WHERE customer_id = 4242\G
Result
*************************** 1. row ***************************
EXPLAIN: -> Filter: (big_orders.customer_id = 4242)  (cost=51291 rows=49913) (actual time=1.49..55.5 rows=10 loops=1)
    -> Table scan on big_orders  (cost=51291 rows=499125) (actual time=0.362..47.1 rows=500000 loops=1)

1 row in set (0.056 sec)

Query OK, 0 rows affected (0.808 sec)
Records: 0  Duplicates: 0  Warnings: 0

*************************** 1. row ***************************
EXPLAIN: -> Index lookup on big_orders using idx_customer (customer_id = 4242)  (cost=10.5 rows=10) (actual time=0.081..0.0814 rows=10 loops=1)

1 row in set (0.000 sec)
What’s happening
  1. First run, no index: the plan is a Table scan that reads all rows=500000, with a Filter on top that keeps the 10 rows for customer 4242. It took about 55 ms. Every one of the 500,000 rows was read and compared, just to return 10.
  2. The optimizer's guess under the filter, rows=49913, is 10 % of the table: with no index and no histogram on customer_id it has no idea how many rows match, so it uses a fixed guess. Compare it with actual … rows=10.
  3. CREATE INDEX reads the table once, sorts the 500,000 (customer_id, id) pairs and writes them as a new B+tree. It took 0.8 s — a one-off cost.
  4. Second run: the plan is now a single Index lookup using idx_customer. MySQL walks down the tree to the first entry for 4242, reads the 10 adjacent entries, and fetches those 10 rows. Actual time: 0.08 ms — roughly 700 times faster, and the gap grows with the table, because a tree lookup costs a few page reads whether the table has 500 thousand rows or 500 million.
  5. The estimate is now exact (rows=10) because an index also gives the optimizer statistics about how the values are distributed.
AdvancedCost of indexes on writes and disk space

Every index is paid for on every write. Here the same 500,000 rows are copied into a table with no secondary indexes and into one with four:

SQL
CREATE TABLE copy_0_idx LIKE big_orders;
ALTER TABLE copy_0_idx DROP INDEX idx_customer;
CREATE TABLE copy_4_idx LIKE copy_0_idx;
ALTER TABLE copy_4_idx
  ADD INDEX (customer_id), ADD INDEX (status), ADD INDEX (ordered_at), ADD INDEX (total);

INSERT INTO copy_0_idx SELECT * FROM big_orders;
INSERT INTO copy_4_idx SELECT * FROM big_orders;

ANALYZE TABLE copy_0_idx, copy_4_idx;
SELECT table_name, ROUND(data_length/1024/1024, 1) AS data_mb, ROUND(index_length/1024/1024, 1) AS index_mb
FROM information_schema.tables WHERE table_schema = 'ch09' AND table_name LIKE 'copy%';
Result
Query OK, 500000 rows affected (0.826 sec)
Records: 500000  Duplicates: 0  Warnings: 0

Query OK, 500000 rows affected (2.270 sec)
Records: 500000  Duplicates: 0  Warnings: 0

+------------+---------+----------+
| TABLE_NAME | data_mb | index_mb |
+------------+---------+----------+
| copy_0_idx |    21.5 |      0.0 |
| copy_4_idx |    21.5 |     34.1 |
+------------+---------+----------+
What’s happening
  1. CREATE TABLE … LIKE copies the structure including indexes, so copy_0_idx drops idx_customer to start from the primary key only.
  2. Loading 500,000 rows into the bare table took 0.83 s; into the table with four secondary indexes it took 2.27 s — about 2.7 times slower (a second run gave 0.85 s and 2.08 s). Each row now means five B+tree inserts instead of one.
  3. The four indexes take 34.1 MB, more than the 21.5 MB of data itself. Indexes compete with data for the buffer pool, so an unused index also makes useful pages fall out of memory sooner.
  4. data_length here is the clustered index (the table itself, see Clustered and secondary indexes in InnoDB); index_length is the sum of the secondary indexes.
AdvancedPitfall: stale sizes in information_schema
AdvancedWhen indexes pay off and when they don't

Where indexes pay off, and where they don't:

  • Use them for columns in WHERE, JOIN … ON, ORDER BY and GROUP BY of frequent queries, especially when the filter matches a small fraction of rows.
  • Think twice on write-heavy tables (logging, event ingestion), on tiny tables (a scan of a few pages is as fast as a lookup), and on columns where almost every row has the same value (see Selectivity and cardinality).
  • Primary and unique keys are indexes too: PRIMARY KEY and UNIQUE always create one, and InnoDB creates an index for a foreign key if a suitable one doesn't exist.

#B+tree indexes

Added

InnoDB stores every index — including the table itself — as a B+tree of fixed-size pages (16 KB by default, innodb_page_size). A B+tree has three kinds of page:

  • Leaf pages (level 0) hold the actual index entries in key order. Leaves are linked to their neighbours in both directions, so once MySQL has found the start of a range it just walks sideways.
  • Internal pages (level 1 and up) hold separator keys and child page numbers — a signpost that says "keys from here to here are under page N".
  • One root page at the top. Every search starts there.

The "+" in B+tree is that only the leaves hold entries; internal pages are pure signposts. That keeps internal pages small and dense: one 16 KB page can point to hundreds of children, so the tree is extremely flat. The mental model is a filing cabinet: the label on the cabinet tells you which drawer, the drawer label tells you which folder, and the folders sit in order so you can flip forward through them. Three or four levels cover hundreds of millions of rows, which is why an indexed lookup reads only three or four pages.

root · page 4 level 2 · 2 child pointers internal page level 1 · id 1 … ~250,000 internal page level 1 · id ~250,000 … 500,000 id 1–376 full rows id 377–752 full rows … 1,327 more leaf pages … … full rows … 500,000 full rows leaves are linked left ↔ right, so a range scan walks sideways
The primary key of big_orders as InnoDB really stores it (root level and pointer counts read from the .ibd file below). The leaf ranges are approximate.

InnoDB keeps per-index statistics in mysql.innodb_index_stats, including how many pages each tree has. The root page number of each index is in information_schema.INNODB_INDEXES:

SQL
SELECT index_name,
       MAX(IF(stat_name = 'size', stat_value, NULL))         AS total_pages,
       MAX(IF(stat_name = 'n_leaf_pages', stat_value, NULL)) AS leaf_pages,
       ROUND(MAX(IF(stat_name = 'size', stat_value, NULL)) * 16 / 1024, 1) AS size_mb
FROM mysql.innodb_index_stats
WHERE database_name = 'ch09' AND table_name = 'big_orders'
GROUP BY index_name;

SELECT i.name AS index_name, i.page_no AS root_page
FROM information_schema.innodb_indexes AS i
JOIN information_schema.innodb_tables AS t USING (table_id)
WHERE t.name = 'ch09/big_orders';
Result
+--------------+-------------+------------+---------+
| index_name   | total_pages | leaf_pages | size_mb |
+--------------+-------------+------------+---------+
| PRIMARY      |        1379 |       1331 |    21.5 |
| idx_customer |         481 |        416 |     7.5 |
+--------------+-------------+------------+---------+
+--------------+-----------+
| index_name   | root_page |
+--------------+-----------+
| PRIMARY      |         4 |
| idx_customer |        39 |
+--------------+-----------+
What’s happening
  1. PRIMARY is the table itself: 1,331 leaf pages hold the 500,000 full rows (about 376 rows per 16 KB page). size (1,379) also counts the internal pages and pages allocated but not yet used.
  2. idx_customer has only 416 leaf pages: each entry is just customer_id plus the primary key id — 8 bytes of data — so about 1,200 entries fit per page.
  3. A lookup for customer_id = 4242 reads the root page 39, follows one pointer to the right leaf, then reads the full rows from the primary key tree. That's a handful of page reads, against 1,331 for a full scan.
  4. Root pages don't move once the index exists: in all 19 tables in ch09 the clustered index root is page 4, and each secondary index gets its own root when it's built. The root is read first on every lookup, so it is almost always in the buffer pool.
AdvancedReading the tree height from the .ibd file

You can check the shape yourself by reading the page header straight out of the tablespace file. PAGE_LEVEL is a 2-byte big-endian number at byte 64 of a page and PAGE_N_RECS is at byte 54. FLUSH TABLES … FOR EXPORT first makes InnoDB write the table's dirty pages to disk:

SQL
FLUSH TABLES big_orders FOR EXPORT;
UNLOCK TABLES;
AdvancedDecoding the root page header
rootpage.sh
# Usage: sh rootpage.sh <table.ibd> <page_no>
dd if="$1" bs=16384 skip="$2" count=1 2>/dev/null | od -An -v -t u1 -j 54 -N 12 |
  awk -v p="$2" '{ printf "page %s: level %d, records %d\n", p, $11 * 256 + $12, $1 * 256 + $2 }'
Result
$ sh rootpage.sh /var/lib/mysql/ch09/big_orders.ibd 4
page 4: level 2, records 2
$ sh rootpage.sh /var/lib/mysql/ch09/big_orders.ibd 39
page 39: level 1, records 416
What’s happening
  1. dd … skip=4 copies exactly page 4 (bytes 65,536–81,919) of the file; od prints 12 bytes starting at offset 54 as unsigned numbers, and awk combines the big-endian byte pairs.
  2. The primary key root is at level 2 with 2 records: it points to two internal pages (level 1), which point to the 1,331 leaves (level 0). So the tree is three levels deep, and every primary key lookup reads three pages.
  3. The idx_customer root is at level 1 with 416 records — exactly its 416 leaf pages. Only two levels: the root fits a pointer to every leaf.
  4. Rough capacity: if a level-1 page holds ~600 pointers to leaves of ~376 rows, two internal levels reach 600 × 600 × 376 ≈ 135 million rows with four page reads. That is why "B+trees are flat" is the key fact to remember.
AdvancedRange scans and index-ordered results

Because entries are sorted, one tree answers equality, ranges, prefixes and ordering. With idx_customer (customer_id):

SQL
EXPLAIN FORMAT=TREE SELECT id, customer_id FROM big_orders WHERE customer_id BETWEEN 100 AND 102\G
SELECT id, customer_id FROM big_orders WHERE customer_id BETWEEN 100 AND 102;
EXPLAIN FORMAT=TREE SELECT id, customer_id FROM big_orders ORDER BY customer_id LIMIT 5\G
EXPLAIN FORMAT=TREE SELECT id, customer_id FROM big_orders ORDER BY customer_id DESC LIMIT 5\G
Result
*************************** 1. row ***************************
EXPLAIN: -> Filter: (big_orders.customer_id between 100 and 102)  (cost=5.23 rows=21)
    -> Covering index range scan on big_orders using idx_customer over (100 <= customer_id <= 102)  (cost=5.23 rows=21)

+--------+-------------+
| id     | customer_id |
+--------+-------------+
|  34594 |         100 |
| 120213 |         100 |
| 261441 |         100 |
| 276128 |         100 |
| 304846 |         100 |
| 348486 |         100 |
| 385867 |         100 |
| 397881 |         100 |
| 476240 |         100 |
| 486376 |         100 |
|  32028 |         101 |
| 262445 |         101 |
| 281530 |         101 |
| 299606 |         101 |
| 322235 |         101 |
| 432820 |         101 |
| 492604 |         101 |
|  28574 |         102 |
| 117841 |         102 |
| 415422 |         102 |
| 439950 |         102 |
+--------+-------------+
21 rows in set (0.000 sec)

*************************** 1. row ***************************
EXPLAIN: -> Limit: 5 row(s)  (cost=0.0138 rows=5)
    -> Covering index scan on big_orders using idx_customer  (cost=0.0138 rows=5)

*************************** 1. row ***************************
EXPLAIN: -> Limit: 5 row(s)  (cost=0.0138 rows=5)
    -> Covering index scan on big_orders using idx_customer (reverse)  (cost=0.0138 rows=5)
What’s happening
  1. BETWEEN 100 AND 102 is a range scan: one descent to the first entry ≥ 100, then a sideways walk along the linked leaves until the first entry > 102. 21 entries read, 21 returned.
  2. Look at the order of the result: by customer_id, and within each customer by id. The leaf entries really are (customer_id, id) pairs, sorted on both — the primary key is part of every secondary index entry (next topic).
  3. ORDER BY customer_id LIMIT 5 needs no sort at all: the index already is the sorted order, so MySQL reads the first five entries and stops. DESC reads the same leaves backwards (reverse).
  4. "Covering" in the plan means the index alone had every column the query needed (id and customer_id), so the table rows were never touched. That gets its own topic: Covering indexes.
AdvancedWhat B+trees can't do, and hash indexes

The same sorted order explains what a B+tree can't do: a condition on a transformed value (WHERE customer_id + 1 = 4243, YEAR(ordered_at) = 2025), a LIKE '%suffix', or a condition on a column that isn't first in the index give MySQL no starting point in the sort order. See Why an index isn't used.

A hash index maps each key through a hash function to a bucket. It finds one exact key in O(1), but hashing destroys order, so it can't do ranges, prefixes or sorting. InnoDB doesn't let you create hash indexes (it silently builds a B+tree if you write USING HASH), but the MEMORY engine does:

SQL
CREATE TABLE mem_lookup (code INT NOT NULL, label VARCHAR(20), INDEX USING HASH (code)) ENGINE=MEMORY;
INSERT INTO mem_lookup
WITH RECURSIVE s(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM s WHERE n < 1000)
SELECT n, CONCAT('L', n) FROM s;

EXPLAIN FORMAT=TREE SELECT * FROM mem_lookup WHERE code = 500\G
EXPLAIN FORMAT=TREE SELECT * FROM mem_lookup WHERE code BETWEEN 500 AND 505\G
Result
*************************** 1. row ***************************
EXPLAIN: -> Index lookup on mem_lookup using code (code = 500)  (cost=0.7 rows=2)

*************************** 1. row ***************************
EXPLAIN: -> Filter: (mem_lookup.`code` between 500 and 505)  (cost=115 rows=111)
    -> Table scan on mem_lookup  (cost=115 rows=1000)
What’s happening
  1. code = 500 uses the hash index: hash 500, go to its bucket.
  2. code BETWEEN 500 AND 505 can't: 500…505 hash to unrelated buckets, so there is no "next entry" to walk to. MySQL falls back to scanning all 1,000 rows.
  3. That's the whole trade-off, and why general-purpose databases default to B+trees: one structure serves =, <, BETWEEN, LIKE 'abc%', ORDER BY and MIN/MAX.
  4. InnoDB's adaptive hash index builds an in-memory hash on top of hot B+tree pages automatically. It was on by default in MySQL 8.0 and is off by default from 8.4 (it caused contention under concurrent workloads); SELECT @@innodb_adaptive_hash_index returned 0 on this 9.7 server.

#Clustered and secondary indexes in InnoDB

Added

In InnoDB the table is an index. Rows are stored in the leaf pages of the primary key's B+tree, in primary key order — this is the clustered index. There is no separate heap of rows (PostgreSQL works that way; InnoDB doesn't).

Every other index is a secondary index. Its leaf entries hold the indexed columns plus the row's primary key value — not a physical address. So a lookup through a secondary index is two tree searches: find the entry in the secondary index, take the primary key from it, then search the clustered index for the full row. Mental model: the clustered index is the filing cabinet sorted by order number with the complete paperwork; a secondary index is a card catalogue sorted by customer, and each card just says "see order number 59040".

Two consequences follow, and both come up in interviews:

  • A secondary index already contains the primary key, so a query that only needs the indexed columns and the PK never touches the table.
  • A wide primary key makes every secondary index wider, because it's copied into each entry.
SQL
EXPLAIN FORMAT=TREE SELECT id FROM big_orders WHERE customer_id = 4242\G
EXPLAIN FORMAT=TREE SELECT id, total FROM big_orders WHERE customer_id = 4242\G
EXPLAIN FORMAT=TRADITIONAL SELECT id FROM big_orders WHERE customer_id = 4242;
EXPLAIN FORMAT=TRADITIONAL SELECT id, total FROM big_orders WHERE customer_id = 4242;
Result
*************************** 1. row ***************************
EXPLAIN: -> Covering index lookup on big_orders using idx_customer (customer_id = 4242)  (cost=2.01 rows=10)

*************************** 1. row ***************************
EXPLAIN: -> Index lookup on big_orders using idx_customer (customer_id = 4242)  (cost=11 rows=10)

+----+-------------+------------+------------+------+---------------+--------------+---------+-------+------+----------+-------------+
| id | select_type | table      | partitions | type | possible_keys | key          | key_len | ref   | rows | filtered | Extra       |
+----+-------------+------------+------------+------+---------------+--------------+---------+-------+------+----------+-------------+
|  1 | SIMPLE      | big_orders | NULL       | ref  | idx_customer  | idx_customer | 4       | const |   10 |   100.00 | Using index |
+----+-------------+------------+------------+------+---------------+--------------+---------+-------+------+----------+-------------+
+----+-------------+------------+------------+------+---------------+--------------+---------+-------+------+----------+-------+
| id | select_type | table      | partitions | type | possible_keys | key          | key_len | ref   | rows | filtered | Extra |
+----+-------------+------------+------------+------+---------------+--------------+---------+-------+------+----------+-------+
|  1 | SIMPLE      | big_orders | NULL       | ref  | idx_customer  | idx_customer | 4       | const |   10 |   100.00 | NULL  |
+----+-------------+------------+------------+------+---------------+--------------+---------+-------+------+----------+-------+
What’s happening
  1. idx_customer was declared on customer_id only, yet SELECT id … WHERE customer_id = 4242 is a Covering index lookup (Extra: Using index in the traditional format). The id it returns comes straight from the secondary index entries, because InnoDB stored the primary key in every entry.
  2. Add total and it's a plain Index lookup: the index has no total, so for each of the 10 entries InnoDB takes the id and descends the clustered index to fetch the row. That's 10 extra tree searches, which the optimizer prices in: cost=11 against cost=2.01.
  3. Both plans have type: ref and key_len: 4 (one INT). The only visible difference in the old format is Using index in Extra — learn to look for it.
  4. In MySQL this second lookup goes through the primary key. That's why InnoDB rows can move between pages (page splits) without updating secondary indexes: they never stored a physical location.
AdvancedTables without a primary key

If you don't declare a primary key, InnoDB still needs a clustered index. It uses the first UNIQUE index whose columns are all NOT NULL; failing that, it adds a hidden 6-byte row id and clusters on that, in an index called GEN_CLUST_INDEX. You can't use the hidden id in queries, and replication tools struggle with tables that have no usable key. MySQL 8.0.30 added sql_generate_invisible_primary_key, which instead adds a real but invisible my_row_id column:

SQL
CREATE TABLE no_pk (name VARCHAR(20), city VARCHAR(20));
INSERT INTO no_pk VALUES ('Rohit','Toronto'),('Maya','Mumbai');
SELECT index_name, stat_name, stat_value FROM mysql.innodb_index_stats
WHERE database_name = 'ch09' AND table_name = 'no_pk' AND stat_name = 'n_diff_pfx01';

SET SESSION sql_generate_invisible_primary_key = ON;
CREATE TABLE gipk_demo (name VARCHAR(20), city VARCHAR(20));
SHOW CREATE TABLE gipk_demo\G
INSERT INTO gipk_demo VALUES ('Rohit','Toronto'),('Maya','Mumbai');
SELECT * FROM gipk_demo;
SELECT my_row_id, name FROM gipk_demo;
Result
+-----------------+--------------+------------+
| index_name      | stat_name    | stat_value |
+-----------------+--------------+------------+
| GEN_CLUST_INDEX | n_diff_pfx01 |          0 |
+-----------------+--------------+------------+
*************************** 1. row ***************************
       Table: gipk_demo
Create Table: CREATE TABLE `gipk_demo` (
  `my_row_id` bigint unsigned NOT NULL AUTO_INCREMENT /*!80023 INVISIBLE */,
  `name` varchar(20) DEFAULT NULL,
  `city` varchar(20) DEFAULT NULL,
  PRIMARY KEY (`my_row_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
+-------+---------+
| name  | city    |
+-------+---------+
| Rohit | Toronto |
| Maya  | Mumbai  |
+-------+---------+
+-----------+-------+
| my_row_id | name  |
+-----------+-------+
|         1 | Rohit |
|         2 | Maya  |
+-----------+-------+
What’s happening
  1. no_pk has neither a primary key nor a unique index, so InnoDB clustered it on the hidden row id: the index is GEN_CLUST_INDEX. (Its statistic shows 0 only because stats hadn't been recalculated for two fresh rows.)
  2. With sql_generate_invisible_primary_key = ON, the same CREATE TABLE gets a my_row_id BIGINT UNSIGNED AUTO_INCREMENT INVISIBLE primary key. SELECT * doesn't show invisible columns, so existing code isn't surprised, but you can name it explicitly.
  3. The cleaner rule is simply: give every InnoDB table an explicit, short, ever-increasing primary key. Managed services often enforce it with sql_require_primary_key = ON (it's 0 by default).
AdvancedHow a wide primary key bloats secondary indexes

The same 200,000 rows and the same secondary index on customer_id, once with an INT primary key and once with a CHAR(36) UUID:

SQL
CREATE TABLE pk_int  (id INT AUTO_INCREMENT PRIMARY KEY, customer_id INT NOT NULL, total DECIMAL(10,2) NOT NULL, INDEX idx_customer (customer_id));
CREATE TABLE pk_uuid (id CHAR(36) PRIMARY KEY,           customer_id INT NOT NULL, total DECIMAL(10,2) NOT NULL, INDEX idx_customer (customer_id));

INSERT INTO pk_int (customer_id, total)
WITH RECURSIVE s(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM s WHERE n < 200000)
SELECT 1 + CRC32(n) % 50000, 10 FROM s;
INSERT INTO pk_uuid (id, customer_id, total)
WITH RECURSIVE s(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM s WHERE n < 200000)
SELECT UUID(), 1 + CRC32(n) % 50000, 10 FROM s;

ANALYZE TABLE pk_int, pk_uuid;
SELECT table_name, index_name, stat_value AS pages, ROUND(stat_value * 16 / 1024, 1) AS mb
FROM mysql.innodb_index_stats
WHERE database_name = 'ch09' AND table_name IN ('pk_int','pk_uuid') AND stat_name = 'size'
ORDER BY table_name, index_name;
Result
Query OK, 200000 rows affected (0.359 sec)
Records: 200000  Duplicates: 0  Warnings: 0

Query OK, 200000 rows affected (0.733 sec)
Records: 200000  Duplicates: 0  Warnings: 0

+------------+--------------+-------+------+
| table_name | index_name   | pages | mb   |
+------------+--------------+-------+------+
| pk_int     | PRIMARY      |   417 |  6.5 |
| pk_int     | idx_customer |   353 |  5.5 |
| pk_uuid    | PRIMARY      |   868 | 13.6 |
| pk_uuid    | idx_customer |  1061 | 16.6 |
+------------+--------------+-------+------+
What’s happening
  1. The data is identical apart from the key. The UUID() insert took twice as long (0.73 s against 0.36 s): new UUIDs land at random places in the clustered index, so InnoDB keeps splitting half-full pages instead of appending at the right-hand edge.
  2. The clustered index doubled (6.5 → 13.6 MB): a 36-character key per row, and pages left partly empty by the splits.
  3. The secondary index tripled (5.5 → 16.6 MB) although it indexes the same INT column — every entry now carries a 36-byte copy of the primary key instead of 4 bytes.
  4. If you need UUIDs, store them as BINARY(16) with UUID_TO_BIN(UUID(), 1) (the 1 moves the timestamp to the front so values arrive in order) or use time-ordered UUIDv7 values. Primary keys: AUTO_INCREMENT vs UUID covers the choice.

#Composite indexes and the leftmost-prefix rule

Added

A composite index covers several columns, e.g. (customer_id, status, ordered_at). Its entries are sorted by the first column, then by the second within equal firsts, then by the third — exactly like a phone book sorted by surname, then first name. The phone book is great for "everyone called Varma", fine for "Rohit Varma", and useless for "everyone called Rohit": all the Rohits are scattered across the book.

That's the leftmost-prefix rule: an index on (a, b, c) can be used for conditions on a, on a and b, or on a, b and c — a prefix of its columns, starting from the left. Two refinements matter in practice:

  • Equality columns first, then one range. With a = ? AND b > ? AND c = ?, MySQL can seek on a and range over b, but c can no longer be used to seek, because within the b range the c values aren't sorted. (It can still be checked inside the index, which EXPLAIN shows as Using index condition.)
  • key_len tells you how many columns were used. It's the byte length of the used prefix: 4 for an INT, 1 for this ENUM, 5 for a DATETIME, plus 1 for each nullable column.
SQL
DROP INDEX idx_customer ON big_orders;
CREATE INDEX idx_cust_status_date ON big_orders (customer_id, status, ordered_at);

EXPLAIN FORMAT=TRADITIONAL SELECT * FROM big_orders WHERE customer_id = 4242;
EXPLAIN FORMAT=TRADITIONAL SELECT * FROM big_orders WHERE customer_id = 4242 AND status = 'shipped';
EXPLAIN FORMAT=TRADITIONAL SELECT * FROM big_orders WHERE customer_id = 4242 AND status = 'shipped' AND ordered_at >= '2024-01-01';
EXPLAIN FORMAT=TRADITIONAL SELECT * FROM big_orders WHERE status = 'shipped' AND ordered_at >= '2025-10-01';
EXPLAIN FORMAT=TRADITIONAL SELECT * FROM big_orders WHERE customer_id = 4242 AND ordered_at >= '2024-01-01';
Result
+----+-------------+------------+------------+------+----------------------+----------------------+---------+-------+------+----------+-------+
| id | select_type | table      | partitions | type | possible_keys        | key                  | key_len | ref   | rows | filtered | Extra |
+----+-------------+------------+------------+------+----------------------+----------------------+---------+-------+------+----------+-------+
|  1 | SIMPLE      | big_orders | NULL       | ref  | idx_cust_status_date | idx_cust_status_date | 4       | const |   10 |   100.00 | NULL  |
+----+-------------+------------+------------+------+----------------------+----------------------+---------+-------+------+----------+-------+
+----+-------------+------------+------------+------+----------------------+----------------------+---------+-------------+------+----------+-----------------------+
| id | select_type | table      | partitions | type | possible_keys        | key                  | key_len | ref         | rows | filtered | Extra                 |
+----+-------------+------------+------------+------+----------------------+----------------------+---------+-------------+------+----------+-----------------------+
|  1 | SIMPLE      | big_orders | NULL       | ref  | idx_cust_status_date | idx_cust_status_date | 5       | const,const |    5 |   100.00 | Using index condition |
+----+-------------+------------+------------+------+----------------------+----------------------+---------+-------------+------+----------+-----------------------+
+----+-------------+------------+------------+-------+----------------------+----------------------+---------+------+------+----------+-----------------------+
| id | select_type | table      | partitions | type  | possible_keys        | key                  | key_len | ref  | rows | filtered | Extra                 |
+----+-------------+------------+------------+-------+----------------------+----------------------+---------+------+------+----------+-----------------------+
|  1 | SIMPLE      | big_orders | NULL       | range | idx_cust_status_date | idx_cust_status_date | 10      | NULL |    4 |   100.00 | Using index condition |
+----+-------------+------------+------------+-------+----------------------+----------------------+---------+------+------+----------+-----------------------+
+----+-------------+------------+------------+------+---------------+------+---------+------+--------+----------+-------------+
| id | select_type | table      | partitions | type | possible_keys | key  | key_len | ref  | rows   | filtered | Extra       |
+----+-------------+------------+------------+------+---------------+------+---------+------+--------+----------+-------------+
|  1 | SIMPLE      | big_orders | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 499125 |     8.33 | Using where |
+----+-------------+------------+------------+------+---------------+------+---------+------+--------+----------+-------------+
+----+-------------+------------+------------+------+----------------------+----------------------+---------+-------+------+----------+-----------------------+
| id | select_type | table      | partitions | type | possible_keys        | key                  | key_len | ref   | rows | filtered | Extra                 |
+----+-------------+------------+------------+------+----------------------+----------------------+---------+-------+------+----------+-----------------------+
|  1 | SIMPLE      | big_orders | NULL       | ref  | idx_cust_status_date | idx_cust_status_date | 4       | const |   10 |    33.33 | Using index condition |
+----+-------------+------------+------------+------+----------------------+----------------------+---------+-------+------+----------+-----------------------+
What’s happening
  1. customer_id = 4242 uses the first column only: type: ref, key_len: 4 (one INT), ref: const. A one-column prefix is a perfectly good use of the index — which is why the separate idx_customer index was dropped: it would be redundant.
  2. Adding status = 'shipped' uses two columns: key_len: 5 (4 + 1 byte for the ENUM), ref: const,const, and the estimate drops to 5 rows.
  3. Adding ordered_at >= '2024-01-01' uses all three: key_len: 10 (4 + 1 + 5 for the DATETIME) and type: range — MySQL seeks to (4242, 'shipped', '2024-01-01') and walks forward while customer and status still match.
  4. status = … AND ordered_at >= … without customer_id has no usable prefix: possible_keys: NULL, type: ALL, 499,125 rows. The index is sorted by customer first, so all the shipped orders are scattered through it, like the Rohits in the phone book.
  5. customer_id = 4242 AND ordered_at >= … skips the middle column. Only customer_id can be used to seek (key_len: 4); ordered_at is checked against each of the 10 index entries before the row is fetched — that's index condition pushdown, Using index condition. If this query matters, an index on (customer_id, ordered_at) serves it fully.
AdvancedRanges, IN lists and ORDER BY

A range on the first column limits what the later columns can do, while an IN list is a set of equalities. And the index order can replace a sort:

SQL
EXPLAIN FORMAT=TRADITIONAL SELECT * FROM big_orders WHERE customer_id BETWEEN 100 AND 200 AND status = 'pending';
EXPLAIN FORMAT=TRADITIONAL SELECT * FROM big_orders WHERE customer_id IN (100, 150, 200) AND status = 'pending';
EXPLAIN FORMAT=TREE SELECT id, ordered_at, total FROM big_orders WHERE customer_id = 4242 ORDER BY ordered_at\G
EXPLAIN FORMAT=TREE SELECT id, ordered_at, total FROM big_orders WHERE customer_id = 4242 AND status = 'shipped' ORDER BY ordered_at DESC\G
Result
+----+-------------+------------+------------+-------+----------------------+----------------------+---------+------+------+----------+-----------------------+
| id | select_type | table      | partitions | type  | possible_keys        | key                  | key_len | ref  | rows | filtered | Extra                 |
+----+-------------+------------+------------+-------+----------------------+----------------------+---------+------+------+----------+-----------------------+
|  1 | SIMPLE      | big_orders | NULL       | range | idx_cust_status_date | idx_cust_status_date | 5       | NULL |  950 |    25.00 | Using index condition |
+----+-------------+------------+------------+-------+----------------------+----------------------+---------+------+------+----------+-----------------------+
+----+-------------+------------+------------+-------+----------------------+----------------------+---------+------+------+----------+-----------------------+
| id | select_type | table      | partitions | type  | possible_keys        | key                  | key_len | ref  | rows | filtered | Extra                 |
+----+-------------+------------+------------+-------+----------------------+----------------------+---------+------+------+----------+-----------------------+
|  1 | SIMPLE      | big_orders | NULL       | range | idx_cust_status_date | idx_cust_status_date | 5       | NULL |    3 |   100.00 | Using index condition |
+----+-------------+------------+------------+-------+----------------------+----------------------+---------+------+------+----------+-----------------------+
*************************** 1. row ***************************
EXPLAIN: -> Sort: big_orders.ordered_at  (cost=3.5 rows=10)
    -> Index lookup on big_orders using idx_cust_status_date (customer_id = 4242)  (cost=3.5 rows=10)

*************************** 1. row ***************************
EXPLAIN: -> Filter: (big_orders.`status` = 'shipped')  (cost=1.75 rows=5)
    -> Index lookup on big_orders using idx_cust_status_date (customer_id = 4242, status = 'shipped') (reverse)  (cost=1.75 rows=5)
What’s happening
  1. BETWEEN 100 AND 200 AND status = 'pending': key_len says 5, but rows: 950 gives it away — MySQL reads every order of customers 100–200 (about 1,000) and filters status in the index. The status only bounds the two ends of the range; customers 101–199 are read in full.
  2. IN (100, 150, 200) is three equalities, so MySQL does three exact seeks on (customer, 'pending'): rows: 3. Rewriting a small integer range as an IN list is a real optimisation trick for this shape of query.
  3. customer_id = 4242 ORDER BY ordered_at needs a Sort: the index has status between customer_id and ordered_at, so within one customer the entries are ordered by status first, not by date.
  4. Add status = 'shipped' and the sort disappears: for a fixed customer and status the entries are ordered by ordered_at, so MySQL reads them backwards (reverse) for DESC. Rule: equality on every column before the sort column, and the sort comes free.
AdvancedSkip scan: a way round a missing prefix

MySQL 8.0.13 added skip scan: if the first index column has few distinct values, MySQL can do one range scan per distinct value, as if you had written first IN (all its values). It only applies when the index covers the query. With a throwaway index on (status, country):

SQL
CREATE INDEX idx_status_country ON big_orders (status, country);
EXPLAIN ANALYZE SELECT COUNT(*) FROM big_orders WHERE country = 'AE'\G
EXPLAIN ANALYZE SELECT /*+ NO_SKIP_SCAN(big_orders) */ COUNT(*) FROM big_orders WHERE country = 'AE'\G
DROP INDEX idx_status_country ON big_orders;
Result
*************************** 1. row ***************************
EXPLAIN: -> Aggregate: count(0)  (cost=40105 rows=1) (actual time=5.87..5.87 rows=1 loops=1)
    -> Filter: (big_orders.country = 'AE')  (cost=20937 rows=83187) (actual time=0.00437..5.21 rows=49975 loops=1)
        -> Covering index skip scan on big_orders using idx_status_country over country = 'AE'  (cost=20937 rows=83187) (actual time=0.00404..3.51 rows=49975 loops=1)

*************************** 1. row ***************************
EXPLAIN: -> Aggregate: count(0)  (cost=61758 rows=1) (actual time=36.7..36.7 rows=1 loops=1)
    -> Filter: (big_orders.country = 'AE')  (cost=50257 rows=49913) (actual time=0.184..36 rows=49975 loops=1)
        -> Covering index scan on big_orders using idx_status_country  (cost=50257 rows=499125) (actual time=0.183..23.8 rows=500000 loops=1)
What’s happening
  1. country is the second column, so by the leftmost-prefix rule the index can't be used to seek. But status has only four values, so the skip scan jumps to ('cancelled','AE'), ('paid','AE'), ('pending','AE') and ('shipped','AE') and reads just those runs: 49,975 entries, 5.9 ms.
  2. With the NO_SKIP_SCAN hint the same index is read from end to end — all 500,000 entries, 36.7 ms. (Still covering, still better than a table scan, but six times slower.)
  3. Skip scan is a safety net, not a design: with customer_id first (50,000 values) it would mean 50,000 little range scans and the optimizer won't choose it. Design indexes so the leftmost column is in your WHERE.
Which queries can use the index?

The table has INDEX idx (a, b, c). Which of these can use the index to seek, and on how many columns?

  1. WHERE a = 1 AND c = 3
  2. WHERE b = 2 AND a = 1
  3. WHERE a > 1 AND b = 2
  4. WHERE b = 2 AND c = 3
Show answer
  1. Seeks on a only; c is checked inside the index (index condition pushdown) because b is missing between them.
  2. Seeks on a and b. The order of conditions in WHERE doesn't matter — the optimizer matches columns, not text.
  3. Seeks on a only, as a range. Once a is a range, the b values inside it aren't sorted, so b can only be filtered.
  4. Can't seek at all: no condition on a. At best a full index scan (if the index covers the query) or a skip scan (if a has very few distinct values).
What’s happening
  1. Picture the entries sorted by a, then b, then c.
  2. For each query, find the longest run of leading columns with = conditions, plus at most one more column used as a range. That's the seek prefix.
  3. Query 1: the prefix is a, then b is missing → stop. Query 2: a and b are both =. Query 3: a is a range → stop after it. Query 4: no a → no prefix.

#Covering indexes

Added

An index covers a query when it contains every column the query reads — in SELECT, WHERE, JOIN, GROUP BY and ORDER BY. Then MySQL answers from the index alone and never visits the clustered index. EXPLAIN shows it as Using index (traditional format) or Covering index … (tree format).

Why it matters so much: a non-covering lookup costs one extra clustered-index search per matching row, and those searches jump around the table in primary-key order, not in index order. For ten rows that's nothing. For twenty thousand rows it is most of the query's time. A covering index replaces all of them with a sequential walk along the index leaves. Mental model: the card catalogue that already has the summary you need written on the card, so you never walk to the shelf.

SQL
EXPLAIN ANALYZE
SELECT COUNT(*), SUM(total) FROM big_orders WHERE customer_id BETWEEN 1 AND 2000\G

CREATE INDEX idx_cust_total ON big_orders (customer_id, total);

EXPLAIN ANALYZE
SELECT COUNT(*), SUM(total) FROM big_orders WHERE customer_id BETWEEN 1 AND 2000\G
Result
*************************** 1. row ***************************
EXPLAIN: -> Aggregate: count(0), sum(big_orders.total)  (cost=56906 rows=1) (actual time=90.3..90.3 rows=1 loops=1)
    -> Index range scan on big_orders using idx_cust_status_date over (1 <= customer_id <= 2000), with index condition: (big_orders.customer_id between 1 and 2000)  (cost=47736 rows=39798) (actual time=31.6..83.7 rows=19875 loops=1)

1 row in set (0.092 sec)

Query OK, 0 rows affected (2.302 sec)
Records: 0  Duplicates: 0  Warnings: 0

*************************** 1. row ***************************
EXPLAIN: -> Aggregate: count(0), sum(big_orders.total)  (cost=16943 rows=1) (actual time=3.1..3.1 rows=1 loops=1)
    -> Filter: (big_orders.customer_id between 1 and 2000)  (cost=7907 rows=39218) (actual time=0.162..2.49 rows=19875 loops=1)
        -> Covering index range scan on big_orders using idx_cust_total over (1 <= customer_id <= 2000)  (cost=7907 rows=39218) (actual time=0.16..1.92 rows=19875 loops=1)

1 row in set (0.005 sec)
What’s happening
  1. The query needs customer_id (to filter) and total (to sum). idx_cust_status_date has customer_id but not total, so the range scan finds 19,875 index entries and then does 19,875 clustered-index lookups to read total. 90 ms.
  2. idx_cust_total (customer_id, total) has both columns. The plan becomes a Covering index range scan: walk the 19,875 adjacent entries and add up total as you go. 3.1 ms — about 30 times faster.
  3. Repeating both versions several times gave 32–80 ms without the covering index and 2.3–3.3 ms with it; the sandbox shares a CPU with other work, but the ratio stays the same.
  4. The optimizer's estimates (rows=39798) are double the real 19,875. That's normal: estimates come from sampled statistics. What matters here is the access path.
  5. The price: CREATE INDEX took 2.3 s and the index adds to every insert. A covering index is worth it for a hot, frequent query, not for every report.
AdvancedTip: no need to add the primary key
AdvancedCovering indexes in PostgreSQL: INCLUDE

PostgreSQL 11+ lets you add non-key columns to an index with INCLUDE. They are stored in the leaves only, so they can be returned without making the tree bigger or affecting its order. MySQL has no INCLUDE; you append the columns to the key instead, as above. The same 500,000-row shape in PostgreSQL 18:

SQL
CREATE INDEX idx_cust_incl ON big_orders (customer_id) INCLUDE (total);
VACUUM ANALYZE big_orders;
EXPLAIN (ANALYZE, COSTS OFF)
SELECT count(*), sum(total) FROM big_orders WHERE customer_id BETWEEN 1 AND 2000;
Result
                                                QUERY PLAN
----------------------------------------------------------------------------------------------------------
 Aggregate (actual time=1.383..1.383 rows=1.00 loops=1)
   Buffers: shared hit=4 read=76
   ->  Index Only Scan using idx_cust_incl on big_orders (actual time=0.005..0.798 rows=19973.00 loops=1)
         Index Cond: ((customer_id >= 1) AND (customer_id <= 2000))
         Heap Fetches: 0
         Index Searches: 1
         Buffers: shared hit=4 read=76
 Planning:
   Buffers: shared hit=39 read=5
 Planning Time: 0.104 ms
 Execution Time: 1.394 ms
(11 rows)
What’s happening
  1. PostgreSQL calls a covering read an Index Only Scan. It read 80 pages (hit=4 read=76) to sum 19,973 values (its hash function made a slightly different data set).
  2. Heap Fetches: 0 is the PostgreSQL-specific part. Its tables are heaps, and an index entry may point to a row version that isn't visible to you, so it has to check the heap unless the page is marked all-visible. VACUUM sets that flag; on a table that hasn't been vacuumed, an "index only" scan can still fetch from the heap.
  3. In InnoDB the visibility check works differently (secondary index pages carry the highest transaction id that changed them, and InnoDB only goes to the clustered index if that's newer than your snapshot), so a covering read is covering without any VACUUM.

#Selectivity and cardinality

Added

Cardinality is the number of distinct values in a column (or index prefix). Selectivity is cardinality divided by the number of rows: 1.0 for a unique column, close to 0 for a yes/no flag. For a single lookup what really counts is the fraction of rows that one particular value matches — customer_id = 4242 matches 10 rows out of 500,000 (0.002 %), status = 'shipped' matches 350,018 (70 %).

An index pays off when it lets MySQL skip most of the table. Each matching entry in a secondary index costs a random lookup into the clustered index, while a table scan reads pages sequentially and fast. So past a certain fraction of matching rows — often quoted as somewhere between 5 % and 30 %, depending on row size and caching — scanning is cheaper than using the index. Mental model: to find three recipes in a cookbook you use the index; to find every recipe that uses salt, you just read the book.

The optimizer decides by estimating rows from statistics and comparing costs. When the estimate is wrong, it picks the wrong plan. Here is an index on the four-value status column:

SQL
CREATE INDEX idx_status ON big_orders (status);

EXPLAIN ANALYZE SELECT * FROM big_orders WHERE status = 'pending'\G
EXPLAIN ANALYZE SELECT * FROM big_orders IGNORE INDEX (idx_status) WHERE status = 'pending'\G
EXPLAIN ANALYZE SELECT * FROM big_orders WHERE status = 'shipped'\G
EXPLAIN ANALYZE SELECT * FROM big_orders IGNORE INDEX (idx_status) WHERE status = 'shipped'\G
Result
Query OK, 0 rows affected (0.406 sec)
Records: 0  Duplicates: 0  Warnings: 0

*************************** 1. row ***************************
EXPLAIN: -> Index lookup on big_orders using idx_status (status = 'pending'), with index condition: (big_orders.`status` = 'pending')  (cost=9103 rows=80688) (actual time=1.99..18.1 rows=40016 loops=1)

1 row in set (0.020 sec)

*************************** 1. row ***************************
EXPLAIN: -> Filter: (big_orders.`status` = 'pending')  (cost=50257 rows=166375) (actual time=0.29..53.4 rows=40016 loops=1)
    -> Table scan on big_orders  (cost=50257 rows=499125) (actual time=0.288..40.9 rows=500000 loops=1)

1 row in set (0.056 sec)

*************************** 1. row ***************************
EXPLAIN: -> Index lookup on big_orders using idx_status (status = 'shipped'), with index condition: (big_orders.`status` = 'shipped')  (cost=25990 rows=249562) (actual time=1.73..141 rows=350018 loops=1)

1 row in set (0.159 sec)

*************************** 1. row ***************************
EXPLAIN: -> Filter: (big_orders.`status` = 'shipped')  (cost=50257 rows=166375) (actual time=0.291..62.3 rows=350018 loops=1)
    -> Table scan on big_orders  (cost=50257 rows=499125) (actual time=0.29..42.5 rows=500000 loops=1)

1 row in set (0.082 sec)
What’s happening
  1. pending (8 % of rows): the optimizer uses idx_status and fetches 40,016 rows through the clustered index in 18 ms. Forcing a table scan with IGNORE INDEX takes 53 ms, so at 8 % the index still wins, by about 3×.
  2. shipped (70 %): the optimizer still chooses the index. It estimated 249,562 rows (half the table) at a cost of 25,990, cheaper than the scan's 50,257. Reality: 350,018 clustered-index lookups, 141 ms.
  3. The table scan for the same query reads all 500,000 rows sequentially and keeps 350,018: 62 ms, more than twice as fast as the plan the optimizer picked. Four repeated runs gave 135–140 ms for the index and 59–62 ms for the scan; an earlier run while the sandbox was busy with other work gave 800–960 ms against 75–160 ms — random lookups suffer far more from contention than sequential reads.
  4. The crossover between "index wins" and "scan wins" was somewhere between 8 % and 70 % of the table here; on disk-bound servers (data not in memory) it comes much earlier. So a low-cardinality index isn't just useless for common values, it can be harmful: it costs writes and invites a bad plan. Fixes, best first: don't index status alone; put it in a composite index after a selective column ((customer_id, status, …) from earlier); or override with IGNORE INDEX for a known report.
AdvancedMeasuring selectivity and reading Cardinality

The exact selectivity of two columns, and the cardinality estimates InnoDB keeps for every index:

SQL
SELECT COUNT(DISTINCT status) AS statuses, COUNT(DISTINCT customer_id) AS customers, COUNT(*) AS total_rows,
       ROUND(COUNT(DISTINCT status) / COUNT(*), 6) AS status_selectivity,
       ROUND(COUNT(DISTINCT customer_id) / COUNT(*), 6) AS customer_selectivity
FROM big_orders;

SELECT index_name, seq_in_index, column_name, cardinality FROM information_schema.statistics
WHERE table_schema = 'ch09' AND table_name = 'big_orders' ORDER BY index_name, seq_in_index;
Result
+----------+-----------+------------+--------------------+----------------------+
| statuses | customers | total_rows | status_selectivity | customer_selectivity |
+----------+-----------+------------+--------------------+----------------------+
|        4 |     50000 |     500000 |             0.0000 |               0.1000 |
+----------+-----------+------------+--------------------+----------------------+
+----------------------+--------------+-------------+-------------+
| INDEX_NAME           | SEQ_IN_INDEX | COLUMN_NAME | CARDINALITY |
+----------------------+--------------+-------------+-------------+
| idx_cust_status_date |            1 | customer_id |       47959 |
| idx_cust_status_date |            2 | status      |      143218 |
| idx_cust_status_date |            3 | ordered_at  |      499125 |
| idx_cust_total       |            1 | customer_id |       48820 |
| idx_cust_total       |            2 | total       |      498664 |
| idx_status           |            1 | status      |           3 |
| PRIMARY              |            1 | id          |      499125 |
+----------------------+--------------+-------------+-------------+
What’s happening
  1. The exact numbers: 4 statuses and 50,000 customers in 500,000 rows. Selectivity 0.1 for customer_id (about 10 rows per value) and practically 0 for status. (ROUND(…, 6) printed 0.0000 because MySQL kept the division's own scale of four decimals.)
  2. CARDINALITY in information_schema.STATISTICS (also SHOW INDEX) is an estimate from sampling: InnoDB reads innodb_stats_persistent_sample_pages (20 by default) random leaf pages per index. So customer_id shows 47,959 instead of 50,000, and status shows 3 instead of 4.
  3. For a composite index, the cardinality on row n is for the prefix up to that column: (customer_id, status) has 143,218 distinct pairs; (customer_id, status, ordered_at) is effectively unique.
  4. Cardinality is per index prefix, not per column, and only for indexed columns. For other columns, the optimizer guesses — unless you give it a histogram.
AdvancedHistograms for columns without an index

Since MySQL 8.0, ANALYZE TABLE … UPDATE HISTOGRAM stores the value distribution of a column in the data dictionary. The optimizer uses it to estimate how many rows a condition keeps (the filtered column of EXPLAIN), which affects join order and plan choice:

SQL
EXPLAIN FORMAT=TRADITIONAL SELECT * FROM big_orders WHERE country = 'IN';
ANALYZE TABLE big_orders UPDATE HISTOGRAM ON country WITH 16 BUCKETS;
EXPLAIN FORMAT=TRADITIONAL SELECT * FROM big_orders WHERE country = 'IN';
EXPLAIN FORMAT=TRADITIONAL SELECT * FROM big_orders WHERE country = 'AE';
SELECT column_name, histogram->>'$."histogram-type"' AS type, JSON_LENGTH(histogram->'$.buckets') AS buckets, histogram->'$.buckets' AS data
FROM information_schema.column_statistics WHERE schema_name = 'ch09' AND table_name = 'big_orders';
Result
+----+-------------+------------+------------+------+---------------+------+---------+------+--------+----------+-------------+
| id | select_type | table      | partitions | type | possible_keys | key  | key_len | ref  | rows   | filtered | Extra       |
+----+-------------+------------+------------+------+---------------+------+---------+------+--------+----------+-------------+
|  1 | SIMPLE      | big_orders | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 499125 |    10.00 | Using where |
+----+-------------+------------+------------+------+---------------+------+---------+------+--------+----------+-------------+
+-----------------+-----------+----------+----------------------------------------------------+
| Table           | Op        | Msg_type | Msg_text                                           |
+-----------------+-----------+----------+----------------------------------------------------+
| ch09.big_orders | histogram | status   | Histogram statistics created for column 'country'. |
+-----------------+-----------+----------+----------------------------------------------------+
+----+-------------+------------+------------+------+---------------+------+---------+------+--------+----------+-------------+
| id | select_type | table      | partitions | type | possible_keys | key  | key_len | ref  | rows   | filtered | Extra       |
+----+-------------+------------+------------+------+---------------+------+---------+------+--------+----------+-------------+
|  1 | SIMPLE      | big_orders | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 499125 |    30.19 | Using where |
+----+-------------+------------+------------+------+---------------+------+---------+------+--------+----------+-------------+
+----+-------------+------------+------------+------+---------------+------+---------+------+--------+----------+-------------+
| id | select_type | table      | partitions | type | possible_keys | key  | key_len | ref  | rows   | filtered | Extra       |
+----+-------------+------------+------------+------+---------------+------+---------+------+--------+----------+-------------+
|  1 | SIMPLE      | big_orders | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 499125 |    10.05 | Using where |
+----+-------------+------------+------------+------+---------------+------+---------+------+--------+----------+-------------+
+-------------+-----------+---------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| COLUMN_NAME | type      | buckets | data                                                                                                                                                                                                                 |
+-------------+-----------+---------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| country     | singleton |       5 | [["base64:type254:QUU=", 0.10050767212366665], ["base64:type254:Q0E=", 0.2990916091495066], ["base64:type254:R0I=", 0.39976327648166105], ["base64:type254:SU4=", 0.7017069762135646], ["base64:type254:VVM=", 1.0]] |
+-------------+-----------+---------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
What’s happening
  1. Without statistics, country = 'IN' gets the fixed guess for an equality: filtered: 10.00 (10 %).
  2. After the histogram, the estimate is 30.19 % for IN and 10.05 % for AE — matching the real 30.1 % and 10.0 %.
  3. With 5 distinct values and 16 buckets allowed, MySQL built a singleton histogram: one bucket per value, storing the cumulative frequency. QUU= is base64 for AE (0.1005), Q0E= is CA (0.2991 − 0.1005 ≈ 20 %), and so on up to US at 1.0. With more distinct values than buckets it builds an equi-height histogram of ranges instead.
  4. Histograms aren't maintained automatically: they're rebuilt only when you run ANALYZE TABLE … UPDATE HISTOGRAM again. The manual is explicit that they're mainly for non-indexed columns — if the range optimizer can use an index dive for a condition, the optimizer prefers that and ignores the histogram.

#Reading EXPLAIN

Added

EXPLAIN shows the plan the optimizer chose without running the query. It's the first tool for every "why is this slow?" question. There are three output formats:

  • TRADITIONAL — one row per table access, with fixed columns. Still what most blog posts, interview questions and MySQL 8.0/8.4 servers show by default.
  • TREE — the iterator tree MySQL actually executes, with costs and row estimates on every step. Since MySQL 9.5 it's the default for a plain EXPLAIN (the release notes list explain_format changing from TRADITIONAL to TREE), and it's the only format that shows hash joins.
  • JSON — machine-readable detail. Since 9.5 the default JSON layout is version 2, based on the same iterator tree (explain_json_format_version).

In the traditional format, the column that matters most is type — the access method — from best to worst:

typeMeaningExample on these tables
system / constAt most one row, found via a primary or unique key with constants; read once, at optimisation timeWHERE email = 'user4242@example.com' with a UNIQUE index
eq_refOne row per row of the previous table, through a primary or unique keyJOIN big_customers c ON c.id = o.customer_id
refSeveral rows through a non-unique index (or a unique-key prefix)WHERE customer_id = 4242
rangeAn index range: BETWEEN, <, >, IN, LIKE 'abc%'WHERE ordered_at >= '2025-11-01'
indexThe whole index read in order (cheaper than the table only if it covers the query)WHERE email LIKE '%4242@example.com'
ALLFull table scana filter on a column with no usable index

The other columns: possible_keys (indexes the optimizer considered), key (the one it chose), key_len (bytes of the index used — see composite indexes), ref (what the key is compared with), rows (estimated rows read), filtered (estimated percentage kept by the remaining conditions) and Extra.

SQL
EXPLAIN FORMAT=TRADITIONAL
SELECT c.city, COUNT(*) AS orders, SUM(o.total) AS revenue
FROM big_orders AS o
JOIN big_customers AS c ON c.id = o.customer_id
WHERE o.ordered_at >= '2025-11-01'
GROUP BY c.city
ORDER BY revenue DESC;
Result
+----+-------------+-------+------------+--------+----------------------+---------+---------+--------------------+--------+----------+----------------------------------------------+
| id | select_type | table | partitions | type   | possible_keys        | key     | key_len | ref                | rows   | filtered | Extra                                        |
+----+-------------+-------+------------+--------+----------------------+---------+---------+--------------------+--------+----------+----------------------------------------------+
|  1 | SIMPLE      | o     | NULL       | ALL    | idx_cust_status_date | NULL    | NULL    | NULL               | 499125 |    33.33 | Using where; Using temporary; Using filesort |
|  1 | SIMPLE      | c     | NULL       | eq_ref | PRIMARY              | PRIMARY | 4       | ch09.o.customer_id |      1 |   100.00 | NULL                                         |
+----+-------------+-------+------------+--------+----------------------+---------+---------+--------------------+--------+----------+----------------------------------------------+
What’s happening
  1. Two rows, both id 1, SIMPLE (no subqueries or unions): a two-table join, executed top to bottom as a nested loop — read o, and for each row look up c.
  2. o (orders): type: ALL, rows: 499125 — a full scan, because there is no index on ordered_at at this point. possible_keys lists idx_cust_status_date only because it contains customer_id, the join column; it's no use for driving the join.
  3. filtered: 33.33 is the optimizer's default guess for a range condition with no index or histogram: it expects a third of the rows to pass ordered_at >= '2025-11-01'. (In reality 3,201 do — under 1 %.)
  4. Extra: Using where = the condition is checked on each row. Using temporary = GROUP BY c.city is done in an internal temporary table. Using filesort = ORDER BY revenue needs a sort; "filesort" is the name of MySQL's sort, which only spills to disk if it's large.
  5. c (customers): type: eq_ref on PRIMARY, key_len: 4, ref: ch09.o.customer_id — for each order, exactly one customer is fetched by primary key. That part of the plan is ideal.
  6. Diagnosis: the problem is the first row. Fix the access to o, and the temporary table and sort will only handle a few thousand rows instead of 166,000. The next example does exactly that.
AdvancedThe same plan in TREE format, and after adding an index

Plain EXPLAIN on MySQL 9.7 prints the tree; read it inside-out: the most indented line runs first and feeds its parent.

SQL
EXPLAIN
SELECT c.city, COUNT(*) AS orders, SUM(o.total) AS revenue
FROM big_orders AS o
JOIN big_customers AS c ON c.id = o.customer_id
WHERE o.ordered_at >= '2025-11-01'
GROUP BY c.city
ORDER BY revenue DESC\G

CREATE INDEX idx_ordered_at ON big_orders (ordered_at);

EXPLAIN FORMAT=TRADITIONAL
SELECT c.city, COUNT(*) AS orders, SUM(o.total) AS revenue
FROM big_orders AS o
JOIN big_customers AS c ON c.id = o.customer_id
WHERE o.ordered_at >= '2025-11-01'
GROUP BY c.city
ORDER BY revenue DESC;
Result
*************************** 1. row ***************************
EXPLAIN: -> Sort: revenue DESC
    -> Table scan on <temporary>
        -> Aggregate using temporary table
            -> Nested loop inner join  (cost=108483 rows=166358)
                -> Filter: (o.ordered_at >= TIMESTAMP'2025-11-01 00:00:00')  (cost=50257 rows=166358)
                    -> Table scan on o  (cost=50257 rows=499125)
                -> Single-row index lookup on c using PRIMARY (id = o.customer_id)  (cost=0.25 rows=1)

Query OK, 0 rows affected (0.407 sec)
Records: 0  Duplicates: 0  Warnings: 0

+----+-------------+-------+------------+--------+-------------------------------------+----------------+---------+--------------------+------+----------+--------------------------------------------------------+
| id | select_type | table | partitions | type   | possible_keys                       | key            | key_len | ref                | rows | filtered | Extra                                                  |
+----+-------------+-------+------------+--------+-------------------------------------+----------------+---------+--------------------+------+----------+--------------------------------------------------------+
|  1 | SIMPLE      | o     | NULL       | range  | idx_cust_status_date,idx_ordered_at | idx_ordered_at | 5       | NULL               | 3201 |   100.00 | Using index condition; Using temporary; Using filesort |
|  1 | SIMPLE      | c     | NULL       | eq_ref | PRIMARY                             | PRIMARY        | 4       | ch09.o.customer_id |    1 |   100.00 | NULL                                                   |
+----+-------------+-------+------------+--------+-------------------------------------+----------------+---------+--------------------+------+----------+--------------------------------------------------------+
What’s happening
  1. The tree reads bottom-up: Table scan on o → Filter on ordered_at → for each surviving row a Single-row index lookup on c using PRIMARY (that's eq_ref) → Nested loop inner join → Aggregate using temporary table (Using temporary) → Table scan on <temporary> → Sort (Using filesort).
  2. Every traditional Extra note has a visible node in the tree, which is why the tree is easier to read once you're used to it — and why MySQL made it the default.
  3. After CREATE INDEX idx_ordered_at, the first row becomes type: range on idx_ordered_at with an estimate of 3,201 rows (index dives are exact here). The join, the grouping and the sort are unchanged but now process 3,201 rows instead of 166,358.
  4. Using index condition replaces Using where: the ordered_at condition is checked inside the index (index condition pushdown) before the row is fetched.
  5. The next topic runs both versions with EXPLAIN ANALYZE: 59.6 ms before the index, 5.1 ms after.
Advancedconst, ref and the rewritten query in SHOW WARNINGS

After a traditional EXPLAIN, SHOW WARNINGS shows the query as the optimizer rewrote it — handy for seeing subqueries turned into joins:

SQL
CREATE UNIQUE INDEX uq_email ON big_customers (email);
EXPLAIN FORMAT=TRADITIONAL
SELECT c.name, o.id, o.status, o.total
FROM big_customers AS c
JOIN big_orders AS o ON o.customer_id = c.id
WHERE c.email = 'user4242@example.com' AND o.total > 100
ORDER BY o.ordered_at;

EXPLAIN FORMAT=TRADITIONAL SELECT * FROM big_orders WHERE id IN (SELECT id FROM big_orders WHERE customer_id = 7);
SHOW WARNINGS\G
Result
+----+-------------+-------+------------+-------+----------------------+----------------------+---------+-------+------+----------+----------------+
| id | select_type | table | partitions | type  | possible_keys        | key                  | key_len | ref   | rows | filtered | Extra          |
+----+-------------+-------+------------+-------+----------------------+----------------------+---------+-------+------+----------+----------------+
|  1 | SIMPLE      | c     | NULL       | const | PRIMARY,uq_email     | uq_email             | 402     | const |    1 |   100.00 | Using filesort |
|  1 | SIMPLE      | o     | NULL       | ref   | idx_cust_status_date | idx_cust_status_date | 4       | const |   10 |    33.33 | Using where    |
+----+-------------+-------+------------+-------+----------------------+----------------------+---------+-------+------+----------+----------------+
+----+-------------+------------+------------+--------+------------------------------+----------------------+---------+--------------------+------+----------+-------------+
| id | select_type | table      | partitions | type   | possible_keys                | key                  | key_len | ref                | rows | filtered | Extra       |
+----+-------------+------------+------------+--------+------------------------------+----------------------+---------+--------------------+------+----------+-------------+
|  1 | SIMPLE      | big_orders | NULL       | ref    | PRIMARY,idx_cust_status_date | idx_cust_status_date | 4       | const              |    9 |   100.00 | Using index |
|  1 | SIMPLE      | big_orders | NULL       | eq_ref | PRIMARY                      | PRIMARY              | 4       | ch09.big_orders.id |    1 |   100.00 | NULL        |
+----+-------------+------------+------------+--------+------------------------------+----------------------+---------+--------------------+------+----------+-------------+
*************************** 1. row ***************************
  Level: Note
   Code: 1003
Message: /* select#1 */ select `ch09`.`big_orders`.`id` AS `id`,`ch09`.`big_orders`.`customer_id` AS `customer_id`,`ch09`.`big_orders`.`status` AS `status`,`ch09`.`big_orders`.`country` AS `country`,`ch09`.`big_orders`.`ordered_at` AS `ordered_at`,`ch09`.`big_orders`.`total` AS `total` from `ch09`.`big_orders` join `ch09`.`big_orders` where ((`ch09`.`big_orders`.`id` = `ch09`.`big_orders`.`id`) and (`ch09`.`big_orders`.`customer_id` = 7))
What’s happening
  1. c is const: a UNIQUE index compared with a constant matches at most one row, so MySQL reads it during optimisation and treats its columns as constants. key_len: 402 = VARCHAR(100) in utf8mb4 (100 × 4 bytes) + 2 length bytes.
  2. Because c.id is now a constant, the join condition o.customer_id = c.id becomes o.customer_id = const, so o is a ref lookup — ref: const rather than a column name.
  3. Using filesort sits on the first row because the sort is planned after the const table; MySQL sorts the 10 orders by ordered_at (the index order is by status first).
  4. The IN (subquery) became a plain join in the SHOW WARNINGS note (code 1003): since id is the primary key, each outer row matches at most once, so MySQL can turn the semi-join into an inner join and drive it from the subquery's index.
AdvancedThe JSON format, version 2

FORMAT=JSON exposes the costs and every detail as data — useful for tooling, verbose for people:

SQL
EXPLAIN FORMAT=JSON SELECT id, total FROM big_orders WHERE ordered_at >= '2025-11-07'\G
Result
*************************** 1. row ***************************
EXPLAIN: {
  "query": "/* select#1 */ select `ch09`.`big_orders`.`id` AS `id`,`ch09`.`big_orders`.`total` AS `total` from `ch09`.`big_orders` where (`ch09`.`big_orders`.`ordered_at` >= TIMESTAMP'2025-11-07 00:00:00')",
  "query_plan": {
    "ranges": [
      "('2025-11-07 00:00:00' <= ordered_at)"
    ],
    "covering": false,
    "operation": "Index range scan on big_orders using idx_ordered_at over ('2025-11-07 00:00:00' <= ordered_at), with index condition: (big_orders.ordered_at >= TIMESTAMP'2025-11-07 00:00:00')",
    "index_name": "idx_ordered_at",
    "table_name": "big_orders",
    "access_type": "index",
    "key_columns": [
      "ordered_at"
    ],
    "schema_name": "ch09",
    "used_columns": [
      "id",
      "ordered_at",
      "total"
    ],
    "estimated_rows": 321.0,
    "index_access_type": "index_range_scan",
    "estimated_total_cost": 144.71,
    "pushed_index_condition": "(big_orders.ordered_at >= TIMESTAMP'2025-11-07 00:00:00')"
  },
  "query_type": "select",
  "json_schema_version": "2.0"
}
What’s happening
  1. json_schema_version: "2.0" — the access-path-based layout that became the default in 9.5. On 8.0/8.4 you get the older version 1 layout (query_block, table, cost_info …), which many tools still parse.
  2. covering: false and used_columns (total isn't in the index) tell you a clustered-index lookup happens per row; pushed_index_condition is the ICP filter.
  3. The rewritten query is included as "query", the same text SHOW WARNINGS gives.
AdvancedPitfall: the default format changed in 9.5
Read this plan
Output
+----+-------------+-------+------+---------------+------+---------+------+-------+----------+-----------------------------+
| id | select_type | table | type | possible_keys | key  | key_len | ref  | rows  | filtered | Extra                       |
+----+-------------+-------+------+---------------+------+---------+------+-------+----------+-----------------------------+
|  1 | SIMPLE      | o     | ALL  | NULL          | NULL | NULL    | NULL | 80000 |    10.00 | Using where; Using filesort |
+----+-------------+-------+------+---------------+------+---------+------+-------+----------+-----------------------------+

What is MySQL doing, and what's the first thing you'd check?

Show answer

It's a full table scan of o (80,000 rows), keeping an estimated 10 % with the WHERE condition, then sorting what's left for the ORDER BY. possible_keys: NULL means no index is even usable for the conditions — so first check the WHERE columns: is there an index whose leftmost column is filtered, and is the column used bare (not wrapped in a function or compared with a different type)?

What’s happening
  1. type: ALL + key: NULL → no index used; every row is read.
  2. possible_keys: NULL → not a cost decision: no index matched at all. (If indexes were listed but key was NULL, the optimizer would have rejected them as too expensive — the selectivity case.)
  3. filtered: 10.00 → a fixed guess for an equality with no statistics: the optimizer doesn't know the distribution.
  4. Using filesort → the rows are sorted after filtering. An index that serves both the filter and the order (equality columns, then the sort column) would remove both the scan and the sort.

#EXPLAIN ANALYZE

Added

EXPLAIN shows estimates. EXPLAIN ANALYZE (MySQL 8.0.18+) runs the query and adds what actually happened to every node of the tree: real time, real row counts and how many times the node ran. It's the difference between a weather forecast and looking out of the window: when a query is slow, the most useful thing to find is the node where the estimate and the reality disagree, because that's where the optimizer made its bad decision.

Every node gets (actual time=FIRST..LAST rows=N loops=L):

  • FIRST — milliseconds until the node produced its first row; LAST — until it produced its last. A sort or an aggregate can't produce anything until it has read everything, so for them FIRST ≈ LAST.
  • rows — rows produced per loop; loops — how often the node ran (the inner side of a nested loop runs once per outer row). Times are also per loop, so the node's total time is roughly LAST × loops.
SQL
EXPLAIN ANALYZE
SELECT c.city, COUNT(*) AS orders, SUM(o.total) AS revenue
FROM big_orders AS o
JOIN big_customers AS c ON c.id = o.customer_id
WHERE o.ordered_at >= '2025-11-01'
GROUP BY c.city
ORDER BY revenue DESC\G
Result
*************************** 1. row ***************************
EXPLAIN: -> Sort: revenue DESC  (actual time=5.08..5.08 rows=7 loops=1)
    -> Table scan on <temporary>  (actual time=5.04..5.04 rows=7 loops=1)
        -> Aggregate using temporary table  (actual time=5.04..5.04 rows=7 loops=1)
            -> Nested loop inner join  (cost=2561 rows=3201) (actual time=0.987..4.47 rows=3201 loops=1)
                -> Index range scan on o using idx_ordered_at over ('2025-11-01 00:00:00' <= ordered_at), with index condition: (o.ordered_at >= TIMESTAMP'2025-11-01 00:00:00')  (cost=1441 rows=3201) (actual time=0.149..1.34 rows=3201 loops=1)
                -> Single-row index lookup on c using PRIMARY (id = o.customer_id)  (cost=0.25 rows=1) (actual time=637e-6..652e-6 rows=1 loops=3201)

1 row in set (0.005 sec)
What’s happening
  1. Start at the most indented node: the index range scan on o produced 3,201 rows in 1.34 ms, and the estimate (rows=3201) was exact.
  2. Its sibling, the primary key lookup on c, shows loops=3201: it ran once per order. Each run took 0.000652 ms (652e-6 is scientific notation — MySQL switches to it for tiny numbers), 1 row each. Total ≈ 3,201 × 0.00065 ≈ 2 ms.
  3. The nested loop join finished at 4.47 ms with 3,201 joined rows. The aggregate grouped them into 7 cities by 5.04 ms; its first-row time equals its last-row time because it had to see every input row before emitting one.
  4. The temporary-table scan and the sort add almost nothing: 7 rows. The whole query took 5.08 ms, matching the client's 0.005 sec.
  5. Before idx_ordered_at existed, the same query took 59.6 ms, and the plan showed why (next example): 46.8 ms of it was the table scan.
AdvancedSpotting a bad estimate

The same query with the index hidden from the optimizer, so the old plan runs:

SQL
EXPLAIN ANALYZE
SELECT c.city, COUNT(*) AS orders, SUM(o.total) AS revenue
FROM big_orders AS o IGNORE INDEX (idx_ordered_at)
JOIN big_customers AS c ON c.id = o.customer_id
WHERE o.ordered_at >= '2025-11-01'
GROUP BY c.city
ORDER BY revenue DESC\G
Result
*************************** 1. row ***************************
EXPLAIN: -> Sort: revenue DESC  (actual time=59.6..59.6 rows=7 loops=1)
    -> Table scan on <temporary>  (actual time=59.6..59.6 rows=7 loops=1)
        -> Aggregate using temporary table  (actual time=59.6..59.6 rows=7 loops=1)
            -> Nested loop inner join  (cost=108483 rows=166358) (actual time=56.7..59 rows=3201 loops=1)
                -> Filter: (o.ordered_at >= TIMESTAMP'2025-11-01 00:00:00')  (cost=50257 rows=166358) (actual time=56.7..56.9 rows=3201 loops=1)
                    -> Table scan on o  (cost=50257 rows=499125) (actual time=0.223..46.8 rows=500000 loops=1)
                -> Single-row index lookup on c using PRIMARY (id = o.customer_id)  (cost=0.25 rows=1) (actual time=564e-6..579e-6 rows=1 loops=3201)

1 row in set (0.060 sec)
What’s happening
  1. The Filter node: estimated rows=166358, actual rows=3201 — off by a factor of 50. That's the default one-third guess for a range on an unindexed column.
  2. Its first row only appeared at 56.7 ms: the newest orders are at the end of the table, so the scan had read almost all 500,000 rows (46.8 ms) before the first one passed.
  3. Here the bad estimate didn't change the plan (there was no index to choose), but in a multi-table join an estimate that's 50 times too high or too low is exactly what makes the optimizer pick the wrong join order. A histogram on ordered_at or an index on it fixes the estimate.
  4. Tip: compare rows in the estimate part (cost=… rows=…) with rows in the actual part, node by node. Multiply by loops for inner nodes.
AdvancedPitfall: EXPLAIN ANALYZE executes statements
AdvancedOutput formats EXPLAIN ANALYZE accepts

EXPLAIN ANALYZE prints the tree by default; FORMAT=JSON (with explain_json_format_version = 2, the 9.5+ default) adds actual_rows, actual_loops, actual_first_row_ms and actual_last_row_ms to each node. The traditional table can't show per-node timings, so it's refused:

Output
mysql> EXPLAIN ANALYZE FORMAT=TRADITIONAL SELECT id FROM big_orders WHERE id = 1;
ERROR 1235 (42000): This version of MySQL doesn't yet support 'EXPLAIN ANALYZE with TRADITIONAL format'
AdvancedPlans of queries running in other sessions

EXPLAIN ANALYZE also can't be used with FOR CONNECTION; to see the plan of a query that's running in another session right now, use plain EXPLAIN FOR CONNECTION <id> (the id from SHOW PROCESSLIST).

#Why an index isn't used

Added

An index is a sorted list of the raw column values. MySQL can only use it when it can turn your condition into "start here in the sorted order, stop there". Anything that hides the raw value, or asks a question the sort order can't answer, forces a scan. Almost every case is one of these:

  • A function or arithmetic on the column: DATE(ordered_at) = …, YEAR(…), LOWER(email) = …, price * 1.13 > …. The index is sorted by ordered_at, not by DATE(ordered_at).
  • An implicit type conversion: comparing a VARCHAR column with a number.
  • A leading wildcard: LIKE '%…'.
  • OR across columns where not every branch has an index.
  • Not the leftmost column of a composite index (see the leftmost-prefix rule).
  • Too many matching rows, so a scan is cheaper (see Selectivity and cardinality).
  • Different character sets or collations on the two sides of a join.

The most common one in real code is the date function:

SQL
EXPLAIN ANALYZE SELECT COUNT(*) FROM big_orders WHERE DATE(ordered_at) = '2025-06-01'\G
EXPLAIN ANALYZE SELECT COUNT(*) FROM big_orders WHERE ordered_at >= '2025-06-01' AND ordered_at < '2025-06-02'\G
Result
*************************** 1. row ***************************
EXPLAIN: -> Aggregate: count(0)  (cost=165263 rows=1) (actual time=42.4..42.4 rows=1 loops=1)
    -> Filter: (cast(big_orders.ordered_at as date) = '2025-06-01')  (cost=50257 rows=499125) (actual time=36.7..42.4 rows=480 loops=1)
        -> Covering index scan on big_orders using idx_ordered_at  (cost=50257 rows=499125) (actual time=0.179..26.6 rows=500000 loops=1)

1 row in set (0.043 sec)

*************************** 1. row ***************************
EXPLAIN: -> Aggregate: count(0)  (cost=208 rows=1) (actual time=0.0591..0.0592 rows=1 loops=1)
    -> Filter: ((big_orders.ordered_at >= TIMESTAMP'2025-06-01 00:00:00') and (big_orders.ordered_at < TIMESTAMP'2025-06-02 00:00:00'))  (cost=97.5 rows=480) (actual time=0.0123..0.0515 rows=480 loops=1)
        -> Covering index range scan on big_orders using idx_ordered_at over ('2025-06-01 00:00:00' <= ordered_at < '2025-06-02 00:00:00')  (cost=97.5 rows=480) (actual time=0.0123..0.0311 rows=480 loops=1)

1 row in set (0.000 sec)
What’s happening
  1. DATE(ordered_at) = '2025-06-01': MySQL rewrote it as cast(ordered_at as date) and has no index on that expression. It still uses idx_ordered_at — but only as a smaller copy of the column to scan end to end (Covering index scan, 500,000 entries) and apply the function to each value. 42 ms.
  2. The rewritten condition ordered_at >= '2025-06-01' AND ordered_at < '2025-06-02' says the same thing in terms of the raw column. Now MySQL can seek to the first entry of 1 June and stop at the first one of 2 June: a range scan of exactly 480 entries, 0.06 ms — 700 times faster.
  3. This rewrite is called making the condition sargable (Search ARGument ABLE): keep the column bare on one side and put all the computation on the constant side.
  4. Note the half-open interval >= day AND < next day. BETWEEN '2025-06-01' AND '2025-06-01 23:59:59' misses fractional seconds and is easy to get wrong; the half-open form is always correct.
  5. When you can't change the query (an ORM, a third-party report), index the expression instead: see functional indexes.
AdvancedImplicit type conversion

big_customers.phone is a VARCHAR(15) with an index. Compare it with a string, then with a number:

SQL
EXPLAIN ANALYZE SELECT id, name FROM big_customers WHERE phone = '4160004242'\G
EXPLAIN ANALYZE SELECT id, name FROM big_customers WHERE phone = 4160004242\G
SHOW WARNINGS;
EXPLAIN FORMAT=TREE SELECT id, status FROM big_orders WHERE customer_id = '4242'\G
Result
*************************** 1. row ***************************
EXPLAIN: -> Index lookup on big_customers using idx_phone (phone = '4160004242')  (cost=0.35 rows=1) (actual time=0.003..0.00321 rows=1 loops=1)

*************************** 1. row ***************************
EXPLAIN: -> Filter: (big_customers.phone = 4160004242)  (cost=5086 rows=4950) (actual time=0.525..5.47 rows=1 loops=1)
    -> Table scan on big_customers  (cost=5086 rows=49500) (actual time=0.0292..4.34 rows=50000 loops=1)

1 row in set, 2 warnings (0.006 sec)

+---------+------+---------------------------------------------------------------------------------------------------+
| Level   | Code | Message                                                                                           |
+---------+------+---------------------------------------------------------------------------------------------------+
| Warning | 1739 | Cannot use ref access on index 'idx_phone' due to type or collation conversion on field 'phone'   |
| Warning | 1739 | Cannot use range access on index 'idx_phone' due to type or collation conversion on field 'phone' |
+---------+------+---------------------------------------------------------------------------------------------------+

*************************** 1. row ***************************
EXPLAIN: -> Covering index lookup on big_orders using idx_cust_status_date (customer_id = '4242')  (cost=2.02 rows=10)
What’s happening
  1. String compared with string: an index lookup, 1 row, 0.003 ms.
  2. String column compared with a number: MySQL's rule is to convert the string column to a number for every row, because many strings convert to the same number ('4160004242', ' 4160004242', '4160004242abc'…). The index is sorted by string, so it's useless: a full scan of 50,000 rows.
  3. MySQL tells you, if you ask: warning 1739, "Cannot use ref access on index 'idx_phone' due to type or collation conversion on field 'phone'". Check SHOW WARNINGS whenever a plan surprises you.
  4. The other direction is harmless: an INT column compared with the string '4242' converts the constant once, and the index is used. The danger is a string column with a numeric value — classic with phone numbers, postcodes and ids stored as VARCHAR, and with drivers that send numbers unquoted.
AdvancedLeading wildcards and OR conditions

A prefix pattern against a leading wildcard, and OR with and without an index on each branch:

SQL
EXPLAIN FORMAT=TRADITIONAL SELECT id FROM big_customers WHERE email LIKE 'user4242@%';
EXPLAIN FORMAT=TRADITIONAL SELECT id FROM big_customers WHERE email LIKE '%4242@example.com';
EXPLAIN FORMAT=TREE SELECT id, total FROM big_orders WHERE customer_id = 4242 OR total = 99.99\G
EXPLAIN FORMAT=TREE SELECT id, total FROM big_orders WHERE customer_id = 4242 OR ordered_at = '2025-06-01 10:00:00'\G
Result
+----+-------------+---------------+------------+-------+---------------+----------+---------+------+------+----------+--------------------------+
| id | select_type | table         | partitions | type  | possible_keys | key      | key_len | ref  | rows | filtered | Extra                    |
+----+-------------+---------------+------------+-------+---------------+----------+---------+------+------+----------+--------------------------+
|  1 | SIMPLE      | big_customers | NULL       | range | uq_email      | uq_email | 402     | NULL |    1 |   100.00 | Using where; Using index |
+----+-------------+---------------+------------+-------+---------------+----------+---------+------+------+----------+--------------------------+
+----+-------------+---------------+------------+-------+---------------+----------+---------+------+-------+----------+--------------------------+
| id | select_type | table         | partitions | type  | possible_keys | key      | key_len | ref  | rows  | filtered | Extra                    |
+----+-------------+---------------+------------+-------+---------------+----------+---------+------+-------+----------+--------------------------+
|  1 | SIMPLE      | big_customers | NULL       | index | NULL          | uq_email | 402     | NULL | 49500 |    11.11 | Using where; Using index |
+----+-------------+---------------+------------+-------+---------------+----------+---------+------+-------+----------+--------------------------+
*************************** 1. row ***************************
EXPLAIN: -> Filter: ((big_orders.customer_id = 4242) or (big_orders.total = 99.99))  (cost=50257 rows=49922)
    -> Table scan on big_orders  (cost=50257 rows=499125)

*************************** 1. row ***************************
EXPLAIN: -> Filter: ((big_orders.customer_id = 4242) or (big_orders.ordered_at = TIMESTAMP'2025-06-01 10:00:00'))  (cost=9.86 rows=11)
    -> Sort-deduplicate by row ID  (cost=9.86 rows=11)
        -> Index range scan on big_orders using idx_cust_status_date over (customer_id = 4242)  (cost=2.03 rows=10)
        -> Index range scan on big_orders using idx_ordered_at over (ordered_at = '2025-06-01 10:00:00')  (cost=1.11 rows=1)
What’s happening
  1. LIKE 'user4242@%' has a fixed prefix, so it's a range from 'user4242@' up to the end of that prefix: 1 row.
  2. LIKE '%4242@example.com' could match anywhere in the sort order: type: index, the whole email index (49,500 entries) is read and every value tested. It's the index rather than the table only because the index happens to cover id. For real "contains" searches, use Full-text search.
  3. customer_id = 4242 OR total = 99.99: rows matching either condition are needed, and there's no index on total, so no index can find them all — full table scan, even though one branch is perfectly indexed.
  4. When both branches have an index, MySQL can use an index merge: range-scan each index, then "Sort-deduplicate by row ID" to union the results without returning a row twice (11 rows). The traditional format shows this as type: index_merge with Using sort_union(…). A UNION of two indexed queries is the manual version of the same idea.
AdvancedCharacter set mismatch in a join

legacy_signups.email is latin1, big_customers.email is utf8mb4, both indexed. MySQL converts latin1 to utf8mb4 (the superset), never the other way, so it matters which side is looked up:

SQL
CREATE TABLE legacy_signups (id INT PRIMARY KEY, email VARCHAR(100) CHARACTER SET latin1 NOT NULL, INDEX (email));
INSERT INTO legacy_signups VALUES (1, 'user4242@example.com'), (2, 'user7@example.com'), (3, 'nobody@example.com');

EXPLAIN FORMAT=TREE SELECT c.id, c.name FROM legacy_signups l JOIN big_customers c ON c.email = l.email\G
EXPLAIN FORMAT=TREE SELECT l.id FROM big_customers c STRAIGHT_JOIN legacy_signups l ON l.email = c.email WHERE c.id < 100\G
SHOW WARNINGS;
Result
*************************** 1. row ***************************
EXPLAIN: -> Nested loop inner join  (cost=1.6 rows=3)
    -> Covering index scan on l using email  (cost=0.55 rows=3)
    -> Single-row index lookup on c using uq_email (email = l.email), with index condition: (c.email = l.email)  (cost=0.283 rows=1)

*************************** 1. row ***************************
EXPLAIN: -> Filter: (l.email = c.email)  (cost=50.1 rows=99)
    -> Inner hash join (<hash>(l.email)=<hash>(c.email))  (cost=50.1 rows=99)
        -> Covering index scan on l using email  (cost=0.00392 rows=3)
        -> Hash
            -> Filter: (c.id < 100)  (cost=20.1 rows=99)
                -> Index range scan on c using PRIMARY over (id < 100)  (cost=20.1 rows=99)

+---------+------+-----------------------------------------------------------------------------------------------+
| Level   | Code | Message                                                                                       |
+---------+------+-----------------------------------------------------------------------------------------------+
| Warning | 1739 | Cannot use ref access on index 'email' due to type or collation conversion on field 'email'   |
| Warning | 1739 | Cannot use range access on index 'email' due to type or collation conversion on field 'email' |
+---------+------+-----------------------------------------------------------------------------------------------+
What’s happening
  1. Driving from legacy_signups and looking up big_customers: each latin1 value is converted to utf8mb4 and searched in uq_email — a normal index lookup.
  2. Forcing the other order with STRAIGHT_JOIN (look up into the latin1 table): the utf8mb4 value can't be converted down safely, so the latin1 index can't be searched — warning 1739 again — and MySQL falls back to a hash join over a scan.
  3. With three rows it doesn't matter; with millions it's a full scan per query. This is a real migration hazard: tables created years apart with latin1, utf8 (utf8mb3) and utf8mb4, or with different collations, joined on string keys. Make join columns the same type, character set and collation.
Spot the slow query

Both queries return the orders placed in 2025. One is fast and one is slow on a large table with an index on ordered_at. Which is which, and why?

SQL
SELECT id FROM big_orders WHERE YEAR(ordered_at) = 2025;
SELECT id FROM big_orders WHERE ordered_at >= '2025-01-01' AND ordered_at < '2026-01-01';
Show answer

The first is slow: YEAR(ordered_at) hides the column inside a function, so the index can't be searched — the plan is a scan of the entire index with year(…) evaluated for every entry, as with DATE() above. The second compares the bare column with constants and can be a range scan.

What’s happening
  1. The index is sorted by ordered_at. "Rows where YEAR(ordered_at) is 2025" isn't a position in that order, unless MySQL knows YEAR() is monotonic — it doesn't reason that way.
  2. The second query states the same set as a range of raw values: seek to 2025-01-01, stop before 2026-01-01.
  3. One caveat: 2025 is about a third of this table, so the optimizer may still prefer a full scan for the second query (WHERE ordered_at >= '2024-01-01', two thirds of the rows, got a Table scan here). Sargable makes the index possible; selectivity decides whether it's worth it.

#Functional, prefix and invisible indexes

Added

Three index variants solve specific problems:

  • A functional index (MySQL 8.0.13+) indexes the result of an expression, such as DATE(ordered_at) or LOWER(email). It's the fix when a query must filter on an expression and you can't rewrite it. Internally MySQL adds a hidden virtual generated column and indexes that; on 5.7 you did the same by hand.
  • A prefix index indexes only the first N characters of a string: INDEX (email(10)). It's smaller, and it's the only way to index a TEXT or BLOB column — but it can't cover a query or serve an ORDER BY.
  • An invisible index (8.0+) is maintained on every write but ignored by the optimizer. It's how you test dropping an index safely: make it invisible, watch, and make it visible again in milliseconds if something gets slow.

The mental model: the functional index is a pre-computed column you never see; the prefix index is a phone book that only prints the first few letters of each surname; the invisible index is an index with a "do not use" sticker — still kept up to date, just not consulted.

SQL
CREATE INDEX idx_order_day ON big_orders ((DATE(ordered_at)));

EXPLAIN ANALYZE SELECT COUNT(*) FROM big_orders WHERE DATE(ordered_at) = '2025-06-01'\G

SELECT index_name, column_name, expression FROM information_schema.statistics
WHERE table_schema = 'ch09' AND table_name = 'big_orders' AND index_name = 'idx_order_day';
Result
Query OK, 0 rows affected (0.479 sec)
Records: 0  Duplicates: 0  Warnings: 0

*************************** 1. row ***************************
EXPLAIN: -> Aggregate: count(0)  (cost=279 rows=1) (actual time=0.172..0.172 rows=1 loops=1)
    -> Index lookup on big_orders using idx_order_day (cast(ordered_at as date) = '2025-06-01')  (cost=168 rows=480) (actual time=0.047..0.163 rows=480 loops=1)

1 row in set (0.000 sec)

+---------------+-------------+----------------------------+
| INDEX_NAME    | COLUMN_NAME | EXPRESSION                 |
+---------------+-------------+----------------------------+
| idx_order_day | NULL        | cast(`ordered_at` as date) |
+---------------+-------------+----------------------------+
What’s happening
  1. The key part is an expression in its own parentheses: ((DATE(ordered_at))). One pair belongs to the index column list, the inner pair marks an expression. Without them MySQL would parse it as a column name and fail.
  2. The query that took 42 ms in the previous topic, WHERE DATE(ordered_at) = '2025-06-01', is now an Index lookup on idx_order_day: 480 rows, 0.17 ms.
  3. information_schema.STATISTICS shows COLUMN_NAME = NULL and the stored EXPRESSION: MySQL normalised DATE(ordered_at) to cast(ordered_at as date). The hidden column doesn't appear in information_schema.COLUMNS or SELECT *.
  4. The index is only used when the query uses the same expression. Here CAST(ordered_at AS DATE) = … and DATE(ordered_at) BETWEEN … AND … also used it, but DATE_FORMAT(ordered_at, '%Y-%m-%d') = '2025-06-01' — the same meaning, a different expression — fell back to a full scan.
  5. Prefer the sargable rewrite when you own the query; a functional index is extra write cost for the same result.
AdvancedBefore 8.0.13: a generated column plus an index

MySQL 5.7 has no functional key parts, but it has generated columns, and an index on a generated column does the same job. The optimizer even matches the expression in your WHERE to the generated column automatically:

SQL
ALTER TABLE big_orders
  ADD COLUMN order_day DATE AS (DATE(ordered_at)) VIRTUAL,
  ADD INDEX idx_order_day_col (order_day);

EXPLAIN FORMAT=TREE SELECT COUNT(*) FROM big_orders WHERE order_day = '2025-06-01'\G
EXPLAIN FORMAT=TREE SELECT COUNT(*) FROM big_orders WHERE DATE(ordered_at) = '2025-06-01'\G
Result
Query OK, 0 rows affected (0.690 sec)
Records: 0  Duplicates: 0  Warnings: 0

*************************** 1. row ***************************
EXPLAIN: -> Aggregate: count(0)  (cost=160 rows=1)
    -> Covering index lookup on big_orders using idx_order_day_col (order_day = DATE'2025-06-01')  (cost=49.5 rows=480)

*************************** 1. row ***************************
EXPLAIN: -> Aggregate: count(0)  (cost=279 rows=1)
    -> Index lookup on big_orders using idx_order_day_col (order_day = '2025-06-01')  (cost=168 rows=480)
What’s happening
  1. VIRTUAL means the value isn't stored in the row; it's computed when read. The index stores it, so the index behaves like any other.
  2. Querying the generated column by name gives a covering lookup.
  3. Querying with the original expression DATE(ordered_at) also uses idx_order_day_col: the optimizer saw that the expression matches the column's definition and substituted it.
  4. A visible generated column has one advantage over a functional index: queries and ORMs can name it. The functional index hides the column, which keeps SELECT * and the table definition unchanged. (Both were dropped again after this test, and idx_order_day recreated.)
AdvancedPrefix indexes: choosing the length

Pick the prefix length by checking how many distinct values each length keeps:

SQL
SELECT COUNT(DISTINCT email) AS full_len,
       COUNT(DISTINCT LEFT(email, 4))  AS len_4,
       COUNT(DISTINCT LEFT(email, 7))  AS len_7,
       COUNT(DISTINCT LEFT(email, 9))  AS len_9,
       COUNT(DISTINCT LEFT(email, 10)) AS len_10
FROM big_customers;

CREATE INDEX idx_email_prefix ON big_customers (email(10));
EXPLAIN FORMAT=TREE SELECT id FROM big_customers IGNORE INDEX (uq_email) WHERE email = 'user4242@example.com'\G
EXPLAIN FORMAT=TREE SELECT id FROM big_customers WHERE email = 'user4242@example.com'\G
Result
+----------+-------+-------+-------+--------+
| full_len | len_4 | len_7 | len_9 | len_10 |
+----------+-------+-------+-------+--------+
|    50000 |     1 |   999 | 50000 |  50000 |
+----------+-------+-------+-------+--------+

*************************** 1. row ***************************
EXPLAIN: -> Filter: (big_customers.email = 'user4242@example.com')  (cost=0.35 rows=1)
    -> Index lookup on big_customers using idx_email_prefix (email = 'user4242@example.com')  (cost=0.35 rows=1)

*************************** 1. row ***************************
EXPLAIN: -> Rows fetched before execution  (cost=0..0 rows=1)
What’s happening
  1. These generated emails all start with user, so 4 characters give 1 distinct value and 7 give 999; from 9 characters every email is distinct. Real data needs the same measurement — choose the shortest prefix whose distinct count is close to the full column's.
  2. With email(10), the lookup still works, but notice the extra Filter above it: the index only knows the first 10 characters, so MySQL must fetch each candidate row and compare the full email. A prefix index can never be covering for that column.
  3. For comparison, the full UNIQUE index makes it a const lookup: "Rows fetched before execution".
  4. Prefix indexes can't be used for ORDER BY email or GROUP BY email either (the index isn't fully sorted by the whole value), and a UNIQUE prefix index enforces uniqueness only on the prefix. Use them for long VARCHAR/TEXT columns where a full index would be too big. InnoDB's limit for an index key is 3,072 bytes.
AdvancedTEXT columns need a prefix

A TEXT column can only be indexed with a prefix — INDEX (body(50)) works, INDEX (body) doesn't:

Output
mysql> CREATE TABLE notes (id INT PRIMARY KEY, body TEXT, INDEX (body));
ERROR 1170 (42000): BLOB/TEXT column 'body' used in key specification without a key length
AdvancedInvisible indexes: test before you drop

Invisible indexes are the safe way to test whether an index can be dropped:

SQL
ALTER TABLE big_orders ALTER INDEX idx_ordered_at INVISIBLE;
SELECT index_name, is_visible FROM information_schema.statistics
WHERE table_schema = 'ch09' AND table_name = 'big_orders' AND seq_in_index = 1 ORDER BY index_name;
EXPLAIN FORMAT=TREE SELECT id, total FROM big_orders WHERE ordered_at >= '2025-11-01'\G

SET SESSION optimizer_switch = 'use_invisible_indexes=on';
EXPLAIN FORMAT=TREE SELECT id, total FROM big_orders WHERE ordered_at >= '2025-11-01'\G
SET SESSION optimizer_switch = 'use_invisible_indexes=off';
Result
+----------------------+------------+
| INDEX_NAME           | IS_VISIBLE |
+----------------------+------------+
| idx_cust_status_date | YES        |
| idx_order_day        | YES        |
| idx_ordered_at       | NO         |
| PRIMARY              | YES        |
+----------------------+------------+

*************************** 1. row ***************************
EXPLAIN: -> Filter: (big_orders.ordered_at >= TIMESTAMP'2025-11-01 00:00:00')  (cost=50257 rows=166358)
    -> Table scan on big_orders  (cost=50257 rows=499125)

*************************** 1. row ***************************
EXPLAIN: -> Index range scan on big_orders using idx_ordered_at over ('2025-11-01 00:00:00' <= ordered_at), with index condition: (big_orders.ordered_at >= TIMESTAMP'2025-11-01 00:00:00')  (cost=1441 rows=3201)
What’s happening
  1. ALTER INDEX … INVISIBLE is a metadata change — instant, no rebuild. IS_VISIBLE shows NO.
  2. The optimizer now plans as if the index didn't exist: a table scan. If nothing important slows down over a few days, DROP INDEX is safe; if something does, ALTER INDEX … VISIBLE brings it back instantly, without the cost of rebuilding.
  3. A single session can opt in with optimizer_switch = 'use_invisible_indexes=on' to compare plans — handy for testing a new index too: create it invisible, try it in one session, then make it visible.
  4. The index is still updated on every write the whole time, so it still costs write performance and still enforces uniqueness if it's UNIQUE.
AdvancedErrors with invisible and primary indexes

Two errors worth recognising. You can't force an invisible index, and the primary key can't be hidden:

Output
mysql> SELECT id FROM big_orders FORCE INDEX (idx_ordered_at) WHERE ordered_at >= '2025-11-07' LIMIT 1;
ERROR 1176 (42000): Key 'idx_ordered_at' doesn't exist in table 'big_orders'
mysql> ALTER TABLE big_orders ALTER INDEX `PRIMARY` INVISIBLE;
ERROR 3522 (HY000): A primary key index cannot be invisible
AdvancedDescending indexes for mixed sort orders

MySQL 8.0 stores DESC index columns in descending order (5.7 accepted DESC but ignored it). An ASC index can be read backwards for ORDER BY a DESC, b DESC, but not for a mixed order like a ASC, b DESC:

SQL
EXPLAIN FORMAT=TREE SELECT customer_id, ordered_at FROM big_orders
WHERE customer_id BETWEEN 100 AND 110 ORDER BY customer_id ASC, ordered_at DESC\G
CREATE INDEX idx_cust_date_desc ON big_orders (customer_id ASC, ordered_at DESC);
EXPLAIN FORMAT=TREE SELECT customer_id, ordered_at FROM big_orders
WHERE customer_id BETWEEN 100 AND 110 ORDER BY customer_id ASC, ordered_at DESC\G
Result
*************************** 1. row ***************************
EXPLAIN: -> Sort: big_orders.customer_id, big_orders.ordered_at DESC  (cost=17.7 rows=83)
    -> Filter: (big_orders.customer_id between 100 and 110)  (cost=17.7 rows=83)
        -> Covering index range scan on big_orders using idx_cust_status_date over (100 <= customer_id <= 110)  (cost=17.7 rows=83)

*************************** 1. row ***************************
EXPLAIN: -> Filter: (big_orders.customer_id between 100 and 110)  (cost=17.7 rows=83)
    -> Covering index range scan on big_orders using idx_cust_date_desc over (100 <= customer_id <= 110)  (cost=17.7 rows=83)
What’s happening
  1. Without a matching index the rows are found through idx_cust_status_date and then sorted.
  2. With (customer_id ASC, ordered_at DESC) the entries are already stored in exactly the requested order, and the Sort node disappears.
  3. Typical use: "each customer's orders, newest first" feeds and leaderboards. (The test index was dropped afterwards.)

#Pagination: OFFSET vs keyset

Added

LIMIT 20 OFFSET 400000 doesn't jump to row 400,000. There's no "row number" in an index, so MySQL reads 400,020 rows in order and throws the first 400,000 away. Page 1 is fast, page 20,000 is slow, and the cost grows linearly with the page number.

Keyset pagination (also called seek or cursor pagination) remembers where the last page ended and asks for "the next 20 after that": WHERE id > 400000 ORDER BY id LIMIT 20. That's an index seek to 400,000 and a read of 20 entries — the same cost for page 1 and page 20,000. Mental model: OFFSET is counting pages from the front of the book every time; keyset is using a bookmark.

SQL
EXPLAIN ANALYZE
SELECT id, ordered_at, total FROM big_orders ORDER BY id LIMIT 20 OFFSET 400000\G

EXPLAIN ANALYZE
SELECT id, ordered_at, total FROM big_orders WHERE id > 400000 ORDER BY id LIMIT 20\G
Result
*************************** 1. row ***************************
EXPLAIN: -> Limit/Offset: 20/400000 row(s)  (cost=32336 rows=20) (actual time=31.7..31.7 rows=20 loops=1)
    -> Index scan on big_orders using PRIMARY  (cost=32336 rows=400020) (actual time=0.238..25.5 rows=400020 loops=1)

1 row in set (0.032 sec)

*************************** 1. row ***************************
EXPLAIN: -> Limit: 20 row(s)  (cost=39735 rows=20) (actual time=0.201..0.203 rows=20 loops=1)
    -> Filter: (big_orders.id > 400000)  (cost=39735 rows=198378) (actual time=0.201..0.202 rows=20 loops=1)
        -> Index range scan on big_orders using PRIMARY over (400000 < id)  (cost=39735 rows=198378) (actual time=0.194..0.195 rows=20 loops=1)

1 row in set (0.000 sec)
What’s happening
  1. OFFSET: the primary key is scanned from the start, rows=400020, and Limit/Offset discards the first 400,000. 31.7 ms for 20 rows — and both queries return the same 20 rows (ids 400,001–400,020).
  2. Keyset: a range scan starting just after id = 400000. It reads 20 rows and stops (actual … rows=20). 0.2 ms, about 150 times faster, and it would be the same on page 1.
  3. Ignore the optimizer's rows=198378 on the range: that's how many rows could match id > 400000; the LIMIT stops the scan after 20.
  4. The client sends the last id of the page it showed (the cursor) with the "next page" request. That's why APIs that paginate this way return a next_cursor instead of a page number.
AdvancedKeyset on a non-unique sort column

Real feeds sort by something like ordered_at, which isn't unique. Add the primary key as a tie-breaker so the order is total, and continue after the last (ordered_at, id) pair. idx_ordered_at is (ordered_at) — but secondary index entries contain the primary key, so it's effectively (ordered_at, id). The way you write the condition matters a lot in MySQL:

SQL
EXPLAIN ANALYZE
SELECT id, ordered_at, total FROM big_orders
WHERE (ordered_at, id) < ('2024-02-11 06:00:00', 200000)
ORDER BY ordered_at DESC, id DESC LIMIT 20\G

EXPLAIN ANALYZE
SELECT id, ordered_at, total FROM big_orders
WHERE ordered_at < '2024-02-11 06:00:00' OR (ordered_at = '2024-02-11 06:00:00' AND id < 200000)
ORDER BY ordered_at DESC, id DESC LIMIT 20\G
Result
*************************** 1. row ***************************
EXPLAIN: -> Limit: 20 row(s)  (cost=0.0139 rows=20) (actual time=132..132 rows=20 loops=1)
    -> Filter: ((big_orders.ordered_at,big_orders.id) < ('2024-02-11 06:00:00',200000))  (cost=0.0139 rows=20) (actual time=132..132 rows=20 loops=1)
        -> Index scan on big_orders using idx_ordered_at (reverse)  (cost=0.0139 rows=20) (actual time=0.0379..125 rows=305021 loops=1)

1 row in set (0.133 sec)

*************************** 1. row ***************************
EXPLAIN: -> Limit: 20 row(s)  (cost=50257 rows=20) (actual time=1.77..1.77 rows=20 loops=1)
    -> Index range scan on big_orders using idx_ordered_at over (ordered_at < '2024-02-11 06:00:00') OR (ordered_at = '2024-02-11 06:00:00' AND id < 200000) (reverse), with index condition: ((big_orders.ordered_at < TIMESTAMP'2024-02-11 06:00:00') or ((big_orders.ordered_at = TIMESTAMP'2024-02-11 06:00:00') and (big_orders.id < 200000)))  (cost=50257 rows=249563) (actual time=1.77..1.77 rows=20 loops=1)

1 row in set (0.002 sec)
What’s happening
  1. The row-constructor form (ordered_at, id) < (…, …) is the neatest way to write "after this cursor", and it's correct. But on MySQL 9.7.2 it isn't turned into a range: MySQL reads the index backwards from the very end and filters, touching 305,021 entries before it finds 20 that qualify. 132 ms — worse than an OFFSET.
  2. The expanded form — earlier timestamp, or the same timestamp with a smaller id — is understood by the range optimizer: a range scan that starts at the cursor and reads 20 entries. 1.77 ms.
  3. Lesson: in MySQL, write the keyset condition in the expanded OR form and check the plan. Always include the tie-breaker; with ordered_at < cursor alone, rows that share the cursor's timestamp are skipped or repeated at page boundaries.
  4. Run EXPLAIN ANALYZE on your keyset query once: the difference between the two forms only shows in the plan, never in the results.
AdvancedRow comparisons in PostgreSQL

PostgreSQL does turn row comparisons into index conditions. The same data and query on PostgreSQL 18, with an index on (ordered_at, id):

SQL
CREATE INDEX idx_ordered_at ON big_orders (ordered_at, id);
VACUUM ANALYZE big_orders;
EXPLAIN (ANALYZE, COSTS OFF)
SELECT id, ordered_at, total FROM big_orders
WHERE (ordered_at, id) < ('2024-02-11 06:00:00', 200000)
ORDER BY ordered_at DESC, id DESC LIMIT 20;
Result
                                                 QUERY PLAN
-------------------------------------------------------------------------------------------------------------
 Limit (actual time=0.016..0.018 rows=20.00 loops=1)
   Buffers: shared hit=1 read=3
   ->  Index Scan Backward using idx_ordered_at on big_orders (actual time=0.015..0.017 rows=20.00 loops=1)
         Index Cond: (ROW(ordered_at, id) < ROW('2024-02-11 06:00:00'::timestamp without time zone, 200000))
         Index Searches: 1
         Buffers: shared hit=1 read=3
 Planning:
   Buffers: shared hit=39
 Planning Time: 0.070 ms
 Execution Time: 0.043 ms
(10 rows)
What’s happening
  1. Index Cond: (ROW(ordered_at, id) < ROW(…)): PostgreSQL uses the row comparison itself to position the scan, then reads backwards.
  2. Four buffers touched and 0.04 ms — the keyset cost you'd expect, with the neat syntax.
  3. PostgreSQL needs the explicit two-column index; in InnoDB the id part comes free with every secondary index.
AdvancedWhen you must have page numbers: the deferred join

Keyset can't jump to "page 7,000" — only to the next or previous page. If the UI really needs page numbers, a deferred join makes OFFSET cheaper: do the offset on a narrow covering index, then fetch full rows for just the 20 ids.

SQL
EXPLAIN ANALYZE
SELECT id, ordered_at, total FROM big_orders ORDER BY ordered_at DESC, id DESC LIMIT 20 OFFSET 300000\G

EXPLAIN ANALYZE
SELECT o.id, o.ordered_at, o.total
FROM big_orders o
JOIN (SELECT id FROM big_orders ORDER BY ordered_at DESC, id DESC LIMIT 20 OFFSET 300000) AS page USING (id)
ORDER BY o.ordered_at DESC, o.id DESC\G
Result
*************************** 1. row ***************************
EXPLAIN: -> Limit/Offset: 20/300000 row(s)  (cost=50257 rows=20) (actual time=82.6..82.6 rows=20 loops=1)
    -> Sort: big_orders.ordered_at DESC, big_orders.id DESC, limit input to 300020 row(s) per chunk  (cost=50257 rows=499125) (actual time=72..78.1 rows=300020 loops=1)
        -> Table scan on big_orders  (cost=50257 rows=499125) (actual time=0.193..35.8 rows=500000 loops=1)

1 row in set (0.083 sec)

*************************** 1. row ***************************
EXPLAIN: -> Sort: o.ordered_at DESC, o.id DESC  (actual time=32.6..32.6 rows=20 loops=1)
    -> Stream results  (cost=93256 rows=20) (actual time=32.5..32.6 rows=20 loops=1)
        -> Nested loop inner join  (cost=93256 rows=20) (actual time=32.5..32.5 rows=20 loops=1)
            -> Table scan on page  (cost=18246..18249 rows=20) (actual time=32.5..32.5 rows=20 loops=1)
                -> Materialize  (cost=18246..18246 rows=20) (actual time=32.5..32.5 rows=20 loops=1)
                    -> Limit/Offset: 20/300000 row(s)  (cost=18241 rows=20) (actual time=32.5..32.5 rows=20 loops=1)
                        -> Covering index scan on big_orders using idx_ordered_at (reverse)  (cost=18241 rows=300020) (actual time=0.415..28.2 rows=300020 loops=1)
            -> Single-row index lookup on o using PRIMARY (id = `page`.id)  (cost=0.25 rows=1) (actual time=921e-6..939e-6 rows=1 loops=20)

1 row in set (0.033 sec)
What’s happening
  1. The plain OFFSET query needs total, which isn't in idx_ordered_at. Walking 300,020 index entries and fetching each full row was judged too expensive, so MySQL scanned and sorted the whole table: 82.6 ms.
  2. The deferred join's inner query only needs id, which is in the index, so it walks 300,020 entries of the narrow covering index (28 ms) and keeps 20 ids.
  3. The outer join then fetches just those 20 rows by primary key (loops=20). Total 32.6 ms — 2.5 times faster, but still linear in the offset. It's a mitigation, not a cure.
AdvancedPitfall: OFFSET pages drift
Predict the cost

Each page holds 50 rows. Roughly how many index entries does MySQL read to serve page 1,000 with ORDER BY id LIMIT 50 OFFSET 49950, and with WHERE id > :last_id ORDER BY id LIMIT 50?

Show answer

About 50,000 entries for the OFFSET version (49,950 skipped + 50 returned), and about 50 for the keyset version.

What’s happening
  1. OFFSET can't skip ahead in a B+tree: there are no row numbers in the index, so MySQL walks 49,950 entries just to count them, then returns the next 50.
  2. Keyset seeks to :last_id (three or four page reads for the descent) and reads the next 50 entries.
  3. Doubling the page number doubles the OFFSET cost; the keyset cost stays the same.

#Finding slow queries

Added

You can't fix what you haven't found. MySQL gives you three complementary sources:

  • The slow query log records every statement that ran longer than long_query_time seconds (default 10), with its time, lock time, rows examined and rows sent. It's off by default.
  • Performance Schema statement digests (events_statements_summary_by_digest) aggregate every statement since the server started, normalised into a "fingerprint" with literals replaced by ?: calls, total and average time, rows examined, whether an index was used. Always on.
  • The sys schema wraps Performance Schema in readable views: statements_with_full_table_scans, statements_with_sorting, schema_unused_indexes, schema_redundant_indexes and more.

The workflow: find the statements with the highest total time (a 5 ms query run a million times beats one 30-second report), look at rows_examined against rows_sent (examining 500,000 rows to send 1 is the smell), then EXPLAIN ANALYZE the worst ones and fix them with the earlier topics of this chapter.

SQL
SET GLOBAL slow_query_log_file = '/var/lib/mysql/ch09-slow.log';
SET GLOBAL slow_query_log = ON;
SET SESSION long_query_time = 0.02;   -- log anything over 20 ms from this session

SELECT COUNT(*) FROM big_orders WHERE DATE_FORMAT(ordered_at, '%Y-%m') = '2025-06';
SELECT COUNT(*) FROM big_orders WHERE customer_id = 4242;
SELECT c.city, SUM(o.total) FROM big_orders o JOIN big_customers c ON c.id = o.customer_id
WHERE o.country = 'AE' GROUP BY c.city ORDER BY 2 DESC LIMIT 3;

SET GLOBAL slow_query_log = OFF;
Result
$ cat /var/lib/mysql/ch09-slow.log
/usr/sbin/mysqld, Version: 9.7.2 (MySQL Community Server - GPL). started with:
Tcp port: 3306  Unix socket: /var/run/mysqld/mysqld.sock
Time                 Id Command    Argument
# Time: 2026-10-16T15:37:47.708412Z
# User@Host: root[root] @ localhost []  Id:   469
# Query_time: 0.037327  Lock_time: 0.000001 Rows_sent: 1  Rows_examined: 500000
use ch09;
SET timestamp=1792165067;
SELECT COUNT(*) FROM big_orders WHERE DATE_FORMAT(ordered_at, '%Y-%m') = '2025-06';
# Time: 2026-10-16T15:37:47.786328Z
# User@Host: root[root] @ localhost []  Id:   469
# Query_time: 0.077262  Lock_time: 0.000000 Rows_sent: 3  Rows_examined: 549978
SET timestamp=1792165067;
SELECT c.city, SUM(o.total) FROM big_orders o JOIN big_customers c ON c.id = o.customer_id WHERE o.country = 'AE' GROUP BY c.city ORDER BY 2 DESC LIMIT 3;
What’s happening
  1. slow_query_log and its file are global; long_query_time can be set per session, so this demo logs only its own statements with a 20 ms threshold. In production you'd set it globally to something like 0.5–1 s in the config file.
  2. The DATE_FORMAT query took 37 ms and examined 500,000 rows to send 1 — a non-sargable condition (see Why an index isn't used). A range on ordered_at would examine about 14,400.
  3. The indexed customer_id = 4242 lookup took about 1 ms, so it isn't in the log at all.
  4. The join took 77 ms and examined 549,978 rows (the 500,000 orders plus roughly one customer lookup for each of the ~50,000 AE orders) to send 3. country has no index; an index on (country, customer_id, total) would be the fix to try.
  5. Rows_examined vs Rows_sent is the quickest way to rank what to fix. log_slow_extra = ON (8.0.14+) adds more fields such as bytes sent and temporary tables created.
AdvancedStatement digests and the sys schema

These views summarise everything since the server started (or since the tables were truncated), so the output reflects whatever ran in ch09 while this chapter was being written:

SQL
SELECT LEFT(digest_text, 70) AS query, count_star AS calls,
       ROUND(sum_timer_wait / 1e12, 3) AS total_s,
       ROUND(avg_timer_wait / 1e9, 2) AS avg_ms,
       sum_rows_examined AS rows_examined, sum_rows_sent AS rows_sent,
       sum_no_index_used AS no_index
FROM performance_schema.events_statements_summary_by_digest
WHERE schema_name = 'ch09' AND digest_text LIKE 'SELECT%'
ORDER BY sum_timer_wait DESC
LIMIT 5;

SELECT object_name, index_name FROM sys.schema_unused_indexes WHERE object_schema = 'ch09';
Result
+------------------------------------------------------------------------+-------+---------+--------+---------------+-----------+----------+
| query                                                                  | calls | total_s | avg_ms | rows_examined | rows_sent | no_index |
+------------------------------------------------------------------------+-------+---------+--------+---------------+-----------+----------+
| SELECT COUNT ( DISTINCTROW STATUS ) AS `statuses` , COUNT ( DISTINCTRO |     1 |   0.291 | 291.12 |        500000 |         1 |        0 |
| SELECT COUNT ( DISTINCTROW `email` ) AS `full_len` , COUNT ( DISTINCTR |     1 |   0.131 | 130.57 |         50000 |         1 |        0 |
| SELECT `country` , COUNT ( * ) , `ROUND` ( COUNT ( * ) / ?, ... ) AS ` |     1 |   0.123 | 123.32 |        500005 |         5 |        1 |
| SELECT STATUS , COUNT ( * ) FROM `big_orders` GROUP BY STATUS          |     1 |   0.087 |  87.46 |        500000 |         4 |        1 |
| SELECT `c` . `city` , SUM ( `o` . `total` ) FROM `big_orders` `o` JOIN |     1 |   0.077 |  77.28 |        549978 |         3 |        1 |
+------------------------------------------------------------------------+-------+---------+--------+---------------+-----------+----------+
+----------------+---------------+
| object_name    | index_name    |
+----------------+---------------+
| articles       | ft_title_body |
| big_customers  | idx_phone     |
| big_orders     | idx_order_day |
| categories     | parent_id     |
| employees      | department_id |
| employees      | manager_id    |
| legacy_signups | email         |
| order_items    | product_id    |
| orders         | customer_id   |
| pk_int         | idx_customer  |
| pk_uuid        | idx_customer  |
| products       | category_id   |
+----------------+---------------+
What’s happening
  1. digest_text is the normalised statement: keywords upper-cased, identifiers quoted, literals replaced by ? — so WHERE customer_id = 4242 and = 7 count as one statement. (COUNT(DISTINCT …) shows up as DISTINCTROW in the normalised text.)
  2. Timer columns are in picoseconds: divide by 10¹² for seconds and by 10⁹ for milliseconds.
  3. sum_no_index_used counts executions that did a full scan. The GROUP BY status and the country queries scanned; the COUNT(DISTINCT …) statistics queries read the whole table through an index, so they don't count as "no index" even though they examined every row.
  4. sys.schema_unused_indexes lists indexes with no reads since the server started — candidates to drop (make them invisible first). It needs a long, representative uptime to mean anything: after a restart every index looks unused, and indexes that only a monthly report uses look unused for most of the month.
AdvancedNote: log analysis tools not run here

SQL and MySQL written fresh; MongoDB built from Rohit’s “mongoDB interview” doc. Every query was run on MySQL 9.7, MongoDB 9.0, Redis 8.10 or PostgreSQL 18.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.