Loading learning content...
The fastest network in the world cannot save you from a slow database query. Databases are often the primary bottleneck in application latency—a single poorly optimized query can take seconds when it should take milliseconds, negating every other optimization in your system.
Consider this common scenario: Your API endpoint returns data in 50ms locally, but in production it takes 2 seconds. The network is fast, the server has plenty of CPU—but the database is scanning 10 million rows to return 50, because someone forgot to add an index. Or worse, the query is executing a cartesian product due to a missing join condition, turning a 50-row result into a 50-million-row intermediate set.
Database query optimization is where elite engineers separate themselves. It requires understanding query planners, indexing theory, data distribution, and the specific characteristics of your database engine. This page will give you that understanding.
By completing this page, you will understand how database query planners work, master indexing strategies for common access patterns, learn to read and interpret query execution plans, and acquire techniques that can reduce query latency from seconds to milliseconds.
Before optimizing queries, you must understand what happens when a query is executed. Databases don't execute SQL directly—they translate it into a query execution plan, a tree of physical operations that retrieve and process data.
The Query Processing Pipeline:
SQL Query → Parser → Query Rewriter → Query Optimizer → Execution Engine → Results
The Query Optimizer is the heart of database performance. It explores different ways to execute your query—which indexes to use, which join algorithms to apply, what order to join tables—and selects the plan with lowest estimated cost.
| Operation | Description | Performance Characteristic |
|---|---|---|
| Sequential Scan (Seq Scan) | Read every row in table | O(n) - slow for large tables |
| Index Scan | Use index to locate rows | O(log n) - much faster for selective queries |
| Index Only Scan | Answer query entirely from index | Fastest - no table access needed |
| Bitmap Index Scan | Build bitmap of matching rows, then fetch | Good for moderate selectivity |
| Nested Loop Join | For each row in outer, scan inner | O(n×m) - fast when inner is small/indexed |
| Hash Join | Build hash table of smaller relation | O(n+m) - good for large unsorted tables |
| Merge Join | Merge two sorted relations | O(n+m) - requires sorted input |
| Sort | Sort rows by specified columns | O(n log n) - expensive for large datasets |
| Aggregate | Compute aggregate functions | O(n) - must process all matching rows |
Cost Estimation:
The optimizer assigns a cost to each possible plan based on:
Cost estimates depend on table statistics—row counts, value distributions, null frequencies. Stale statistics lead to poor plans. This is why ANALYZE (PostgreSQL) or UPDATE STATISTICS (SQL Server) is critical.
Selectivity:
Selectivity is the fraction of rows that match a condition. A condition matching 1% of rows has 0.01 selectivity—very selective. A condition matching 90% has 0.9 selectivity—not selective.
Selective conditions favor index scans; non-selective conditions favor sequential scans (reading whole table once is faster than thousands of random index lookups).
Databases make optimization decisions based on statistics collected about your data. After loading large amounts of data or significant data changes, run ANALYZE (PostgreSQL) or UPDATE STATISTICS (SQL Server) to refresh statistics. Stale statistics can cause the optimizer to choose catastrophically wrong plans—like choosing a sequential scan of 100 million rows instead of an index scan that would read 100 rows.
Execution plans are your window into database decision-making. Learning to read them reveals why queries are slow and what to optimize.
EXPLAIN vs EXPLAIN ANALYZE:
EXPLAIN shows the planned execution (estimates only, doesn't run query)EXPLAIN ANALYZE shows actual execution (runs query, compares estimates to reality)Always use EXPLAIN ANALYZE when debugging—estimated costs can be very different from actual costs when statistics are stale or data is skewed.
12345678910111213141516171819202122232425262728293031323334353637383940414243
-- PostgreSQL: Basic query with execution planEXPLAIN ANALYZESELECT u.name, o.total_amountFROM users uJOIN orders o ON u.id = o.user_idWHERE o.created_at > '2024-01-01' AND o.status = 'completed'; -- Example output:/*Nested Loop (cost=0.87..1234.56 rows=142 width=40) (actual time=0.045..2.341 rows=138 loops=1) -> Index Scan using idx_orders_created_at on orders o (cost=0.43..567.89 rows=142 width=20) (actual time=0.025..1.234 rows=138 loops=1) Index Cond: (created_at > '2024-01-01') Filter: (status = 'completed') Rows Removed by Filter: 45 -> Index Scan using users_pkey on users u (cost=0.43..4.45 rows=1 width=24) (actual time=0.006..0.006 rows=1 loops=138) Index Cond: (id = o.user_id)Planning Time: 0.234 msExecution Time: 2.456 ms*/ -- Key things to look for:-- 1. "Seq Scan" on large tables - usually bad, needs index-- 2. "actual rows" >> "rows" estimate - stale statistics-- 3. High "loops" count with nested loops - may need different join-- 4. "Rows Removed by Filter" is high - filter not pushed to index-- 5. Large "cost" numbers - expensive operations -- PostgreSQL: Get detailed buffer/IO statisticsEXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)SELECT * FROM large_table WHERE id = 12345; -- MySQL equivalentEXPLAIN ANALYZESELECT u.name, o.total_amountFROM users uJOIN orders o ON u.id = o.user_idWHERE o.created_at > '2024-01-01';Use visual tools for complex plans: pgAdmin has a graphical plan viewer for PostgreSQL, MySQL Workbench visualizes EXPLAIN output, and tools like explain.depesz.com (PostgreSQL) color-code slow operations. Visual representation makes it easier to spot bottlenecks in complex multi-join queries.
Indexes are the most powerful tool for query optimization. They transform O(n) table scans into O(log n) lookups. But indexing is nuanced—wrong indexes waste space and slow writes; right indexes make impossible queries instant.
How B-Tree Indexes Work:
Most database indexes are B-trees (balanced trees). A B-tree index maintains sorted key values in a tree structure where:
For a table with 1 billion rows, a B-tree typically has depth 4-5. Finding any row requires at most 4-5 page reads, versus potentially millions for a sequential scan.
| Index Type | Best For | Not Good For |
|---|---|---|
| B-Tree (default) | Equality, range, sorting, prefix LIKE | Full-text search, array containment |
| Hash | Exact equality lookups only | Range queries, sorting |
| GIN (Generalized Inverted) | Full-text search, arrays, JSONB | Simple equality/range |
| GiST (Generalized Search Tree) | Geometric/spatial data, ranges | Simple equality |
| BRIN (Block Range) | Very large sorted tables (time-series) | Random access patterns |
| Bitmap | Multiple low-selectivity conditions | High-cardinality columns |
Index Selection Principles:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
-- Single column index for equality and range queriesCREATE INDEX idx_orders_created_at ON orders(created_at); -- Composite index for multi-column filtering-- Column order matters! Put equality columns first, then rangeCREATE INDEX idx_orders_user_status_date ON orders(user_id, status, created_at); -- This index supports these queries efficiently:-- WHERE user_id = X-- WHERE user_id = X AND status = Y-- WHERE user_id = X AND status = Y AND created_at > Z-- BUT NOT: WHERE status = Y (can't skip user_id) -- Covering index: includes all columns query needs-- Enables "Index Only Scan" - no table access requiredCREATE INDEX idx_orders_covering ON orders(user_id, status) INCLUDE (order_total, created_at); -- Partial index: only index subset of rows-- Smaller, faster, perfect for skewed dataCREATE INDEX idx_active_orders ON orders(created_at) WHERE status = 'active'; -- Expression index: index computed valuesCREATE INDEX idx_users_lower_email ON users(LOWER(email)); -- Now this query uses the index:-- WHERE LOWER(email) = 'user@example.com' -- For text search with LIKE 'prefix%'-- B-tree works for prefix, but need special operator classCREATE INDEX idx_users_name_pattern ON users(name varchar_pattern_ops); -- PostgreSQL: Check index usageSELECT schemaname, relname AS table_name, indexrelname AS index_name, idx_scan AS times_used, idx_tup_read AS tuples_read, idx_tup_fetch AS tuples_fetchedFROM pg_stat_user_indexesORDER BY idx_scan DESC;Every index slows down writes—INSERT, UPDATE, and DELETE must maintain all indexes. A table with 10 indexes makes insertions 10x slower than a table with no indexes. Index only columns that benefit query performance, and regularly review unused indexes. In PostgreSQL, check pg_stat_user_indexes for idx_scan = 0 to find unused indexes.
Beyond basic indexing, advanced strategies unlock performance for complex query patterns.
Composite Index Column Ordering:
In composite indexes, column order is critical. The index can only use columns from left to right. Given index (A, B, C):
WHERE A = 1 — Uses indexWHERE A = 1 AND B = 2 — Uses index fullyWHERE A = 1 AND B = 2 AND C > 3 — Uses all columnsWHERE A = 1 AND C = 3 — Uses A only, scans for CWHERE B = 2 — Cannot use index (A skipped)The Rule: Put equality conditions first, range conditions last. Put most selective equality columns first for best filtering.
WHERE status = 'active' — Equality, uses B-treeWHERE created_at > '2024-01-01' — Range, uses B-treeWHERE email LIKE 'john%' — Prefix match, uses B-treeWHERE user_id IN (1,2,3) — Equality list, uses indexORDER BY created_at DESC LIMIT 10 — Index scan with limitWHERE (a, b) > (1, 2) — Row comparison, uses compositeWHERE YEAR(created_at) = 2024 — Function on columnWHERE amount * 1.1 > 100 — Expression on columnWHERE email LIKE '%@gmail.com' — Suffix matchWHERE name != 'John' — Negative conditionsWHERE status IS NOT NULL — Often low selectivityWHERE LOWER(email) = 'x' — Without expression indexCovering Indexes:
A covering index includes all columns a query needs, allowing an Index Only Scan—the database never reads the table, only the index. This eliminates the expensive random I/O of table lookups.
-- Query needs user_id, email, name
SELECT email, name FROM users WHERE user_id = 123;
-- Covering index with INCLUDE clause
CREATE INDEX idx_users_covering ON users(user_id) INCLUDE (email, name);
Partial Indexes:
Partial indexes only index rows matching a condition. For tables where queries always filter on a condition, partial indexes are smaller and faster.
-- Active orders are 5% of all orders
CREATE INDEX idx_active_orders ON orders(created_at) WHERE status = 'active';
-- Index is 20x smaller, faster to scan, faster to maintain
Index-Organized Tables (Clustered Indexes):
In MySQL (InnoDB) and SQL Server, the primary key index stores actual row data—rows are physically ordered by primary key. This means:
Choose primary keys carefully in these systems—UUID primary keys cause random I/O on every insert; sequential IDs allow efficient append-only writes.
B-tree indexes become fragmented over time as rows are inserted and deleted. Fragmented indexes are larger and slower. Schedule regular REINDEX (PostgreSQL) or ALTER INDEX REBUILD (SQL Server) during low-traffic periods. Monitor index bloat with pg_stat_user_indexes and pg_relation_size.
Sometimes the best optimization is rewriting the query itself. Semantically equivalent queries can have vastly different performance characteristics.
Common Query Patterns and Their Optimizations:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
-- PROBLEM 1: Subquery in WHERE with correlation-- Bad: Correlated subquery executes once per rowSELECT * FROM orders oWHERE o.total > ( SELECT AVG(total) FROM orders o2 WHERE o2.user_id = o.user_id); -- Better: Rewrite as JOIN with pre-aggregated dataSELECT o.* FROM orders oJOIN ( SELECT user_id, AVG(total) as avg_total FROM orders GROUP BY user_id) user_avg ON o.user_id = user_avg.user_idWHERE o.total > user_avg.avg_total; -- PROBLEM 2: OR conditions on different columns-- Bad: Often can't use indexes effectivelySELECT * FROM products WHERE category_id = 5 OR brand_id = 10; -- Better: UNION of two indexed queriesSELECT * FROM products WHERE category_id = 5UNIONSELECT * FROM products WHERE brand_id = 10; -- PROBLEM 3: DISTINCT with large result set-- Bad: Sorts entire result for deduplicationSELECT DISTINCT user_id FROM orders WHERE created_at > '2024-01-01'; -- Better: GROUP BY (sometimes optimizes differently)SELECT user_id FROM orders WHERE created_at > '2024-01-01' GROUP BY user_id; -- Even better if exists check is sufficientSELECT user_id FROM users uWHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.created_at > '2024-01-01'); -- PROBLEM 4: Counting with expensive conditions-- Bad: Counts all matching rowsSELECT COUNT(*) FROM orders WHERE status = 'completed'; -- Better for approximation: Use pg_stat_user_tablesSELECT n_tup_ins - n_tup_del AS approx_count FROM pg_stat_user_tables WHERE relname = 'orders'; -- Or for exact count on filtered large table, consider:SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = 'orders'; -- PROBLEM 5: Pagination with OFFSET-- Bad: OFFSET 10000 still reads and discards 10000 rowsSELECT * FROM products ORDER BY id LIMIT 20 OFFSET 10000; -- Better: Keyset pagination (seek method)SELECT * FROM products WHERE id > 10020 -- Last ID from previous pageORDER BY id LIMIT 20; -- PROBLEM 6: NOT IN with nullable column-- Bad: NOT IN with NULLs returns unexpected results, can't use indexSELECT * FROM users WHERE id NOT IN (SELECT user_id FROM deleted_users); -- Better: NOT EXISTS (handles NULLs correctly, often faster)SELECT * FROM users uWHERE NOT EXISTS (SELECT 1 FROM deleted_users d WHERE d.user_id = u.id); -- Or: LEFT JOIN / IS NULLSELECT u.* FROM users uLEFT JOIN deleted_users d ON u.id = d.user_idWHERE d.user_id IS NULL; -- PROBLEM 7: Multiple conditions on same table-- Bad: Multiple subqueries hit same table repeatedlySELECT * FROM products WHERE category_id = (SELECT id FROM categories WHERE name = 'Electronics') AND brand_id = (SELECT id FROM brands WHERE name = 'Samsung'); -- Better: Single lookup with JOINSELECT p.* FROM products pJOIN categories c ON p.category_id = c.idJOIN brands b ON p.brand_id = b.idWHERE c.name = 'Electronics' AND b.name = 'Samsung';JOINs are where query optimization gets complex. The number of possible join orders grows factorially with table count—for 10 tables, there are 10! = 3.6 million possible orders. Understanding join algorithms helps you structure queries for optimal execution.
Join Algorithm Comparison:
| Algorithm | Mechanism | Best When | Cost |
|---|---|---|---|
| Nested Loop | For each outer row, scan inner table/index | Small outer, indexed inner | O(n × m) or O(n × log m) with index |
| Hash Join | Build hash table on smaller table, probe with larger | Large unsorted tables, no useful indexes | O(n + m) with memory for hash table |
| Merge Join | Merge two sorted inputs | Both sides already sorted (by index or prior sort) | O(n + m) but requires sorted input |
Join Optimization Strategies:
1. Ensure Join Columns Are Indexed: Foreign key columns should almost always be indexed. Without an index, every join becomes a sequential scan of the inner table.
2. Filter Before Joining: Apply WHERE conditions to reduce rows before joining. The optimizer usually does this, but complex queries may fool it.
3. Consider Join Order: With multiple joins, order matters. Join the most restrictive conditions first to minimize intermediate result sizes. Most optimizers explore orderings automatically, but hints can help.
4. Avoid Cartesian Products: A missing join condition creates a cartesian product (every combination of rows). 1000 × 1000 = 1 million rows. Always verify join conditions completely connect all tables.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
-- Ensure FK columns are indexedCREATE INDEX idx_orders_user_id ON orders(user_id);CREATE INDEX idx_order_items_order_id ON order_items(order_id);CREATE INDEX idx_order_items_product_id ON order_items(product_id); -- PostgreSQL: Force specific join order when optimizer failsSET join_collapse_limit = 1; -- Respect explicit join order SELECT *FROM users uJOIN orders o ON o.user_id = u.id -- Join 1JOIN order_items oi ON oi.order_id = o.id -- Join 2JOIN products p ON p.id = oi.product_id -- Join 3WHERE u.id = 12345; RESET join_collapse_limit; -- PostgreSQL: Hint to disable specific join typeSET enable_hashjoin = off; -- Force nested loop or merge joinEXPLAIN ANALYZE SELECT ...;SET enable_hashjoin = on; -- Reset -- Large join optimization: materialize expensive subqueryWITH expensive_filter AS MATERIALIZED ( SELECT user_id, SUM(total) as total_spend FROM orders WHERE created_at > '2024-01-01' GROUP BY user_id HAVING SUM(total) > 1000)SELECT u.*, ef.total_spendFROM users uJOIN expensive_filter ef ON u.id = ef.user_id; -- Lateral join for efficient correlated logic-- Get latest 3 orders per user efficientlySELECT u.*, recent_orders.*FROM users u,LATERAL ( SELECT order_id, total, created_at FROM orders WHERE user_id = u.id ORDER BY created_at DESC LIMIT 3) recent_ordersWHERE u.status = 'active'; -- This is more efficient than:-- SELECT * FROM users u-- JOIN orders o ON o.user_id = u.id-- WHERE ... (with window functions for top 3)Most databases support hints to force specific join orders or algorithms (LEADING/USE_HASH in Oracle, OPTION (LOOP JOIN) in SQL Server, GUCs in PostgreSQL). Use hints only as a last resort when the optimizer consistently fails. Hints are maintenance burdens—they become wrong as data changes. Prefer fixing statistics, adding indexes, or restructuring queries.
Certain query patterns appear repeatedly in applications. Knowing the optimized form for each saves debugging time.
Pattern 1: The N+1 Query Problem
One of the most common ORM-induced performance issues.
123456789101112131415161718192021222324252627282930313233343536373839
// N+1 PROBLEM: 1 query for users, N queries for ordersconst users = await prisma.user.findMany({ take: 100 });for (const user of users) { // This executes 100 separate queries! const orders = await prisma.order.findMany({ where: { userId: user.id } });} // SOLUTION: Eager loading / batch fetchingconst usersWithOrders = await prisma.user.findMany({ take: 100, include: { orders: true // Single query with JOIN }}); // ALTERNATIVE: DataLoader pattern for GraphQLimport DataLoader from 'dataloader'; const orderLoader = new DataLoader(async (userIds: string[]) => { const orders = await prisma.order.findMany({ where: { userId: { in: userIds } } }); // Group by userId and return in same order as input const ordersByUser = new Map<string, Order[]>(); for (const order of orders) { const existing = ordersByUser.get(order.userId) || []; ordersByUser.set(order.userId, [...existing, order]); } return userIds.map(id => ordersByUser.get(id) || []);}); // Now resolvers use the loader - automatically batchedconst resolvers = { User: { orders: (user) => orderLoader.load(user.id) }};Pattern 2: Leaderboards and Rankings
Getting top N with position is common but easy to implement poorly.
1234567891011121314151617181920212223242526272829303132
-- Efficient: Get top 100 with rank-- Use window function with LIMITSELECT user_id, score, RANK() OVER (ORDER BY score DESC) as positionFROM leaderboardORDER BY score DESCLIMIT 100; -- Get specific user's rank without scanning entire table-- Create covering indexCREATE INDEX idx_leaderboard_score ON leaderboard(score DESC, user_id); -- Count users with higher scoreSELECT (SELECT COUNT(*) FROM leaderboard WHERE score > l.score) + 1 as position, l.scoreFROM leaderboard lWHERE l.user_id = 12345; -- For pagination in leaderboards, use keysetSELECT user_id, scoreFROM leaderboardWHERE score < 9500 -- Last score from previous pageORDER BY score DESCLIMIT 100; -- For ties, include user_id as tiebreakerWHERE (score, user_id) < (9500, 'prev_user_id')ORDER BY score DESC, user_id DESCLIMIT 100;Pattern 3: Time-Series Queries
Queries over time ranges with aggregation.
12345678910111213141516171819202122232425262728293031323334353637383940414243
-- Partition by time for efficient range queriesCREATE TABLE events ( id BIGSERIAL, event_type VARCHAR(50), created_at TIMESTAMP, data JSONB) PARTITION BY RANGE (created_at); CREATE TABLE events_2024_01 PARTITION OF eventsFOR VALUES FROM ('2024-01-01') TO ('2024-02-01'); CREATE TABLE events_2024_02 PARTITION OF eventsFOR VALUES FROM ('2024-02-01') TO ('2024-03-01'); -- Query automatically only scans relevant partitionSELECT * FROM events WHERE created_at >= '2024-01-15' AND created_at < '2024-01-20'; -- For large time-series, use BRIN index (tiny, fast for sorted data)CREATE INDEX idx_events_created_at_brin ON events USING BRIN (created_at) WITH (pages_per_range = 128); -- Aggregate with date_trunc for groupingSELECT date_trunc('hour', created_at) as hour, event_type, COUNT(*) as countFROM eventsWHERE created_at >= NOW() - INTERVAL '24 hours'GROUP BY 1, 2ORDER BY 1 DESC, 3 DESC; -- Pre-aggregate into summary table for fast dashboardsINSERT INTO event_hourly_summary (hour, event_type, count)SELECT date_trunc('hour', created_at), event_type, COUNT(*)FROM eventsWHERE created_at >= NOW() - INTERVAL '1 hour'GROUP BY 1, 2ON CONFLICT (hour, event_type) DO UPDATE SET count = EXCLUDED.count;Database queries are often the dominant source of application latency. Optimizing them requires understanding how databases execute queries and applying systematic techniques. Here are the key principles:
What's Next:
Optimized queries are essential but not sufficient. When query optimization reaches its limits, caching becomes the next line of defense. The next page explores how to use caching specifically for latency reduction—not just throughput, but making responses faster through intelligent data placement.
You now understand the fundamentals of database query optimization—from execution plans to indexing strategies to query rewriting. These techniques can reduce query times from seconds to milliseconds, often delivering 10-1000x performance improvements.