Database indexes are the single most impactful tool for query performance optimization. A poorly indexed database can turn a query that should complete in milliseconds into one that takes minutes. But over-indexing creates write performance penalties and storage overhead. This guide teaches you when, how, and what to index — with practical examples in PostgreSQL and MySQL.
How Indexes Work (Conceptually)
Without an index, a database must perform a full table scan — examining every row to find matches. With a B-tree index (the most common type), the database maintains a sorted tree structure that allows it to find matching rows in O(log n) time instead of O(n). For a table with 10 million rows, that's the difference between examining 10 million rows and examining ~23.
The trade-off: indexes must be updated every time data changes (INSERT, UPDATE, DELETE). This adds write overhead and storage cost. The goal is to index columns that are frequently read and infrequently written.
Types of Database Indexes
B-Tree Index
The most common and versatile index type. Works for =, <, >, BETWEEN, IN, and prefix string matching. PostgreSQL and MySQL both use B-tree by default when you CREATE INDEX.
Hash Index
Faster than B-tree for exact equality lookups but useless for range queries or ordering. Rarely used because B-tree is nearly as fast for equality and handles range queries too.
GIN Index (PostgreSQL)
Generalized Inverted Index — essential for JSONB queries, full-text search (tsvector), and array containment queries. Significantly faster than B-tree for these use cases.
GiST Index (PostgreSQL)
Generalized Search Tree — used for PostGIS geographic queries, range types, and full-text search. More versatile than GIN but slower for pure text search.
Partial Index
Index only the rows matching a WHERE condition. Example: index only active users, not deleted ones. Dramatically reduces index size and improves read/write performance for the filtered set.
Composite Index
Index on multiple columns. A composite index (a, b) can satisfy queries on (a), (a, b), but NOT on (b) alone. Place the highest-cardinality column or most-filtered column first.
Covering Index
An index that includes all columns needed by a query — the database never needs to hit the main table. PostgreSQL supports INCLUDE columns in indexes (pg 11+). Extremely fast for read-heavy workloads.
Full-Text Index
MySQL FULLTEXT or PostgreSQL tsvector+tsquery. Far better than LIKE '%term%' for text search. For serious full-text search, consider Elasticsearch or PostgreSQL's built-in FTS capabilities.
Practical Indexing Examples
Basic Index Creation
Finding Missing Indexes with EXPLAIN ANALYZE
Finding Unused Indexes (PostgreSQL)
The Index Cardinality Rule
High-cardinality columns (many unique values) benefit most from indexes. A boolean column (true/false) has low cardinality — an index on it is nearly useless since the database will need to read ~50% of rows anyway. A UUID or email column has very high cardinality — indexes shine here.
Over-Indexing Warning
Every index adds overhead to INSERT, UPDATE, and DELETE operations. A table with 15 indexes on a write-heavy workload can perform 3–5x slower on writes than a properly indexed table with 3–5 indexes. Regularly audit your indexes and drop any that aren't being used (see the unused indexes query above). In PostgreSQL, you can use pg_stat_user_indexes to track index usage since the last stats reset.