You create an index on a column, but the database still reads the whole table for a query that filters on that column. What are the reasons an optimizer skips a usable index, and how would you narrow down which one applies?
answer
- unusable vs rejected
- non-sargable: function, cast, leading wildcard
- estimated vs actual rows
- selectivity crossover, tiny table
- stats before hints
basics
~20 sEither the index cannot be used (a function or cast wraps the column, a leading wildcard, wrong leading column) or the optimizer decided it is not worth it (the filter matches a large fraction of rows, the table is tiny, or row estimates are wrong from stale statistics). Read the actual plan.
solid answer
~50 sI split it into two families. **Unusable**: the predicate cannot be matched against sorted keys - a function or implicit cast applied to the column, a leading wildcard in a pattern match, a negation, or the column is not the leading column of a composite index. **Usable but rejected**: the optimizer costed both access paths and the scan won - the filter matches a large fraction of rows so each match costs a random page fetch, the table is only a few pages, or the row estimate is badly wrong because statistics are stale, sampled thinly, or the value falls outside the histogram after a bulk load. Diagnosis: get the plan with actual row counts. If estimated and actual rows differ by orders of magnitude, it is an estimation problem, so refresh statistics. If they agree, the optimizer is probably right and I question the query instead. In parallel I read the plan filter text for casts and function calls.
go deeper
Name the everyday causes: a function or cast on the column, a leading wildcard, and the fact that the optimizer may simply find a scan cheaper. Say you would look at the execution plan.
Structure the answer as unusable versus rejected, explain why a sorted B+Tree cannot answer a transformed predicate, and mention selectivity and table size as cost factors.
Lead with the diagnostic loop - actual versus estimated rows, plan filter text, statistics freshness - and connect misestimates to downstream join-method choices.
Frame it as an estimation-quality problem: which cost-model constants match your storage, how statistics maintenance is scheduled at your data volume, and when plan stability tooling is worth the maintenance debt.
## Two distinct failure modes When a plan shows a full table scan despite a matching index, exactly one of two things happened. 1. The index was **unusable** for that predicate: the engine could not turn the filter into a contiguous range of index keys. 2. The index was **usable but rejected**: the optimizer costed both access paths and judged the scan cheaper. Separating them is the whole diagnosis, because the fixes are opposite. Family one is fixed by reshaping the predicate or the index; family two is fixed by statistics, data distribution, or accepting the plan. ## Family one: the predicate is not searchable A B+Tree stores keys sorted by the column's own value. Any index access must reduce to *find the first key >= X, walk forward until > Y*. Predicates that break that shape are commonly called non-sargable: - **A function wraps the column**, for example lower(email) = '[email protected]' or price * 1.2 > 100. The index holds email, not lower(email); the sort order of raw values tells the engine nothing about the order of transformed values. - **An implicit cast lands on the column side.** Comparing an indexed text column to a number (or vice versa in some engines) makes the engine convert every row's value, which is a function call by another name. If the cast lands on the literal instead, the index still works. Which side gets converted is a per-engine type-precedence rule, so a comparison that is fine in one product silently disables the index in another. - **A leading wildcard** in a pattern match has no known prefix, so there is no entry point into the sorted keys. A trailing wildcard does have one. - **The column is not the leading column** of a composite index, so there is no single start point in key order. - **Negations** (not equal, NOT IN) describe everything except a narrow range, which almost always costs more than reading the table. ## Family two: usable, but costed away Optimizers are cost based. They estimate how many rows a predicate returns, translate that into page reads plus CPU, and pick the cheapest plan. An index loses when: - **Selectivity is poor.** Each matched row that is not already in the index costs a lookup into the table, typically a random page fetch. Once matches exceed a few percent of the table, the scan's cheap sequential reads win. - **The table is tiny.** A three-page table is read entirely for three I/Os; descending the index costs at least root plus leaf plus the row fetch. - **The estimate is wrong.** Stale, missing, or thinly sampled statistics; a value outside the histogram range after a bulk load; correlated predicates whose selectivities the optimizer multiplies as if independent. A ten-thousand-fold underestimate makes a nested-loop-with-index plan look free, and a large overestimate makes it look ruinous. - **Cost-model constants mismodel the hardware.** The assumed price of random versus sequential I/O and the assumed cache hit ratio are configuration. Defaults tuned for spinning disks systematically over-price index access on NVMe. ## A diagnosis workflow 1. Capture the plan with **actual** row counts and timings, not the estimate alone. 2. Compare estimated to actual rows at the filter. Orders of magnitude apart means an estimation problem; close together means the optimizer chose deliberately. 3. Read the predicate as the plan prints it. Engines usually show the cast or function they inserted, which exposes family one instantly. 4. Check table size and the fraction of rows matched. 5. Refresh statistics and re-plan. If the plan flips, you had a statistics problem, not an indexing problem. 6. Only then change the schema: an expression index for the wrapped column, a partial index for a narrow hot subset, or adding the projected column so the lookup disappears. 7. A hint is the last resort, not the first. ## What not to conclude "The index must be corrupt, rebuild it" is rarely correct. A rebuild often appears to fix the problem only because it refreshes statistics as a side effect. Equally, a full scan is not automatically a defect. For a query returning a third of a table, or for a lookup table of two hundred rows, the scan is the right plan and forcing the index makes it slower.
- The plan estimated 12 rows from the index filter but 400,000 rows actually came back. What does that tell you?The optimizer is working from a bad cardinality estimate, so every downstream choice is suspect - it likely picked nested loops with per-row lookups that now run 400,000 times. Causes are stale or unsampled statistics, a value outside the histogram after a bulk load, or correlated predicates whose selectivities were multiplied as if independent. Refreshing statistics is the first move; multi-column or expression statistics are the next.
- The same query is fast with a literal value and slow with a bind parameter. Why might that be?With a literal the optimizer can look the exact value up in the histogram; with a parameter it may plan for an unknown or for whichever value it first saw. That produces either a generic average-selectivity plan or a plan tuned to an unrepresentative value, and a skewed column then gets the wrong access path. Remedies include re-planning per execution for that statement, splitting the hot value into its own query, or partial indexes on the skewed values.
saying these in an interview costs you the question
- Assuming that creating an index guarantees the optimizer will use it
- Treating any full table scan as a bug rather than sometimes the cheapest plan
- Jumping straight to rebuilding or hinting the index before looking at row estimates
- Not knowing that a function or implicit cast on the indexed column disables the index
- Claiming statistics are irrelevant because the query text did not change