Indexing is the single most impactful skill for database performance. A well-placed index can turn a query from minutes to milliseconds. This module gives you deep understanding of how indexes work and when to use them.
Estimated Time: 12-15 hours Hands-On: Index analysis labs with real query plans Key Skill: Knowing exactly which index to create for any query
-- Without index: scans 10 million rowsSELECT * FROM users WHERE email = 'john@example.com';-- Time: 5-10 seconds-- With index: jumps directly to the row-- Time: 1-5 milliseconds
Copy
┌─────────────────────────────────────────────────────────────────────────┐│ FULL TABLE SCAN vs INDEX LOOKUP │├─────────────────────────────────────────────────────────────────────────┤│ ││ SEQUENTIAL SCAN (No Index) B-TREE INDEX LOOKUP ││ ─────────────────────────── ───────────────── ││ ││ ┌─────┬─────┬─────┬─────┐ ┌─────────────────┐ ││ │Row 1│Row 2│Row 3│ ... │ │ Root Node │ ││ └──┬──┴──┬──┴──┬──┴─────┘ │ [M values] │ ││ │ │ │ └────────┬────────┘ ││ ▼ ▼ ▼ │ ││ Check Check Check ┌───────┴───────┐ ││ each each each ▼ ▼ ││ row row row ┌─────────┐ ┌─────────┐ ││ │ │ │ │ Branch │ │ Branch │ ││ ▼ ▼ ▼ └────┬────┘ └────┬────┘ ││ 10 million comparisons │ │ ││ ▼ ▼ ││ O(n) - Linear time ┌──────────┐ ┌──────────┐ ││ │ Leaf │ │ Leaf │ ││ │ (Target!)│ │ │ ││ └──────────┘ └──────────┘ ││ ││ O(log n) - Logarithmic time ││ ~25 comparisons for 10M rows ││ │└─────────────────────────────────────────────────────────────────────────┘
What is a B-Tree? (Before the technical details)A B-Tree is like a multi-level directory structure:Real-World Analogy: Library OrganizationThink of a library:
Top level (Root): “Books A-H” go left, “Books I-P” go middle, “Books Q-Z” go right
Middle level (Branches): “Books A-C” go left, “Books D-F” go middle, “Books G-H” go right
Bottom level (Leaves): Actual books sorted alphabetically
To find “Database Design” book:
Root: “D” is in “A-H” section → Go left
Branch: “D” is in “D-F” section → Go middle
Leaf: Find “Database Design” → Done!
In Databases:
Root node: Contains ranges that point to branch nodes
Branch nodes: Contain ranges that point to leaf nodes
Leaf nodes: Contain actual values with pointers to table rows
Why B-Tree?
Balanced: All paths from root to leaves are the same length (guaranteed fast lookups)
Sorted: Values are in order (enables range queries like “find all prices between 10and20”)
Wide: Each node can hold many values (reduces tree height, fewer disk reads)
Visual Example (Simplified):Imagine indexing user IDs from 1 to 100:
Copy
[Root: 50] / \ [Branch: 25] [Branch: 75] / \ / \ [1-12] [13-24] [26-37] [38-49] [51-62] [63-74] [76-87] [88-100] ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ Actual user rows with those IDs
To find user ID 23:
Root: 23 < 50 → Go left
Branch: 23 > 25? No, 23 is in left branch → Go to [13-24] leaf
Leaf: Find 23 → Get pointer to actual row
Total: 3 steps (instead of checking all 100 rows!)
-- B-Tree supports these operators:-- <, <=, =, >=, >, BETWEEN, IN, IS NULL, IS NOT NULL-- Equality lookup: O(log n)SELECT * FROM users WHERE email = 'john@example.com';-- Range scan: O(log n + k) where k is result sizeSELECT * FROM orders WHERE created_at > '2024-01-01';-- Sorting (if index order matches): No extra sort needed!SELECT * FROM products ORDER BY price DESC LIMIT 10;-- PREFIX pattern matching (starts with)SELECT * FROM users WHERE name LIKE 'John%'; -- Uses index!SELECT * FROM users WHERE name LIKE '%John'; -- Cannot use index!
-- Single column indexCREATE INDEX idx_users_email ON users(email);-- Unique index (also enforces uniqueness)CREATE UNIQUE INDEX idx_users_email_unique ON users(email);-- Multi-column (composite) indexCREATE INDEX idx_orders_user_date ON orders(user_id, created_at DESC);-- Index with included columns (covering index)CREATE INDEX idx_orders_user_covering ON orders(user_id) INCLUDE (status, total);-- Partial index (index only some rows)CREATE INDEX idx_orders_pending ON orders(created_at)WHERE status = 'pending';-- Expression index (index on computed value)CREATE INDEX idx_users_lower_email ON users(lower(email));-- Concurrent creation (doesn't lock table)CREATE INDEX CONCURRENTLY idx_products_name ON products(name);
-- Drop indexDROP INDEX idx_users_email;-- Drop concurrently (doesn't lock)DROP INDEX CONCURRENTLY idx_users_email;-- Rebuild index (useful after lots of updates/deletes)REINDEX INDEX idx_users_email;-- Rebuild concurrently (PostgreSQL 12+)REINDEX INDEX CONCURRENTLY idx_users_email;-- Rebuild all indexes on a tableREINDEX TABLE users;
CREATE INDEX idx_orders_user_status_date ON orders(user_id, status, created_at);
This index supports queries on:
✅ (user_id) - First column
✅ (user_id, status) - First two columns
✅ (user_id, status, created_at) - All columns
❌ (status) - Not leftmost column!
❌ (status, created_at) - Not leftmost!
❌ (user_id, created_at) - Skips status
Copy
┌─────────────────────────────────────────────────────────────────────────┐│ COMPOSITE INDEX STRUCTURE │├─────────────────────────────────────────────────────────────────────────┤│ │```sqlCREATE INDEX idx_sessions_token ON sessions USING hash(token);-- Only supports =SELECT * FROM sessions WHERE token = 'abc123xyz'; -- Fast!SELECT * FROM sessions WHERE token > 'abc'; -- Cannot use!
Hash indexes are rarely needed. B-Tree handles equality almost as fast and is more versatile. Use Hash only when you’re certain you’ll never need range queries.
Best for: Array containment, JSONB, full-text search
Copy
-- Array operationsCREATE INDEX idx_posts_tags ON posts USING gin(tags);SELECT * FROM posts WHERE tags @> ARRAY['postgresql'];SELECT * FROM posts WHERE tags && ARRAY['sql', 'nosql']; -- Overlap-- JSONB operationsCREATE INDEX idx_products_attrs ON products USING gin(attributes);SELECT * FROM products WHERE attributes @> '{"color": "red"}';SELECT * FROM products WHERE attributes ? 'size';-- Full-text searchCREATE INDEX idx_articles_search ON articles USING gin(to_tsvector('english', title || ' ' || body));SELECT * FROM articles WHERE to_tsvector('english', title || ' ' || body) @@ to_tsquery('english', 'postgresql & performance');
Best for: Geometric data, ranges, full-text with phrases
Copy
-- Range typesCREATE INDEX idx_events_duration ON events USING gist(during);SELECT * FROM events WHERE during && '[2024-01-01, 2024-01-31]'::daterange; -- Overlaps-- Geometric (with PostGIS)CREATE INDEX idx_locations_point ON locations USING gist(geom);SELECT * FROM locations WHERE ST_DWithin(geom, ST_MakePoint(-73.99, 40.73)::geography, 1000);-- Full-text with phrase searchCREATE INDEX idx_docs_search ON documents USING gist(to_tsvector('english', content));
Best for: Very large tables with naturally ordered data (time series)
Copy
-- Perfect for append-only time series dataCREATE INDEX idx_logs_time ON logs USING brin(created_at);-- Much smaller than B-Tree: stores min/max per block range-- Table: 100GB → B-Tree: 2GB, BRIN: 2MB
Copy
┌─────────────────────────────────────────────────────────────────────────┐│ BRIN INDEX STRUCTURE │├─────────────────────────────────────────────────────────────────────────┤│ ││ Table blocks (pages): ││ ┌─────────────┬─────────────┬─────────────┬─────────────┐ ││ │ Block 0-127 │ Block 128-255│ Block 256-383│ Block 384+ │ ││ │ Jan 1-7 │ Jan 8-14 │ Jan 15-21 │ Jan 22+ │ ││ └─────────────┴─────────────┴─────────────┴─────────────┘ ││ ││ BRIN Index (tiny!): ││ ┌───────────────────────────────────────────────────────┐ ││ │ Block Range │ Min Date │ Max Date │ ││ ├──────────────┼─────────────┼──────────────────────────┤ ││ │ 0-127 │ Jan 1 │ Jan 7 │ ││ │ 128-255 │ Jan 8 │ Jan 14 │ ││ │ 256-383 │ Jan 15 │ Jan 21 │ ││ │ 384-511 │ Jan 22 │ Jan 28 │ ││ └──────────────┴─────────────┴──────────────────────────┘ ││ ││ Query: WHERE created_at = 'Jan 10' ││ → Check BRIN: Jan 10 could be in blocks 128-255 ││ → Scan only those blocks (not entire table!) ││ ││ Requirements: ││ • Data must be physically clustered by index column ││ • Works best for append-only tables ││ • Loses effectiveness if data is scattered ││ │└─────────────────────────────────────────────────────────────────────────┘
-- Only index active users (most queries filter for active)CREATE INDEX idx_users_email_active ON users(email)WHERE deleted_at IS NULL;-- Only index pending orders (statuses you filter often)CREATE INDEX idx_orders_pending ON orders(created_at)WHERE status = 'pending';-- Index high-value ordersCREATE INDEX idx_orders_high_value ON orders(user_id, created_at)WHERE total > 1000;-- Size comparison-- Full index: 500MB-- Partial index: 50MB (if only 10% of rows match condition)
-- Case-insensitive email lookupCREATE INDEX idx_users_lower_email ON users(lower(email));-- Now this uses the index:SELECT * FROM users WHERE lower(email) = 'john@example.com';-- Extract year for date queriesCREATE INDEX idx_orders_year ON orders(EXTRACT(YEAR FROM created_at));-- JSONB field extractionCREATE INDEX idx_products_brand ON products((attributes->>'brand'));SELECT * FROM products WHERE attributes->>'brand' = 'Apple';
Include extra columns to avoid table lookups entirely.
Copy
-- Regular indexCREATE INDEX idx_orders_user ON orders(user_id);-- Query needs status and total:SELECT status, total FROM orders WHERE user_id = 123;-- 1. Lookup user_id in index → get row pointer-- 2. Fetch row from table → get status, total-- 2 operations!-- Covering index (PostgreSQL 11+)CREATE INDEX idx_orders_user_covering ON orders(user_id) INCLUDE (status, total);-- Now:-- 1. Lookup user_id in index → status, total already there!-- Index-only scan (faster!)-- Use EXPLAIN to verify:EXPLAIN SELECT status, total FROM orders WHERE user_id = 123;-- "Index Only Scan using idx_orders_user_covering"
-- Primary key creates unique index automaticallyCREATE TABLE users ( id SERIAL PRIMARY KEY -- Creates unique index on id);-- UNIQUE constraint creates unique indexALTER TABLE users ADD CONSTRAINT unique_email UNIQUE (email);-- Creates idx_users_email (unique)-- Unique with condition (partial unique)CREATE UNIQUE INDEX idx_users_email_active ON users(email)WHERE deleted_at IS NULL;-- Only active users need unique emails-- Unique across multiple columnsCREATE UNIQUE INDEX idx_product_variants ON products(product_id, size, color);-- Each combination must be unique
While B-Trees are the standard for most relational databases, modern systems often choose between B-Trees and LSM-Trees depending on the read/write profile.
A B-Tree is a self-balancing tree data structure that maintains sorted data and allows searches, sequential access, insertions, and deletions in logarithmic time.B-Tree Leaf Splits (The Write Penalty):
When you insert a new key into a full node (typically 8KB or 16KB page), the database must perform a leaf split:
A new page is allocated.
Half the keys from the full node are moved to the new page.
The parent node is updated to include a pointer to the new page.
If the parent is also full, the split propagates up the tree.
Performance Impact: Leaf splits are expensive because they require extra I/O and locks on the tree structure, potentially causing latency spikes during heavy write bursts.
Log-Structured Merge-Trees (LSM-Trees) are used in write-heavy systems like Cassandra, Bigtable, and RocksDB (and as an option in some relational extensions).
Memtable: All writes are first buffered in an in-memory sorted structure (the memtable). This is fast because it’s purely in-RAM.
SSTables: Once the memtable is full, it is flushed to disk as a sorted, immutable file called an SSTable (Sorted String Table).
Compaction: Background processes merge smaller SSTables into larger ones, removing deleted entries and resolving updates.
Trade-offs:
Write Performance: Superior to B-Trees because writes are always sequential flushes.
Read Performance: Potentially slower than B-Trees because a single read might have to check multiple SSTables (Read Amplification).
A partial index is built over a subset of a table defined by a conditional expression. This is exceptionally powerful for optimizing queries that target a specific lifecycle state of a record.
Copy
-- Index only active accounts-- Huge space savings if 90% of accounts are 'deactivated'CREATE INDEX idx_active_accounts ON accounts(user_id)WHERE status = 'active';
You can index the result of a function or expression, rather than just column values. This is essential when the query logic involves transformations.
Copy
-- Indexing a truncated date for reportingCREATE INDEX idx_orders_day ON orders (date_trunc('day', created_at));-- Indexing a specific key in a JSONB blobCREATE INDEX idx_item_sku ON order_items ((details->>'sku'));
A Covering Index uses the INCLUDE clause to add payload columns to a B-Tree’s leaf nodes. This enables Index-Only Scans, but there’s a catch: The Visibility Map.For an index-only scan to be truly fast, the database must verify that the data in the index is “visible” to the current transaction without checking the table heap.
If the page is marked as “all-visible” in the Visibility Map, the DB skips the heap.
If the page has recently changed and isn’t marked yet, the DB must still fetch the row from disk, negating the speed benefit.
Pro-tip: Regular VACUUM (or aggressive autovacuum) is required to keep the Visibility Map fresh and ensure Index-Only Scans stay fast.
-- Basic explain (estimated costs only)EXPLAIN SELECT * FROM users WHERE email = 'john@example.com';-- With actual execution statsEXPLAIN ANALYZE SELECT * FROM users WHERE email = 'john@example.com';-- Full detailsEXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT * FROM users WHERE email = 'john@example.com';
EXPLAIN (ANALYZE, BUFFERS)SELECT u.name, COUNT(o.id) as order_countFROM users uJOIN orders o ON u.id = o.user_idWHERE u.created_at > '2024-01-01'GROUP BY u.id, u.nameORDER BY order_count DESCLIMIT 10;
┌─────────────────────────────────────────────────────────────────────────┐│ SCAN TYPE COMPARISON │├──────────────────────┬──────────────────────────────────────────────────┤│ Seq Scan │ Reads entire table, row by row ││ │ O(n) - Use when: no index, or selecting most ││ │ of the table │├──────────────────────┼──────────────────────────────────────────────────┤│ Index Scan │ Traverse index, then fetch rows from table ││ │ O(log n + k) - Use when: selecting few rows │├──────────────────────┼──────────────────────────────────────────────────┤│ Index Only Scan │ All data from index, no table access ││ │ Fastest! Requires covering index + visible rows │├──────────────────────┼──────────────────────────────────────────────────┤│ Bitmap Index Scan │ Build bitmap of matching rows, then fetch ││ │ Good for: medium selectivity, multiple indexes ││ │ combining with OR │└──────────────────────┴──────────────────────────────────────────────────┘
-- Find unused indexesSELECT schemaname, relname AS table, indexrelname AS index, idx_scan AS times_used, pg_size_pretty(pg_relation_size(indexrelid)) AS sizeFROM pg_stat_user_indexesWHERE idx_scan = 0ORDER BY pg_relation_size(indexrelid) DESC;-- Find most used indexesSELECT relname AS table, indexrelname AS index, idx_scan AS times_used, idx_tup_read AS rows_read, idx_tup_fetch AS rows_fetchedFROM pg_stat_user_indexesORDER BY idx_scan DESCLIMIT 20;-- Tables with too many sequential scans (may need indexes)SELECT relname AS table, seq_scan, idx_scan, CASE WHEN idx_scan > 0 THEN ROUND(100.0 * seq_scan / (seq_scan + idx_scan), 2) END AS seq_scan_pct, pg_size_pretty(pg_relation_size(relid)) AS sizeFROM pg_stat_user_tablesWHERE seq_scan > 0ORDER BY seq_scan DESCLIMIT 20;
Indexes accumulate dead entries over time, becoming larger and slower.
Copy
-- Check index size vs table sizeSELECT relname AS table, pg_size_pretty(pg_table_size(relid)) AS table_size, pg_size_pretty(pg_indexes_size(relid)) AS indexes_size, ROUND(100.0 * pg_indexes_size(relid) / NULLIF(pg_table_size(relid), 0), 2) AS index_ratioFROM pg_stat_user_tablesORDER BY pg_indexes_size(relid) DESC;-- Rebuild bloated indexREINDEX INDEX CONCURRENTLY idx_orders_user;-- Or create replacement and swapCREATE INDEX CONCURRENTLY idx_orders_user_new ON orders(user_id);DROP INDEX idx_orders_user;ALTER INDEX idx_orders_user_new RENAME TO idx_orders_user;
-- Update statistics for a tableANALYZE users;-- Update for entire databaseANALYZE;-- Check when statistics were last updatedSELECT relname, last_analyze, last_autoanalyze, n_live_tup, n_dead_tupFROM pg_stat_user_tablesORDER BY n_dead_tup DESC;-- Configure autovacuum for busy tablesALTER TABLE orders SET ( autovacuum_analyze_scale_factor = 0.01, -- Analyze at 1% change autovacuum_vacuum_scale_factor = 0.01 -- Vacuum at 1% dead tuples);
-- Query 1: User lookup by emailSELECT * FROM users WHERE email = 'john@example.com';-- Query 2: User's recent ordersSELECT * FROM orders WHERE user_id = 123ORDER BY created_at DESCLIMIT 10;-- Query 3: Pending orders older than 1 hourSELECT * FROM orders WHERE status = 'pending'AND created_at < NOW() - INTERVAL '1 hour';-- Query 4: Product search by category and price rangeSELECT * FROM products WHERE category_id = 5AND price BETWEEN 100 AND 500ORDER BY price;-- Query 5: Count of orders by status per user (dashboard)SELECT user_id, status, COUNT(*) FROM orders GROUP BY user_id, status;
Solutions
Copy
-- Query 1: Simple unique indexCREATE UNIQUE INDEX idx_users_email ON users(email);-- Query 2: Composite with sort orderCREATE INDEX idx_orders_user_created ON orders(user_id, created_at DESC);-- Query 3: Partial index on pending ordersCREATE INDEX idx_orders_pending_created ON orders(created_at)WHERE status = 'pending';-- Query 4: Composite for filter + sortCREATE INDEX idx_products_category_price ON products(category_id, price);-- Query 5: Covering index for aggregationCREATE INDEX idx_orders_user_status ON orders(user_id, status);-- Or include count columns if other columns needed
-- This query is slow. Analyze and fix it.EXPLAIN ANALYZESELECT p.name, p.price, c.name as category_name, AVG(r.rating) as avg_rating, COUNT(r.id) as review_countFROM products pLEFT JOIN categories c ON p.category_id = c.idLEFT JOIN reviews r ON p.id = r.product_idWHERE lower(p.name) LIKE '%laptop%'AND p.price > 500AND p.status = 'active'GROUP BY p.id, p.name, p.price, c.nameHAVING COUNT(r.id) > 10ORDER BY avg_rating DESCLIMIT 20;
Analysis and Solution
Copy
-- Problems identified:-- 1. lower(p.name) LIKE '%laptop%' - Cannot use index (function + leading wildcard)-- 2. No covering index for the main filter conditions-- 3. Reviews aggregation might be slow-- Solution 1: Full-text search for nameALTER TABLE products ADD COLUMN name_search tsvector GENERATED ALWAYS AS (to_tsvector('english', name)) STORED;CREATE INDEX idx_products_name_search ON products USING gin(name_search);-- Solution 2: Index for filtersCREATE INDEX idx_products_active_price ON products(price)WHERE status = 'active';-- Solution 3: Index for reviews aggregationCREATE INDEX idx_reviews_product ON reviews(product_id);-- Optimized query:SELECT p.name, p.price, c.name as category_name, AVG(r.rating) as avg_rating, COUNT(r.id) as review_countFROM products pLEFT JOIN categories c ON p.category_id = c.idLEFT JOIN reviews r ON p.id = r.product_idWHERE p.name_search @@ to_tsquery('english', 'laptop') -- Use full-text searchAND p.price > 500AND p.status = 'active'GROUP BY p.id, p.name, p.price, c.nameHAVING COUNT(r.id) > 10ORDER BY avg_rating DESCLIMIT 20;