Index Theory

What a B-tree index actually trades away to make lookups fast, why a function wrapped around the indexed column silently disables it, and why having an index does not guarantee the optimizer will use it.

Reading Query Execution Plans mentioned an Index Scan node without explaining what makes it fast, or why it is not always the plan the optimizer chooses even when an index exists. This article covers both.

What an index actually is#

A database index is a second data structure, stored alongside the table, holding the indexed column's values in sorted order along with a pointer back to each row. Without one, finding a specific value means scanning every row; with one, finding it means a search through a structure built specifically to make that fast.

Why a B-tree specifically#

The overwhelmingly common structure is a B-tree: a balanced, sorted tree where every lookup takes roughly the same, small number of steps regardless of table size — logarithmic, not linear, in the row count:

Text
                [50]              /      \         [20, 35]    [70, 90]        /   |   \    /   |   \      ...  ...  ... ...  ...  ...

Finding the row with value 72 means comparing against a handful of nodes on the way down — a few dozen comparisons even across a table with millions of rows — rather than checking every row one at a time. This is also why a B-tree suits range queries (WHERE amount BETWEEN 100 AND 200) well: once the starting point is found, the sorted structure makes scanning forward from it cheap.

An index is not free: the write-side cost#

Every INSERT, UPDATE, or DELETE has to update every index on that table, not just the underlying rows — an index makes reads faster at a real, ongoing cost to every write. A table with ten indexes pays that maintenance cost ten times over on every single insert, which is why indexing every column "just in case" is a genuine trade-off, not a free optimization.

Selectivity: why having an index doesn't guarantee it gets used#

Selectivity is the fraction of rows a given value actually matches. A status column with only two values, active and inactive, split roughly 50/50, is not very selective — a query for WHERE status = 'active' would still need to fetch roughly half the table, and randomly accessing half a table's rows through an index is often slower than simply scanning the whole thing sequentially:

Text
High selectivity (order_id = 4021)  -> index finds ~1 row -> index scan is clearly worth it
Low selectivity (status = 'active', matches 50% of rows)  -> index would still need to fetch half the table via     random access -> optimizer often prefers a sequential scan instead

An unused index is not necessarily a bug

Seeing a Seq Scan in a plan even though an index exists on the filtered column is often the optimizer correctly judging that column's selectivity too low for the index to help — not a sign anything is broken. Checking selectivity before assuming an index "should" have been used avoids chasing a problem that is not actually there.

Function-wrapped columns break index usage the same way they break pushdown#

Predicate and Projection Pushdown covered a filter like WHERE UPPER(status) = 'SHIPPED' losing its ability to use a Parquet file's stored statistics, because those statistics describe the raw column, not the transformed value. A standard B-tree index has the identical limitation: it is built on the raw column's values, so WHERE UPPER(status) = 'SHIPPED' cannot use a plain index on status either — the same underlying reason, showing up in a second, unrelated part of the stack.

Composite indexes and column order#

A multicolumn index on (customer_id, order_date) can efficiently serve a filter on customer_id alone, or on customer_id and order_date together — but generally cannot efficiently serve a filter on order_date alone, because the index is sorted by customer_id first. This leftmost-prefix rule means column order in a composite index is a real design decision, not an arbitrary one — put the column most queries filter on first.

Common mistakes#

Indexing every column "just in case"#

Each additional index is ongoing write cost paid whether or not it is ever used to speed up a read. Index the columns actual queries filter or join on, not every column that theoretically could be.

Wrapping the filtered column in a function#

WHERE UPPER(status) = 'SHIPPED' disables a plain index on status the same way it disables Parquet's statistics-based pushdown — filter on the raw column, or build an expression index specifically for that transformed value if the function call cannot be avoided.

Assuming a missing index scan means something is broken#

Low selectivity is frequently the correct reason an index goes unused. Check the actual selectivity of the filtered column before assuming the optimizer made a mistake.

Further reading#

See also#