Indexes & Performance
Speed up queries with smart indexing.
Why Indexes Matter
An index is like a book's index, it helps you find information quickly without reading every page. Without indexes, databases must scan every row to find what you're looking for (called a "full table scan"). With proper indexes, queries that took seconds can run in milliseconds. But indexes aren't free, they use storage and slow down writes. Learning when and how to use them is critical for building fast applications.
The Problem: Slow Queries
Imagine searching for a user by email in a table with 1 million users.
Without Index: Full Table Scan
SELECT * FROM users WHERE email = 'alice@example.com';
Expected Output:
Database must: 1. Read row 1, check email ❌ 2. Read row 2, check email ❌ 3. Read row 3, check email ❌ ... 999,999. Read row 999,999, check email ❌ 1,000,000. Read row 1,000,000, check email ✓ Found! Time: ~2-3 seconds (1 million row scans)
With Index: Direct Lookup
Database uses index: 1. Look up 'alice@example.com' in index ✓ Found at row 547,823 2. Read row 547,823 directly Time: ~1-5 milliseconds (index lookup + 1 row read)
Creating Indexes
Indexes are created with CREATE INDEX statements.
Basic Index
Index a single column for faster lookups.
CREATE INDEX idx_users_email ON users(email);
Unique Index
Ensures no duplicate values (like UNIQUE constraint).
CREATE UNIQUE INDEX idx_users_email_unique ON users(email);
Multi-Column Index (Composite)
Index multiple columns together for specific query patterns.
CREATE INDEX idx_orders_customer_date ON orders(customer_id, order_date);
Speeds up queries filtering by customer_id, or both customer_id AND order_date
Dropping Indexes
Remove unused indexes to save space and improve write performance.
DROP INDEX idx_users_email;
Automatic Indexes
Some indexes are created automatically by the database.
- PRIMARY KEY: Always indexed automatically
- UNIQUE constraints: Create indexes automatically
CREATE TABLE users (
user_id INTEGER PRIMARY KEY, -- Automatically indexed
email VARCHAR(255) UNIQUE -- Automatically indexed
);No need to manually index these columns
⚠️ NOT Automatically Indexed
- Foreign keys: In PostgreSQL and Oracle you must index these yourself. (MySQL/InnoDB is the exception: it creates one automatically.)
- Regular columns: Only indexed if you create them
When to Create Indexes
Not every column needs an index. Index strategically.
✅ Good Candidates for Indexes
- WHERE clauses: Columns frequently filtered
- JOIN conditions: Foreign key columns
- ORDER BY: Columns used for sorting
- High cardinality: Columns with many unique values
Example: Index WHERE Columns
-- Frequent query SELECT * FROM orders WHERE customer_id = 123; -- Create index CREATE INDEX idx_orders_customer ON orders(customer_id);
Now lookups by customer_id are fast
Example: Index JOIN Columns
-- Frequent join SELECT * FROM orders o JOIN customers c ON o.customer_id = c.customer_id; -- Index the foreign key CREATE INDEX idx_orders_customer_id ON orders(customer_id);
Dramatically speeds up joins
When NOT to Create Indexes
❌ Bad Candidates for Indexes
- Small tables: Tables with <1000 rows don't benefit
- Low cardinality AND evenly spread: a boolean that is 50/50 is not worth indexing. A boolean that is 99/1 usually is, via a partial index
- Frequently updated: Indexes slow down INSERT/UPDATE/DELETE
- Never queried: Don't index columns you never search
Example: Bad Index (Low Cardinality)
-- Usually pointless: only 2 values, roughly 50/50. -- Matching half the table is cheaper via sequential scan than via -- an index plus a random heap fetch for every hit, and the planner -- knows it, so it will ignore this index anyway. CREATE INDEX idx_users_is_active ON users(is_active); -- The exception: heavily SKEWED values. If 99% of rows are -- status='archived' and you always query the other 1%, a PARTIAL -- index on just those rows is small, cheap to maintain, and will -- absolutely be used. CREATE INDEX idx_users_pending ON users(created_at) WHERE status = 'pending';
With an even split the planner skips the first index entirely. Skew is what makes an index on a low-cardinality column worthwhile, and a partial index is how you exploit it.
Composite Indexes: Order Matters
Multi-column indexes have a specific column order that affects which queries can use them.
Index Column Order
CREATE INDEX idx_orders_customer_date_status ON orders(customer_id, order_date, status);
This Index Can Speed Up:
✅ WHERE customer_id = ?
✅ WHERE customer_id = ? AND order_date = ?
✅ WHERE customer_id = ? AND order_date = ? AND status = ?
✅ WHERE customer_id = ? AND status = ? (partial use)
❌ WHERE order_date = ? (skips first column)
❌ WHERE status = ? (skips first columns)
Covering Indexes: Maximum Speed
A covering index contains all columns needed by a query, database doesn't need to access the table at all.
Non-Covering Index
CREATE INDEX idx_users_email ON users(email); SELECT email, name, age FROM users WHERE email = 'alice@example.com';
Expected Output:
Process: 1. Use index to find row 2. Read table to get name and age (2 operations)
Covering Index
-- Works, but name and age become part of the B-tree's sort key -- even though you never search or sort by them. CREATE INDEX idx_users_email_name_age ON users(email, name, age); -- Better in PostgreSQL: INCLUDE carries the extra columns as payload -- in the leaf nodes only. Smaller index, and it still enables an -- index-only scan. CREATE INDEX idx_users_email_covering ON users(email) INCLUDE (name, age); SELECT email, name, age FROM users WHERE email = 'alice@example.com';
Expected Output:
Process: 1. Read from index only (has all columns) (1 operation - faster!)
Trade-off: Larger index, but potentially 2x faster queries
Types of Indexes
B-Tree Index (Default)
Most common. Good for equality and range queries.
How a B-Tree Finds a Row (searching for age = 80)
Figure 1: The database compares 80 at each level and follows one branch, reaching the row in a handful of hops (O(log n)) instead of scanning every row (O(n)). Leaf nodes point to the actual table rows.
CREATE INDEX idx_users_age ON users(age); -- A B-tree keeps values in sorted order, so it serves both -- equality and range predicates, and can supply ORDER BY for free: -- WHERE age = 25 -- WHERE age > 18 -- WHERE age BETWEEN 20 AND 30 -- ORDER BY age SELECT count(*) FROM users WHERE age BETWEEN 20 AND 30;
Hash Index
Fast equality lookups only. No range queries or sorting.
CREATE INDEX idx_users_email_hash ON users USING HASH (email); -- A hash index stores only hashes, so equality is all it can do: -- WHERE email = 'alice@example.com' <- uses the index -- WHERE email LIKE 'alice%' <- cannot: no ordering -- ORDER BY email <- cannot: no ordering -- A B-tree handles all three, which is why it stays the default. SELECT count(*) FROM users WHERE email = 'alice@example.com';
Partial Index
Index only specific rows (PostgreSQL feature).
CREATE INDEX idx_active_users ON users(email) WHERE is_active = true;
Smaller index, faster queries for active users only
Full-Text Index
For searching text content.
-- PostgreSQL: a GIN index over a tsvector
CREATE INDEX idx_articles_fts ON articles
USING GIN (to_tsvector('english', title || ' ' || content));
SELECT * FROM articles
WHERE to_tsvector('english', title || ' ' || content)
@@ websearch_to_tsquery('english', 'database performance');
-- MySQL: FULLTEXT index + MATCH ... AGAINST
CREATE FULLTEXT INDEX idx_articles_content
ON articles(title, content);
SELECT * FROM articles
WHERE MATCH(title, content) AGAINST('database performance');EXPLAIN: Analyzing Query Performance
EXPLAIN shows how the database will execute your query and whether it uses indexes.
Using EXPLAIN
-- EXPLAIN alone shows the plan. EXPLAIN ANALYZE actually runs the -- query and reports real timings, which is what you usually want. EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'alice@example.com';
Expected Output:
Without an index (real output, PostgreSQL 16, 1,000,000 rows):
Gather (cost=1000.00..15554.43 rows=1 width=42)
(actual time=16.655..18.678 rows=1 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Parallel Seq Scan on users (cost=0.00..14554.33 rows=1 width=42)
(actual time=13.640..13.640 rows=0 loops=3)
Filter: (email = 'alice@example.com'::text)
Rows Removed by Filter: 333333
Planning Time: 0.160 ms
Execution Time: 18.688 ms
After CREATE INDEX idx_users_email ON users(email):
Index Scan using idx_users_email on users
(cost=0.42..8.44 rows=1 width=42)
(actual time=0.018..0.019 rows=1 loops=1)
Index Cond: (email = 'alice@example.com'::text)
Planning Time: 0.159 ms
Execution Time: 0.031 ms
18.688 ms -> 0.031 ms, about 600x. Note "Rows Removed by Filter:
333333" in the first plan: each of the 3 parallel workers threw away
a third of the table to find one row.Look For
- Seq Scan: reads the whole table. Bad when you expected few rows; perfectly correct when you are reading most of the table anyway
- Index Scan: Good - using an index
- High cost numbers: Expensive query
- High row estimates: Too many rows scanned
Index Maintenance
Rebuild Fragmented Indexes
Over time, indexes become fragmented and slow. Rebuild periodically.
-- PostgreSQL: plain REINDEX takes a lock that blocks writes REINDEX INDEX idx_users_email; -- PostgreSQL 12+: rebuild without blocking writes (do this in production) REINDEX INDEX CONCURRENTLY idx_users_email; -- MySQL ALTER TABLE users DROP INDEX idx_users_email; CREATE INDEX idx_users_email ON users(email);
Monitor Index Usage
Remove unused indexes to save space and improve write speed.
-- PostgreSQL: Find unused indexes SELECT schemaname, relname, indexrelname FROM pg_stat_user_indexes WHERE idx_scan = 0;
Indexing Best Practices
✅ Always Index Foreign Keys
Foreign key columns are used in JOINs constantly. Always index them.
✅ Index WHERE Clause Columns
Columns frequently used in WHERE conditions should be indexed.
✅ Use Composite Wisely
Put most selective column first in composite indexes. Column order matters!
✅ Don't Over-Index
Every index slows down writes. Only create indexes you actually need.
✅ Test in Production-Like Data
Index performance differs vastly between 100 rows and 1 million rows. Test with realistic data volumes.
✅ Monitor Query Performance
Use EXPLAIN to verify indexes are being used. Measure before and after.
Key Takeaways
- Indexes dramatically speed up reads - but slow down writes
- Always index foreign keys - used constantly in JOINs
- Index WHERE clause columns - columns you filter on frequently
- Primary keys auto-indexed - don't create duplicate indexes
- Composite index order matters - most selective column first
- Don't over-index - every index costs storage and write speed
- Use EXPLAIN - verify your indexes are actually being used
- Low cardinality needs skew - an evenly split boolean is a wasted index; a lopsided one deserves a partial index
- Indexes are the #1 way to improve database performance, learn to use them strategically and your applications will scale effortlessly