MySQL Indexes
An index is a separate data structure that MySQL maintains alongside a table to enable fast row lookups. Without indexes, every query that filters or sorts data must scan every row in the table — a full table scan. With the right indexes, MySQL can jump directly to the rows it needs, making queries that would take seconds on large tables complete in milliseconds.
How a B-Tree Index Works
MySQL's default index type is a B-tree (balanced tree). The structure stores key values in sorted order across tree nodes. Each internal node points to child nodes, and each leaf node stores the key value plus a pointer back to the actual table row.
When you run WHERE email = 'alice@example.com':
- Without an index: MySQL reads every row — O(n) with n rows.
- With a B-tree index on email: MySQL traverses root → branch → leaf in O(log n) steps, then follows the row pointer.
For InnoDB, the clustered index (the primary key) is special: the leaf nodes of the clustered index are the actual row data — there is no separate row pointer. For secondary indexes, leaf nodes store the secondary key value plus the primary key value (not a physical pointer), so a secondary index lookup always ends with a primary key lookup to fetch the full row.
Clustered vs Secondary Indexes in InnoDB
Feature | Clustered Index (PK) | Secondary Index |
|---|---|---|
Leaf node contains | Full row data | Secondary key + primary key value |
One per table? | Yes — exactly one | Unlimited |
Row lookup after traversal | Not needed — leaf IS the row | Required — uses PK to fetch full row |
Physical row order | Rows stored in PK order on disk | No effect on physical order |
Covering index possible? | N/A | Yes — if query needs only the secondary key + PK columns |
CREATE INDEX Syntax
-- Single-column index CREATE INDEX idx_users_email ON users (email); -- Unique index (also enforces uniqueness constraint) CREATE UNIQUE INDEX idx_users_email_unique ON users (email); -- Index at table creation time CREATE TABLE orders ( id INT NOT NULL AUTO_INCREMENT, user_id INT NOT NULL, status VARCHAR(20), created_at DATETIME, PRIMARY KEY (id), INDEX idx_user_id (user_id), INDEX idx_status (status), INDEX idx_created (created_at) ); -- Add an index to an existing table ALTER TABLE products ADD INDEX idx_category (category_id); ALTER TABLE products ADD UNIQUE INDEX idx_sku (sku);
Index Selectivity and Cardinality
Selectivity is the ratio of distinct values to total rows. High selectivity (close to 1.0) means the index can eliminate most rows. Low selectivity (close to 0.0) means the index is nearly useless for filtering.
The SHOW INDEX command exposes the optimizer's cardinality estimate for each index:
-- Show all indexes with cardinality SHOW INDEX FROM users; -- Key columns: -- Cardinality: estimated number of distinct values in the index -- Non_unique: 0 = UNIQUE, 1 = non-unique -- Seq_in_index: position in a composite index (1, 2, 3...) -- Calculate exact selectivity for any column SELECT 'email' AS col, COUNT(DISTINCT email) / COUNT(*) AS selectivity FROM users UNION ALL SELECT 'status', COUNT(DISTINCT status) / COUNT(*) AS selectivity FROM users; -- email: ~1.00 (excellent — every email is unique) -- status: ~0.003 (poor — only 3 distinct values in 1000 rows)
ANALYZE TABLE orders; to refresh the cardinality statistics that the optimizer uses. Stale statistics lead to poor query plans.Composite Index Column Order Strategy
The order of columns in a composite index is critical. MySQL can use the index only when querying the leftmost prefix of the indexed columns. The general rule for ordering:
- Equality columns first (WHERE col = value) — these eliminate the most rows
- Range columns next (WHERE col > value, BETWEEN, IN) — ranges can only use one column per index
- ORDER BY columns last — if the sort matches the index tail, MySQL avoids a filesort
-- Goal: optimize WHERE user_id = ? AND status = ? ORDER BY created_at CREATE INDEX idx_orders_composite ON orders (user_id, status, created_at); -- This composite index is used for all of: SELECT * FROM orders WHERE user_id = 42; -- leftmost prefix SELECT * FROM orders WHERE user_id = 42 AND status = 'paid'; -- first two columns SELECT * FROM orders WHERE user_id = 42 AND status = 'paid' ORDER BY created_at; -- all three columns -- This is NOT used effectively (skips leftmost column): SELECT * FROM orders WHERE status = 'paid'; -- cannot use the index efficiently
Covering Indexes
A covering index contains all columns a query needs — both for filtering and for the SELECT list. MySQL can answer the query entirely from the index without touching the main table rows. EXPLAIN will show Using index in the Extra column.
-- Index covers both the WHERE clause and the SELECT list CREATE INDEX idx_covering ON orders (user_id, status, created_at); -- All needed columns are in the index — no table access required SELECT user_id, status, created_at FROM orders WHERE user_id = 42; -- Verify with EXPLAIN EXPLAIN SELECT user_id, status, created_at FROM orders WHERE user_id = 42G
id: 1 select_type: SIMPLE table: orders type: ref key: idx_covering rows: 12 Extra: Using index <-- covered! no table access needed
Prefix Indexes on VARCHAR Columns
For long VARCHAR or TEXT columns, you can index only the first N characters to save space. The tradeoff: a prefix index cannot be a covering index, and its selectivity depends on how well the prefix discriminates rows.
-- Index the first 20 characters of email CREATE INDEX idx_email_prefix ON users (email(20)); -- Find the right prefix length: compare selectivity SELECT COUNT(DISTINCT LEFT(email, 5)) / COUNT(*) AS sel_5, COUNT(DISTINCT LEFT(email, 10)) / COUNT(*) AS sel_10, COUNT(DISTINCT LEFT(email, 20)) / COUNT(*) AS sel_20, COUNT(DISTINCT email) / COUNT(*) AS sel_full FROM users; -- Stop adding characters when selectivity plateaus
Invisible Indexes (MySQL 8.0+)
MySQL 8.0 introduced invisible indexes. An invisible index is maintained but ignored by the query optimizer. This lets you test the impact of removing an index without actually dropping it — a safe way to decommission suspected redundant indexes.
-- Make an index invisible (optimizer ignores it, but it stays maintained) ALTER TABLE orders ALTER INDEX idx_status INVISIBLE; -- Run your workload and check performance — if nothing breaks, safe to drop -- Check query plans changed as expected: EXPLAIN SELECT * FROM orders WHERE status = 'pending'; -- Restore the index to visible ALTER TABLE orders ALTER INDEX idx_status VISIBLE; -- Create an invisible index from the start (pre-test before enabling) CREATE INDEX idx_test ON orders (created_at) INVISIBLE;
Index Hints
When the optimizer picks the wrong index, you can guide it with hints:
-- Suggest an index (optimizer may still ignore it) SELECT * FROM orders USE INDEX (idx_status) WHERE status = 'pending'; -- Force the use of a specific index (optimizer must use it) SELECT * FROM orders FORCE INDEX (idx_status) WHERE status = 'pending'; -- Tell the optimizer not to use a specific index SELECT * FROM orders IGNORE INDEX (idx_created_at) WHERE status = 'pending'; -- MySQL 8.0 optimizer hints (preferred — more expressive) SELECT /*+ INDEX(orders idx_status) */ * FROM orders WHERE status = 'pending';
ANALYZE TABLE first.When NOT to Index
Small tables (fewer than a few thousand rows) — full scans are fast enough and indexes add write overhead.
Low-cardinality columns like boolean flags (is_active) or a status column with only 2–3 values — the optimizer often ignores these indexes and does a full scan instead.
Columns that are updated very frequently — every row update must also update all covering indexes on that column.
Tables that are write-heavy (logs, event streams, audit trails) — indexes slow INSERT/UPDATE/DELETE.
Columns used only with a leading wildcard: LIKE "%term" cannot use a B-tree index.
Redundant columns that are already covered by the leftmost prefix of a composite index.
Index Maintenance
Indexes can become fragmented over time as rows are inserted, updated, and deleted. Fragmented indexes waste space and slow queries.
-- Check fragmentation via information_schema
SELECT TABLE_NAME, DATA_FREE, DATA_LENGTH, INDEX_LENGTH,
ROUND(DATA_FREE / (DATA_LENGTH + INDEX_LENGTH) * 100, 1) AS fragmentation_pct
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'orders';
-- Rebuild all indexes on a table (reclaims space, rebuilds stats)
OPTIMIZE TABLE orders;
-- Update cardinality statistics only (faster than OPTIMIZE)
ANALYZE TABLE orders;
-- MySQL 8.0: online index rebuild — does not lock reads or writes
ALTER TABLE orders DROP INDEX idx_user_id,
ADD INDEX idx_user_id (user_id),
ALGORITHM=INPLACE, LOCK=NONE;Finding Unused and Redundant Indexes
-- MySQL 8.0: indexes with zero usage since server start
SELECT object_schema, object_name, index_name
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE index_name IS NOT NULL
AND count_star = 0
AND object_schema NOT IN ('mysql', 'performance_schema', 'sys', 'information_schema')
ORDER BY object_schema, object_name;
-- Find duplicate / redundant indexes with Percona Toolkit
-- pt-duplicate-key-checker --host=localhost --user=root
-- Use sys schema view (MySQL 8.0)
SELECT * FROM sys.schema_redundant_indexes
WHERE table_schema = DATABASE();
SELECT * FROM sys.schema_unused_indexes
WHERE object_schema = DATABASE();SHOW INDEX and DROP INDEX
-- List all indexes on a table SHOW INDEX FROM orders; SHOW INDEX FROM ordersG -- vertical output, easier to read -- Drop an index by name DROP INDEX idx_status ON orders; -- Using ALTER TABLE syntax ALTER TABLE orders DROP INDEX idx_status; -- Drop primary key (rare — only for tables without AUTO_INCREMENT) ALTER TABLE legacy_table DROP PRIMARY KEY;
Index Types Summary
Index Type | Use Case | Notes |
|---|---|---|
B-Tree (default) | Equality, range, ORDER BY, prefix match | Works with all data types in InnoDB |
UNIQUE | Enforce distinct values and enable fast lookup | Also a constraint — rejects duplicate inserts |
FULLTEXT | Natural language text search with relevance ranking | MATCH() AGAINST() syntax; InnoDB since MySQL 5.6 |
SPATIAL | Geometric / geographic data types | Used with POINT, POLYGON, etc. |
Hash | Exact equality lookups only — no range, no sort | Only available in MEMORY engine, not InnoDB |
Index Performance Impact — Before and After
-- Before: no index on email column, 500,000 rows EXPLAIN SELECT * FROM users WHERE email = 'alice@example.com'G -- type: ALL, rows: 500000, key: NULL -- After: add a unique index CREATE UNIQUE INDEX idx_users_email ON users (email); EXPLAIN SELECT * FROM users WHERE email = 'alice@example.com'G -- type: const, rows: 1, key: idx_users_email -- The const type means MySQL found at most one matching row via the unique index -- It is the fastest possible access type
Index Interaction with Joins
-- A join on an unindexed column is extremely slow on large tables EXPLAIN SELECT u.name, o.total FROM users u JOIN orders o ON o.user_id = u.id WHERE u.id = 42G -- orders table: type: ALL if user_id has no index -- With an index on orders.user_id: CREATE INDEX idx_orders_user ON orders (user_id); EXPLAIN SELECT u.name, o.total FROM users u JOIN orders o ON o.user_id = u.id WHERE u.id = 42G -- orders table: type: ref, key: idx_orders_user, rows: 5 -- Rule: always index the column on the many-side of a 1:many JOIN
Function-Based Indexes (MySQL 8.0+)
Applying a function to an indexed column in a WHERE clause prevents the index from being used. MySQL 8.0 adds functional indexes to solve this:
-- This query CANNOT use a regular index on created_at: SELECT * FROM orders WHERE YEAR(created_at) = 2024; -- Because the function wraps the indexed column — optimizer cannot use the index -- Solution 1 (any MySQL version): rewrite as a range query SELECT * FROM orders WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'; -- Now the regular index on created_at works fine -- Solution 2 (MySQL 8.0+): create a functional index CREATE INDEX idx_year ON orders ((YEAR(created_at))); -- Then YEAR(created_at) = 2024 uses this index
Quick Reference Checklist
Index every foreign key column — MySQL requires it for efficient constraint checks on parent DELETE/UPDATE.
Add indexes for ORDER BY columns to eliminate 'Using filesort' in EXPLAIN.
Use composite indexes to cover both WHERE filters and ORDER BY in one structure.
Verify index usage with EXPLAIN before and after adding an index.
Use EXPLAIN ANALYZE on MySQL 8.0 to see actual row counts vs optimizer estimates.
Run ANALYZE TABLE after bulk loads to refresh cardinality statistics.
Use invisible indexes (MySQL 8.0) to safely test decommissioning an index before dropping it.
Avoid more than 5–6 indexes on a write-heavy table — each index is updated on every write.
Never wrap an indexed column in a function in WHERE — rewrite as a range or use a functional index (MySQL 8.0).
Use prefix indexes (col(N)) for long VARCHAR/TEXT columns when full-column indexes exceed the key length limit.
Index Statistics and ANALYZE TABLE
-- Cardinality statistics are sampled, not exact -- After a large bulk insert, the optimizer may have stale estimates SHOW INDEX FROM orders; -- Cardinality: 1247 <-- may be outdated after bulk insert -- Refresh statistics without rebuilding the index ANALYZE TABLE orders; -- Result: Table 'orders' Op: analyze Status: OK -- SHOW INDEX after ANALYZE shows updated cardinality SHOW INDEX FROM orders; -- Cardinality: 1823456 <-- now reflects actual data -- Configure how much data InnoDB samples when computing statistics -- (higher = more accurate but slower ANALYZE) SET GLOBAL innodb_stats_persistent_sample_pages = 20; -- default is 20
Index Design Decision Tree
Query Pattern | Index Strategy |
|---|---|
WHERE col = value (single column, high cardinality) | Single-column index on col |
WHERE col1 = ? AND col2 = ? | Composite index (col1, col2) — equality on both |
WHERE col1 = ? AND col2 > ? | Composite index (col1, col2) — equality first, range last |
WHERE col1 = ? ORDER BY col2 | Composite index (col1, col2) — eliminates filesort |
SELECT a, b FROM t WHERE c = ? | Covering index (c, a, b) — all needed columns in index |
WHERE col LIKE "prefix%" | Regular B-tree index on col works for prefix patterns |
WHERE col LIKE "%suffix" or "%middle%" | FULLTEXT index — B-tree cannot help with leading wildcards |
WHERE col IN (v1, v2, v3) | Index on col — MySQL does a range scan for IN lists |
Verifying Index Usage After Changes
-- After adding an index, confirm it is used by your query -- Step 1: add the index CREATE INDEX idx_orders_user_status ON orders (user_id, status); -- Step 2: run EXPLAIN EXPLAIN SELECT * FROM orders WHERE user_id = 42 AND status = 'pending'G -- Confirm: key = idx_orders_user_status, type = ref -- Step 3: run EXPLAIN ANALYZE (MySQL 8.0) to confirm real performance EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42 AND status = 'pending'G -- Confirm: actual time is small, actual rows matches rows estimate -- Step 4: check the slow log after the index is in production -- The query should no longer appear there