Things You Didn't Know About Indexes
- title
- Things You Didn't Know About Indexes
- type
- summary
- summary
- Common index pitfalls (composite order, functions) and lesser-known types (partial, covering, functional)
- tags
- databases, postgresql, performance
- created
- 2026-04-15
- updated
- 2026-07-22
A practical guide to database indexes using a PokΓ©mon table as the running example. Covers the trade-offs most tutorials skip and three index types that solve specific problems.
The fundamental trade-off
Reads get faster, writes get slower.
Every INSERT, UPDATE, or DELETE must update all indexes on the table. A table with eight indexes has nine structures to maintain. The query planner also weighs more options with more indexes β planning time can exceed execution time on fast lookups.
Why your index isn't working
Composite indexes care about order
An index on (type_1, type_2) creates a structure sorted first by type_1, then by type_2 within each group:
Bug β Flying β [Butterfree, ...]
β Poison β [Venonat, ...]
Electric β Flying β [Zapdos, ...]
Fire β Flying β [Charizard, ...]
Water β Flying β [Wingull, ...]
This helps queries on type_1 alone, or type_1 AND type_2 together. But type_2 alone? The Flying entries are scattered under Bug, Electric, Fire, Water β no single place to jump to. The index won't help.
If you query on type_2 alone as often as type_1, create a second index.
Functions defeat your index
SELECT * FROM pokemon WHERE lower(name) = 'pikachu';
The index is on name, not lower(name). As far as the database is concerned, those are different things. It falls back to a full table scan, lowercasing each name as it goes.
This applies to any function wrapping the column. Implicit type conversions count too β comparing text against integer triggers a silent conversion with the same effect.
Don't guess, measure
EXPLAIN SELECT * FROM pokemon WHERE name = 'Pikachu';
Index Scan = using your index. Seq Scan = reading every row. Use EXPLAIN ANALYZE to run the query and get actual timings, not just planner estimates.
Lesser-known index types
Functional indexes
Index the expression instead of the column:
CREATE INDEX ON pokemon (lower(name));
Now WHERE lower(name) = 'pikachu' uses the index. Works with any deterministic, immutable function.
Caveat: if you're indexing lower(name) constantly, ask why you haven't stored names in lowercase.
Partial indexes
Index only rows matching a condition:
CREATE INDEX ON pokemon (name) WHERE is_legendary = true;
80 entries instead of 1000. Smaller, faster to query, cheaper to maintain. Queries that don't match the condition (WHERE is_legendary = false) fall back to a table scan, which is fine β they match most rows anyway.
Good for soft-delete patterns:
CREATE INDEX ON users (email) WHERE deleted_at IS NULL;
Covering indexes
When the index contains every column the query needs, the database can answer from the index alone β no trip to the table. EXPLAIN shows this as Index Only Scan.
Use INCLUDE to add columns without sorting by them:
CREATE INDEX ON pokemon (name) INCLUDE (base_attack);
Now this query never touches the table:
SELECT name, base_attack FROM pokemon WHERE name = 'Pikachu';
Why not put base_attack in the indexed columns? Because the database would sort by it on every write β work with no benefit when you only search by name.
Further reading
Use The Index, Luke β comprehensive guide to SQL indexing.
database-design-and-implementation β Sciore's book builds the machinery this page describes from the outside: B-tree index, buffer pool, lock manager, planner, one chapter at a time. The fastest cure for treating EXPLAIN output as an oracle is having written the planner that produces it.
readings-in-database-systems β the Red Book, free online. Where to go when the practical questions run out and you want the field: annotated papers on storage, query optimization, and transactions, with the editors arguing about what mattered.
See also inverted-index for full-text search indexes, lsm-tree for write-optimized storage where index maintenance is particularly expensive.