Lesson 0004 · Internals & Performance

When indexes are ignored

You built the perfect index and MySQL scans the whole table anyway — here are the four reasons why, and the fix for each.

~10 minRetrieval quiz + hands-on EXPLAINCheat sheet: here

In Lesson 0003 you learned to spot the symptom: possible_keys lists your index, but key comes back NULL and type is ALL. This lesson explains the causes. Almost every "why won't it use my index?" bug is one of four things — and once you see the single principle behind them, you'll predict them before you even run EXPLAIN.

The one idea

The principle

A B-tree index is a sorted list of the bare column's values. MySQL can only seek it when the query compares that bare column, directly, to a type-compatible constant. Wrap the column in a function, feed it a mismatched type, or open the search on the left, and the sorted order is useless — so MySQL falls back to a full scan.

Killer 1 — a function or expression on the column

The moment you wrap an indexed column in a function or arithmetic, the index dies. The index stores created_at values in order — it knows nothing about the order of YEAR(created_at).

-- index on created_at is IGNORED — column is wrapped
WHERE YEAR(created_at) = 2026
WHERE created_at + INTERVAL 1 DAY > NOW()
WHERE UPPER(last_name) = 'JONES'

Fix A — rewrite as a bare-column range (the sargable form):

WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'   -- uses the index (range)

Fix B — add a functional index (MySQL 8.0.13+), which indexes the expression's value instead of the column's: MySQL Manual: functional key parts "index expression values rather than column or column prefix values… enable indexing of values not stored directly in the table."

ALTER TABLE people ADD INDEX idx_upper ((UPPER(last_name)));   -- now UPPER(last_name)= can seek

Killer 2 — a leading wildcard in LIKE

An index is a phone book (Lesson 0002): you can find names starting with "Pat", but not names containing "pat" — those are scattered everywhere. So a wildcard on the left kills the index; a wildcard on the right is fine. MySQL Manual: "the LIKE value begins with a wildcard character" → index not used; "LIKE 'Patrick%'" → index used.

QueryIndex?
WHERE key_col LIKE 'Pat%'used (range)
WHERE key_col LIKE '%Pat%'ignored — full scan
WHERE key_col LIKE '%Pat'ignored — full scan

Need "contains" search at speed? That's a job for a FULLTEXT index or a search engine — not a B-tree. A trick for "ends-with": store a reversed copy and index that, turning the suffix search into a prefix search.

Killer 3 — an implicit type mismatch

Compare an indexed string column to a number and MySQL must implicitly convert — and because many strings ('1', ' 1', '1a') all collapse to the number 1, the sorted string index can't be used. MySQL Manual: "For comparisons of a string column with a number, MySQL cannot use an index on the column to look up the value quickly."

WHERE str_col = 1      -- IGNORED: string column vs number → implicit conversion
WHERE str_col = '1'    -- uses the index: types match
The quiet version

The same trap hides in joins: joining a VARCHAR user_id to a BIGINT user_id, or across columns with different collations, forces a conversion and drops the index on one side. Matching column types across a schema isn't pedantry — it's index insurance.

Killer 4 — the index just isn't worth it (low selectivity)

This one isn't a bug — it's the optimizer being right. If a filter matches a large fraction of the table (say status = 'active' where 90% are active), using the secondary index means a bookmark lookup (Lesson 0001) for almost every row — more work than just scanning the table once. So the optimizer picks ALL on purpose. A column with few distinct values has low selectivity; indexing it alone rarely helps.

Confirm it with the funnel

Recall rows × filtered from 0003: if filtered is high (little is filtered out), the index can't save much, and a scan wins. Use EXPLAIN ANALYZE to check the optimizer guessed the selectivity right; if not, ANALYZE TABLE to refresh statistics.

Check yourself

Answer from memory — effortful recall is what builds retention. Feedback is immediate.

Why does WHERE YEAR(created_at) = 2026 skip the index on created_at?

The index sorts raw created_at values, not YEAR(...) of them. Wrapping the column in a function makes that sorted order useless — rewrite as a bare-column range or add a functional index.

Which LIKE pattern can still use a B-tree index?

A wildcard on the left scatters matches through the index, so only a right-anchored pattern ('pat%') is a prefix the index can range over.

For an indexed string column str_col, which filter uses the index?

Comparing a string column to a number forces an implicit conversion (many strings map to 1), so the index can't be used. Quote the literal so the types match.

The optimizer skips an index on purpose when the filter matches:

Low selectivity: if a filter keeps most rows, a secondary index means a bookmark lookup for nearly every row — costlier than one full scan. Choosing ALL is correct here.

A function on an indexed column can be made seekable (8.0.13+) with:

A functional index (functional key part) indexes the expression's value, so a filter on that same expression can seek it. It's implemented as a hidden virtual generated column.

WHERE created_at BETWEEN ? AND ? on an indexed column gives type:

A bare-column BETWEEN is the sargable range form — the index seeks the low bound and scans to the high bound. That's type: range (the rung between ref and index).

Hands-on: kill an index, then revive it

Reuse the people table (index name (last_name, first_name)). Read key and type each time.

-- 1) wrapped column → key: NULL, type: ALL
EXPLAIN SELECT * FROM people WHERE UPPER(last_name) = 'JONES';

-- 2) sargable rewrite → key: name, type: ref
EXPLAIN SELECT * FROM people WHERE last_name = 'Jones';

-- 3) leading wildcard → key: NULL; right wildcard → key: name, type: range
EXPLAIN SELECT * FROM people WHERE last_name LIKE '%ones';
EXPLAIN SELECT * FROM people WHERE last_name LIKE 'Jon%';

-- 4) revive the wrapped query with a functional index
ALTER TABLE people ADD INDEX idx_upper ((UPPER(last_name)));
EXPLAIN SELECT * FROM people WHERE UPPER(last_name) = 'JONES';   -- now uses idx_upper
What you should observe

1 scans (key: NULL). 2 seeks (key: name, type: ref). 3: the leading-wildcard query scans; 'Jon%' becomes a range. 4: after the functional index, the UPPER() query finally seeks idx_upper. You just watched all four rules — and two fixes — in the plan. Drop the extra index with ALTER TABLE people DROP INDEX idx_upper; when done.

Primary source — read this next

MySQL 8.0 Reference Manual — Comparison of B-Tree and Hash Indexes (the LIKE rules) plus Type Conversion in Expression Evaluation (the string-vs-number rule). For the fix, see CREATE INDEX — functional key parts.