CONCEPT Cited by 3 sources
Index obfuscation¶
Index obfuscation is the situation where a query filters on an indexed column but wraps the column in an expression (function call, arithmetic, implicit cast) so the planner no longer recognises the predicate as index-eligible. The index still exists, the query still returns correct results — but the planner silently falls back to a full table scan because the expression's value is not the thing the index is sorted by.
Aaron Francis's folk-framing:
When we wrap the
created_atcolumn in a function, we're obfuscating the column from MySQL, and it can no longer use the index. By wrapping thecreated_atcolumn in aYEARfunction, we're asking MySQL to do an index lookup onYEAR(created_at), which is not an index MySQL maintains. It is only maintaining thecreated_atindex.— Aaron Francis, Using redundant conditions to unlock indexes in MySQL
(Source: sources/2026-04-21-planetscale-using-redundant-conditions-to-unlock-indexes-in-mysql.)
Industry-standard term: non-sargable¶
The database-systems term of art is non-sargable predicate — "Search ARGument ABLE". A sargable predicate is one the planner can push down to an index seek; a non-sargable predicate forces a full scan because the indexed column is hidden behind an expression. Francis's "obfuscation" framing is more accessible to developers who don't know the optimiser term of art, and is the framing this wiki canonicalises.
The four classes¶
Index obfuscation divides into four common classes:
- Function wrapping —
WHERE YEAR(created_at) = 2023when the index is oncreated_at. The fix is either a range rewrite (when the function is invertible on the interval of interest —BETWEEN '2023-01-01' AND '2023-12-31') or a functional index on the expression itself. - Implicit type cast —
WHERE string_col = 123when the column isVARCHAR. MySQL casts every row's string to an integer before comparison and the index becomes unusable. Canonical fix: quote the literal (= '123'). This is the subtlest class because the query returns correct results and only EXPLAIN reveals the scan. - Arithmetic on the indexed column —
WHERE col + 1 = xrather thanWHERE col = x - 1. The first form is non-sargable, the second is a normal index seek. - Multi-column expressions —
WHERE ADDTIME(due_date, due_time) BETWEEN ... AND ...where the expression spans two columns. No single-column index can serve this predicate; the fix is either a multi-column functional index or a redundant condition on a projection of the expression.
Canonical EXPLAIN signature¶
Index obfuscation has an unambiguous EXPLAIN fingerprint.
The sargable version produces:
The obfuscated version — same index, same data, same semantic predicate — produces:
The telltale three-column flip (type: ALL, key: NULL,
rows: table_cardinality) is the primary operational
diagnostic. See concepts/mysql-access-type for the full
access-type ladder; ALL is the worst rung.
Three escape hatches¶
In order of preference:
- Rewrite the predicate to be sargable. For invertible
functions this is a mechanical rewrite:
YEAR(c) = 2023→c BETWEEN '2023-01-01' AND '2023-12-31';col + 1 = x→col = x - 1;'a' || col LIKE 'a%'→col LIKE '%'. Always the first choice when available. - Add a functional index on the expression (MySQL 8.0.13+) — or an explicit named generated column indexed separately. Trades write-amplification budget for planner visibility.
- Add a redundant condition on a single-column projection of the wrapped expression. Costs no schema change; costs some false-positive candidate rows that the original predicate filters out. The right tool when the function wraps multiple columns (cases #2 and #4 above) or when adding an index is blocked by write-amplification budget.
Why it matters¶
Index obfuscation is a silent-failure class. The query
returns correct results and the app behaves normally — the
symptom is a "slow query" alarm or a "database is
slower than last week" operational complaint. Without
EXPLAIN, the cause is invisible. Francis's framing makes
this legible to developers who would otherwise never
suspect their innocuous YEAR() call was the reason their
todos table went from 1ms queries to 40ms queries when
row count crossed 10k.
The class is easy to introduce and hard to detect in
review: WHERE DATE(created_at) = '2023-06-07' looks
natural; WHERE created_at >= '2023-06-07' AND created_at
< '2023-06-08' looks awkward. Teams that don't teach
sargability end up with this pattern spread across their
application code.
Implicit type cast: the silent footgun¶
Of the four classes, implicit type cast is the hardest to spot because there is no visible function call. MySQL's rule for mixed-type comparisons is: convert strings to numbers. So:
-- id is INT, query passes it as a string
WHERE id = '123' -- sargable (literal cast to int)
-- phone is VARCHAR, query passes it as a number
WHERE phone = 5551234 -- NON-sargable (column cast to number)
The second form silently full-scans because every phone
row value must be cast to a number before comparison. The
fix is to quote the literal. Production incidents from
this class have been reported across ORMs that auto-infer
bind parameter types from language values (PHP's loose
typing, Ruby's to_s coercions, JavaScript's Number/String
ambiguity).
Relationship to redundant conditions¶
Redundant conditions are the compensating pattern for index obfuscation when neither rewrite nor functional index is available. The redundant condition re-expresses a subset of the query's filter using only indexed columns without expressions; the planner uses the index to narrow the candidate set; the expensive expression then filters out false positives post-index.
The two canonical uses on the wiki:
- Hash-column equality with collision safety — see
concepts/redundant-condition-query-hint for the
url_md5 = x AND url = 'x'pattern where the MD5 predicate steers the index and the URL predicate catches collisions. - Function-wrapped multi-column predicates — see
patterns/de-obfuscate-via-redundant-condition for the
ADDTIME(due_date, due_time) ... AND due_date BETWEEN ...pattern.
Seen in¶
-
sources/2026-04-21-planetscale-why-isnt-mysql-using-my-index — Aaron Francis's 2023-05-04 diagnostic post names obfuscation as reason #6 in his eight-reason taxonomy of why MySQL ignores an index. The worked example is
SELECT * FROM people WHERE YEAR(created_at) = 2023;with the canonical folk-framing: "You've hidden the indexed column, and MySQL cannot see it." Classifies obfuscation alongside leading wildcard and type/charset mismatch as the three structural non-sargable classes (plus invisible-index as the MySQL-8-specific fourth). This 2023-05-04 post is Francis's diagnostic framing — the 2023-06-07 companion post (Using redundant conditions) is his mechanism framing — read together they bracket the detect / de-obfuscate loop. -
sources/2026-04-21-planetscale-using-redundant-conditions-to-unlock-indexes-in-mysql — canonical wiki introduction. Aaron Francis names the pattern as "obfuscation" and walks
YEAR(created_at)andADDTIME(due_date, due_time)as the two teaching examples. - sources/2026-04-21-planetscale-generated-hash-columns-in-mysql — same author, 2-year-later companion; uses obfuscation to motivate generated hash columns for equality-lookup on long unindexable columns.
- systems/mysql — the planner substrate.
- systems/innodb — the B+tree-based index engine whose sargable path is being hidden.
Related¶
- concepts/redundant-condition-query-hint
- concepts/domain-knowledge-redundant-condition
- concepts/secondary-index
- concepts/mysql-explain
- concepts/mysql-access-type
- concepts/functional-index-mysql
- concepts/generated-hash-column
- patterns/de-obfuscate-via-redundant-condition
- patterns/generated-hash-column-for-equality-lookup
- systems/mysql
- systems/innodb