Optimize slow SQL queries with indexing, query rewriting, and execution plan analysis. Outputs before/after metrics and index strategies.
argument-hint
["slow query","database type","table sizes"]
allowed-tools
Read, Write, Bash
SQL Query Optimization
Transform slow queries into fast ones through indexing, rewriting, and execution plan tuning. Not guesswork — systematic analysis using EXPLAIN, index strategies, and measurable improvements.
CREATE INDEX idx_orders_user_id ON orders(user_id);
Fast:
SELECT*FROM orders WHERE user_id =123;
-- Execution time: 15ms
EXPLAIN Output:
Index Scan using idx_orders_user_id on orders (cost=0.42..12.50 rows=100)
Index Cond: (user_id = 123)
Improvement: 167x faster
2. SELECT * (Transfer Unnecessary Data)
Slow:
SELECT*FROM orders WHERE status ='pending';
-- Returns 50 columns, 100KB per row
Fast:
SELECT id, user_id, total, created_at
FROM orders
WHERE status ='pending';
-- Returns 4 columns, 200 bytes per row
Improvement: 500x less data transferred
3. N+1 Query Problem
Slow:
# Django ORM
orders = Order.objects.all() # 1 queryfor order in orders:
print(order.user.name) # N queries (1 per order)# Total: 1 + 1000 = 1001 queries
Fast:
orders = Order.objects.select_related('user').all() # 1 query with JOINfor order in orders:
print(order.user.name) # No additional queries# Total: 1 query
SQL Generated:
SELECT orders.*, users.*FROM orders
INNERJOIN users ON orders.user_id = users.id;
Improvement: 1001 queries → 1 query
4. OR Conditions (Index Not Used)
Slow:
SELECT*FROM orders
WHERE status ='pending'OR status ='processing';
-- Index on status not used efficiently
Fast:
SELECT*FROM orders
WHERE status IN ('pending', 'processing');
-- Index scan
Even Better:
SELECT*FROM orders WHERE status ='pending'UNIONALLSELECT*FROM orders WHERE status ='processing';
-- Uses index twice
5. Function on Indexed Column
Slow:
SELECT*FROM users
WHERELOWER(email) ='user@example.com';
-- Index on email not used (function applied)
Fix: Functional Index
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
Or: Rewrite Query
SELECT*FROM users
WHERE email ='user@example.com'-- Assume already lowercaseOR email ='USER@EXAMPLE.COM';
6. LIKE with Leading Wildcard
Slow:
SELECT*FROM products
WHERE name LIKE'%phone%';
-- Cannot use index (leading wildcard)
Fix: Full-Text Search
-- PostgreSQLCREATE INDEX idx_products_name_fts ON products
USING GIN (to_tsvector('english', name));
SELECT*FROM products
WHERE to_tsvector('english', name) @@ to_tsquery('phone');
Or: Trigram Index
CREATE EXTENSION pg_trgm;
CREATE INDEX idx_products_name_trgm ON products
USING GIN (name gin_trgm_ops);
SELECT*FROM products
WHERE name ILIKE '%phone%';
-- Now uses GIN index
Index Strategies
Composite Index (Multiple Columns)
Query:
SELECT*FROM orders
WHERE user_id =123AND status ='pending'ORDERBY created_at DESC;
Optimal Index:
CREATE INDEX idx_orders_user_status_created
ON orders(user_id, status, created_at DESC);
Order Matters:
Most selective column first (user_id)
Equality conditions before range
ORDER BY column last
Wrong Index:
CREATE INDEX idx_orders_bad
ON orders(created_at, user_id, status);
-- Less efficient: created_at first is not selective
Covering Index (Include Columns)
Query:
SELECT id, total, created_at
FROM orders
WHERE user_id =123;
Index-Only Scan:
CREATE INDEX idx_orders_user_covering
ON orders(user_id)
INCLUDE (id, total, created_at);
Benefit: No need to access table (index contains all data)
Partial Index (Filtered)
Query:
SELECT*FROM orders WHERE status ='pending';
Smaller Index:
CREATE INDEX idx_orders_pending
ON orders(created_at)
WHERE status ='pending';
-- Index only pending orders (10% of table)
EXPLAIN ANALYZE
SELECT*FROM orders WHERE user_id =123;
Output:
Index Scan using idx_orders_user_id on orders
(cost=0.42..12.50 rows=100 width=200)
(actual time=0.025..0.138 rows=95 loops=1)
Index Cond: (user_id = 123)
Planning Time: 0.123 ms
Execution Time: 0.165 ms
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename) - pg_relation_size(schemaname||'.'||tablename)) AS external_size
FROM pg_tables
WHERE schemaname NOTIN ('pg_catalog', 'information_schema')
ORDERBY pg_total_relation_size(schemaname||'.'||tablename) DESC
LIMIT 20;
Materialized Views
Slow:
SELECTDATE(created_at) asdate,
COUNT(*) as orders,
SUM(total) as revenue
FROM orders
GROUPBYDATE(created_at);
-- Scans 5M rows every query
Fast: Materialized View
CREATE MATERIALIZED VIEW daily_revenue ASSELECTDATE(created_at) asdate,
COUNT(*) as orders,
SUM(total) as revenue
FROM orders
GROUPBYDATE(created_at);
CREATE INDEX idx_daily_revenue_date ON daily_revenue(date);
-- Refresh daily
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_revenue;
Query:
SELECT*FROM daily_revenue WHEREdate='2024-01-15';
-- 5ms instead of 2500ms
Partitioning (Very Large Tables)
-- Range partition by dateCREATE TABLE orders (
id BIGSERIAL,
created_at TIMESTAMPTZ NOT NULL,
...
) PARTITIONBYRANGE (created_at);
CREATE TABLE orders_2024_01 PARTITIONOF orders
FORVALUESFROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE orders_2024_02 PARTITIONOF orders
FORVALUESFROM ('2024-02-01') TO ('2024-03-01');
-- Indexes on each partitionCREATE INDEX idx_orders_2024_01_user ON orders_2024_01(user_id);
CREATE INDEX idx_orders_2024_02_user ON orders_2024_02(user_id);
Benefit: Queries on recent data only scan recent partition