| name | query-optimization |
| description | Guide for optimizing SQL queries with EXPLAIN ANALYZE, index tuning, N+1 detection, covering indexes, and query plan analysis. Use when the user has slow database queries, asks about query performance, needs to interpret EXPLAIN output, or wants to eliminate N+1 queries. Trigger whenever query performance, slow queries, or database optimization is discussed.
|
Query Optimization
Diagnose and fix slow SQL queries using EXPLAIN ANALYZE, proper indexing, query
rewriting, and N+1 detection.
When to Use
- User reports slow database queries
- User asks about EXPLAIN ANALYZE output
- User needs to optimize query performance
- User has N+1 query problems in an ORM
- User asks about index tuning or covering indexes
Core Patterns
EXPLAIN ANALYZE
Always start with EXPLAIN ANALYZE to understand what the database is actually doing.
EXPLAIN ANALYZE
SELECT o.id, o.status, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'pending'
ORDER BY o.ordered_at DESC
LIMIT 20;
Key things to look for in the output:
-- BAD: Sequential scan on large table
Seq Scan on orders (cost=0.00..15234.00 rows=500000 width=48)
Filter: (status = 'pending'::text)
Rows Removed by Filter: 450000
-- GOOD: Index scan
Index Scan using idx_orders_status_ordered_at on orders
(cost=0.42..85.20 rows=50000 width=48)
Index Cond: (status = 'pending'::text)
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM orders WHERE customer_id = 12345;
ANALYZE orders;
Index Tuning
Create indexes that match your query patterns. Column order in composite indexes matters.
CREATE INDEX idx_orders_status_ordered_at ON orders(status, ordered_at DESC);
CREATE INDEX idx_orders_customer_status_date
ON orders(customer_id, status, ordered_at DESC);
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE customer_id = 100 AND status = 'pending'
ORDER BY ordered_at DESC
LIMIT 10;
Covering Indexes (Index-Only Scans)
Include all columns the query needs in the index to avoid hitting the table at all.
CREATE INDEX idx_orders_status_covering
ON orders(status, ordered_at DESC)
INCLUDE (id, customer_id);
EXPLAIN ANALYZE
SELECT id, customer_id, ordered_at
FROM orders
WHERE status = 'pending'
ORDER BY ordered_at DESC
LIMIT 20;
N+1 Query Detection and Fix
N+1 queries execute 1 query for the parent, then N queries for each child. Fix with
JOINs or batch loading.
SELECT o.id, o.status, o.ordered_at, c.name, c.email
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'pending'
ORDER BY o.ordered_at DESC;
SELECT * FROM orders WHERE status = 'pending' ORDER BY ordered_at DESC;
SELECT * FROM customers WHERE id IN (1, 2, 3, 4, 5);
ORM-level fixes:
from sqlalchemy.orm import joinedload, selectinload
orders = session.query(Order).options(
joinedload(Order.customer)
).filter(Order.status == "pending").all()
orders = session.query(Order).options(
selectinload(Order.items)
).filter(Order.status == "pending").all()
Query Rewriting
Restructure queries to help the optimizer choose better plans.
SELECT c.name,
(SELECT COUNT(*) FROM orders WHERE customer_id = c.id) AS order_count
FROM customers c;
SELECT c.name, COUNT(o.id) AS order_count
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.name;
SELECT * FROM orders WHERE customer_id = 100 OR status = 'pending';
SELECT * FROM orders WHERE customer_id = 100
UNION ALL
SELECT * FROM orders WHERE status = 'pending' AND customer_id != 100;
SELECT * FROM customers WHERE LOWER(email) = 'user@example.com';
CREATE INDEX idx_customers_email_lower ON customers(LOWER(email));
SELECT * FROM customers WHERE LOWER(email) = 'user@example.com';
Anti-Patterns
- Not using EXPLAIN ANALYZE: Guessing at performance problems wastes time. Always measure first with EXPLAIN ANALYZE.
- Indexing every column: Indexes cost write performance and storage. Index only columns used in WHERE, JOIN, and ORDER BY of frequent queries.
- **SELECT ***: Fetching all columns prevents covering index optimizations and wastes network bandwidth. Select only the columns you need.
- Ignoring stale statistics: PostgreSQL's planner uses table statistics to choose plans. Run
ANALYZE after bulk data changes.
- Using OFFSET for pagination:
OFFSET 10000 still scans 10000 rows. Use keyset pagination: WHERE id > last_seen_id ORDER BY id LIMIT 20.
- Premature optimization: Optimize queries that are actually slow in production, not queries that might be slow someday.
Quick Reference
SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 20;
SELECT indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;
SELECT relname, seq_scan, seq_tup_read, idx_scan
FROM pg_stat_user_tables
WHERE seq_scan > 100 AND pg_relation_size(relid) > 10000000
ORDER BY seq_tup_read DESC;
SELECT
relname AS table_name,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
pg_size_pretty(pg_relation_size(relid)) AS table_size,
pg_size_pretty(pg_indexes_size(relid)) AS index_size
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relid) DESC;
SELECT * FROM orders
WHERE (ordered_at, id) < ('2024-01-15 10:30:00', 5000)
ORDER BY ordered_at DESC, id DESC
LIMIT 20;