MySQLIndexes

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':

  1. Without an index: MySQL reads every row — O(n) with n rows.
  2. 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

Note
Because secondary index lookups do a second lookup via the primary key, wide primary keys (e.g., a UUID stored as VARCHAR(36)) make every secondary index larger and slower. Use INT or BIGINT AUTO_INCREMENT PKs in InnoDB when possible.
CREATE INDEX Syntax

SQL
-- 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:

SQL
-- 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)
Tip
After bulk inserts or major data changes, run 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:

  1. Equality columns first (WHERE col = value) — these eliminate the most rows
  2. Range columns next (WHERE col > value, BETWEEN, IN) — ranges can only use one column per index
  3. ORDER BY columns last — if the sort matches the index tail, MySQL avoids a filesort

SQL
-- 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.

SQL
-- 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.

SQL
-- 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
Warning
A prefix index cannot be used as a covering index because MySQL cannot reconstruct the full column value from the prefix. The optimizer must always fetch the row to verify the full value when a WHERE clause is more specific than the prefix.
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.

SQL
-- 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:

SQL
-- 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';
Note
Use index hints only as a last resort. If the optimizer consistently picks the wrong index, the root cause is usually stale statistics — run 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.

SQL
-- 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

SQL
-- 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

SQL
-- 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

SQL
-- 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

SQL
-- 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:

SQL
-- 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

SQL
-- 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

SQL
-- 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