Indexes — Cheat Sheet
MySQL · 6 topics. Download the PDF or the Instagram carousel and share it.
Indexes Overview
Indexes are data structures that speed up row lookup at the cost of write overhead and storage; InnoDB uses B-tree indexes for most index types.
- ✓The clustered (primary key) index IS the table — leaf nodes store full row data in PK order.
- ✓Secondary indexes store indexed columns + PK; reading a full row requires a second lookup into the clustered index.
- ✓Random UUID PKs cause B-tree page splits and fragmentation; use BIGINT AUTO_INCREMENT or time-ordered UUIDs.
- ✓High-cardinality columns (email, ID) make better indexes than low-cardinality ones (boolean, status with 3 values).
- ✓Over-indexing is a real problem — every index adds overhead on INSERT/UPDATE/DELETE; remove unused indexes.
- ✓Use EXPLAIN and performance_schema.table_io_waits_summary_by_index_usage to identify missing and unused indexes.
-- PRIMARY KEY (clustered index): the table IS the index
-- Rows stored in PK order on disk; all secondary indexes include PK
CREATE TABLE orders (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, -- clustered
...
);
-- Single-column secondary index: speeds up equality and range on one column
CREATE INDEX idx_status ON orders (status);
-- Composite (multi-column) index: leftmost prefix rule applies
-- Covers: status, status+customer_id, status+customer_id+created_at
CREATE INDEX idx_status_customer_date ON orders (status, customer_id, created_at);
-- UNIQUE index: enforces uniqueness + fast lookup (same as regular index + constraint)
CREATE UNIQUE INDEX idx_email ON users (email);
-- FULLTEXT index: relevance-based text search with MATCH() ... AGAINST()
CREATE FULLTEXT INDEX idx_description ON products (description);
SELECT * FROM products WHERE MATCH(description) AGAINST ('wireless headphones' IN BOOLEAN MODE);
-- Prefix index: index only first N characters (saves space for long VARCHAR)
CREATE INDEX idx_url_prefix ON pages (url(50));
-- Cannot be used for ORDER BY or GROUP BY (not covering)B-Tree Index
InnoDB's default B-tree index supports equality, range, prefix, and ORDER BY operations; the leftmost prefix rule governs which queries benefit from a composite index.
- ✓Every InnoDB table has a clustered B-Tree index (the PRIMARY KEY) — row data is physically stored at its leaf nodes in PK order.
- ✓Secondary index leaf nodes store the indexed column(s) + primary key; MySQL does a second lookup (bookmark lookup) to fetch the full row.
- ✓A covering index includes all columns needed by a query, eliminating the bookmark lookup — EXPLAIN shows "Using index".
- ✓The leftmost prefix rule: a composite index on (a, b, c) is usable for queries that start with a; skipping the first column means the index is not used.
- ✓After a range condition on a column in a composite index, subsequent columns in the index cannot be used for filtering.
- ✓Avoid applying functions on indexed columns in WHERE clauses (YEAR(created_at)); use range conditions instead to keep the index usable.
-- Clustered index = PRIMARY KEY
-- Row data is physically ordered by customer_id
CREATE TABLE customers (
customer_id INT NOT NULL AUTO_INCREMENT,
email VARCHAR(255) NOT NULL,
name VARCHAR(100),
city VARCHAR(50),
PRIMARY KEY (customer_id) -- clustered index
);
-- Secondary index on email
CREATE INDEX idx_email ON customers (email);
-- When MySQL uses idx_email to find a row:
-- 1. Traverse idx_email B-Tree to find leaf with email value → gets customer_id
-- 2. Traverse PRIMARY KEY B-Tree with that customer_id → gets full row
-- This is the "bookmark lookup" — 2 B-Tree traversals
-- Avoid it with a covering index (include all needed columns):
CREATE INDEX idx_email_name ON customers (email, name);
-- SELECT name FROM customers WHERE email = '...'
-- → satisfied entirely from the secondary index, no bookmark lookup neededComposite Index
A composite index on (a, b, c) supports queries on (a), (a, b), and (a, b, c) due to leftmost prefix matching; column order matters — put high-cardinality, equality columns first.
- ✓Composite index (a, b, c) supports queries on (a), (a, b), (a, b, c) via leftmost prefix
- ✓Queries filtering only on b or c alone cannot use the (a, b, c) index
- ✓Put equality filter columns first, range/ORDER BY columns last
- ✓A covering index includes all queried columns — no row lookup needed ("Using index")
- ✓Low-cardinality first columns (e.g. boolean flag) make the index nearly useless
- ✓Index merge (two single-column indexes) is usually slower than one well-designed composite
-- Index: (status, created_at, user_id)
CREATE INDEX idx_orders_status_date_user
ON orders(status, created_at, user_id);
-- Uses index (status equality + created_at range = both usable)
EXPLAIN SELECT * FROM orders
WHERE status = 'PAID' AND created_at > '2025-01-01';
-- → type: range, key: idx_orders_status_date_user
-- CANNOT use index — skips leftmost column
EXPLAIN SELECT * FROM orders WHERE created_at > '2025-01-01';
-- → type: ALL (full table scan)
-- Uses only the first column (range on 2nd → 3rd column unused in range scan)
EXPLAIN SELECT * FROM orders
WHERE status = 'PAID' AND created_at > '2025-01-01' AND user_id = 42;
-- status: range scan; created_at used in range; user_id NOT used for range
-- (user_id may still be used for filtering via index condition pushdown)Covering Index
A covering index includes all columns needed for a query, eliminating the secondary lookup to the clustered index; MySQL shows "Using index" in EXPLAIN for covered queries.
- ✓A covering index contains all columns a query needs: WHERE + ORDER BY + SELECT
- ✓InnoDB includes the PK in every secondary index leaf — PK is always "free" for covering
- ✓"Using index" in EXPLAIN Extra means the query is covered — no row lookup
- ✓Row lookup (secondary → clustered index) is O(N) random I/O — covering index eliminates it
- ✓Index column order: equality filters → range/sort → SELECT payload columns
- ✓Trade-off: wider covering indexes write slower and consume more space — add only what is needed
-- Query 1: only needs columns in the index
-- Index: (status, created_at)
SELECT id, status, created_at -- id = PK, already in every secondary index
FROM orders
WHERE status = 'PAID'
ORDER BY created_at DESC;
-- EXPLAIN → Extra: Using index ✓ (PK included implicitly)
-- Query 2: needs 'total' — not in the index → row lookup required
SELECT id, status, created_at, total -- total NOT in index
FROM orders
WHERE status = 'PAID';
-- EXPLAIN → Extra: (no "Using index") → performs row lookup for each row
-- Fix: create a covering index that includes 'total'
CREATE INDEX idx_orders_covering
ON orders(status, created_at, total);
-- Now: EXPLAIN → Extra: Using index ✓Full-Text Index
Full-text indexes enable MATCH(col) AGAINST('term') searches with natural language and boolean modes; faster than LIKE '%term%' for text-heavy search on large datasets.
- ✓FULLTEXT indexes use an inverted index — MATCH() AGAINST() uses the index; LIKE '%term%' does not
- ✓Natural Language mode returns relevance-ranked results; Boolean mode supports +/- operators but is not ranked by default
- ✓Words shorter than innodb_ft_min_token_size (default 3) and stop words are not indexed
- ✓After changing min_token_size, run OPTIMIZE TABLE to rebuild the full-text index
- ✓Boolean mode + (must) and - (must not) operators are the most common use case for structured text search
- ✓For fuzzy matching, stemming, multilingual search, or > 50M rows: use Elasticsearch or OpenSearch instead
-- Create table with full-text index
CREATE TABLE articles (
id INT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(200),
body TEXT,
FULLTEXT idx_ft_title_body (title, body) -- multi-column full-text index
);
-- Add to existing table
ALTER TABLE articles ADD FULLTEXT INDEX idx_ft_body (body);
-- Natural Language mode (default) — ranked by relevance
SELECT id, title, MATCH(title, body) AGAINST ('kafka streaming') AS relevance
FROM articles
WHERE MATCH(title, body) AGAINST ('kafka streaming') -- WHERE uses index
ORDER BY relevance DESC
LIMIT 10;
-- EXPLAIN should show: Extra = "Using where; Ft_hints: sorted"
EXPLAIN SELECT * FROM articles
WHERE MATCH(title, body) AGAINST ('kafka streaming');
-- Minimum token length (default 3 — cannot search 2-char words)
SHOW VARIABLES LIKE 'innodb_ft_min_token_size'; -- default: 3
-- To allow 2-char words: set innodb_ft_min_token_size=2 in my.cnf + OPTIMIZE TABLEIndex Selectivity & Cardinality
High selectivity (many distinct values) makes an index effective; cardinality statistics in information_schema.statistics guide the optimiser's index selection decisions.
- ✓Selectivity = distinct values / total rows; high selectivity (→ 1.0) makes an index highly effective
- ✓Cardinality is the optimiser's estimate of distinct values; run ANALYZE TABLE to refresh after bulk changes
- ✓If an index would return > 20–30% of rows, the optimiser often prefers a full table scan (cheaper sequential I/O)
- ✓EXPLAIN shows "key: NULL" and "type: ALL" when the optimiser rejects an available index due to low selectivity
- ✓Low-selectivity leading columns (status, boolean flags) benefit from high-selectivity second columns in composite indexes
- ✓Prefix indexes (description(20)) reduce storage on long text columns but cannot serve ORDER BY or covering index lookups
-- Check index cardinality (higher = more selective)
SHOW INDEX FROM orders;
-- Columns: Key_name, Column_name, Cardinality
-- Cardinality is an estimate — refreshed by ANALYZE TABLE
-- Manually calculate selectivity for a column
SELECT
COUNT(DISTINCT status) / COUNT(*) AS status_selectivity,
COUNT(DISTINCT customer_id) / COUNT(*) AS customer_selectivity,
COUNT(DISTINCT id) / COUNT(*) AS id_selectivity
FROM orders;
-- status: 0.00001 (5 distinct values / 500k rows) → LOW selectivity
-- customer_id: 0.05 (25k customers / 500k rows) → MEDIUM selectivity
-- id: 1.0 (500k / 500k) → HIGH selectivity (primary key)
-- Low-selectivity index example: status has 5 values
-- Querying WHERE status = 'PLACED' returns 20% of rows
-- Optimiser may prefer full scan over index lookup
-- Refresh statistics after bulk load
ANALYZE TABLE orders;
-- information_schema cardinality
SELECT INDEX_NAME, COLUMN_NAME, CARDINALITY
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'shop' AND TABLE_NAME = 'orders'
ORDER BY SEQ_IN_INDEX;