Lesson 11 of 12

Query Performance & EXPLAIN

A query that is fast on a small test can be slow on real data. EXPLAIN shows how the database plans to run a query, so you can see why it is slow and fix it.

SQL
-- EXPLAIN shows the plan; ANALYZE actually runs it and reports real timings.
EXPLAIN ANALYZE
SELECT c.name, COUNT(o.id) AS orders
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE c.country_code = 'PT'
GROUP BY c.name;

-- What to look for in the output:
--
--   Seq Scan on orders          -> reading EVERY row. Fine on 500 rows,
--                                  a red flag on 50 million.
--   Index Scan using idx_...    -> using an index. Usually what you want.
--   Bitmap Heap Scan            -> index found many rows, then fetched them.
--   Nested Loop                 -> good for small inputs, terrible for large ones.
--   Hash Join / Merge Join      -> the usual choice for big joins.
--   rows=1000 ... actual rows=2000000
--                               -> the estimate is way off; run ANALYZE on the
--                                  table so the planner has fresh statistics.
--   Rows Removed by Filter      -> work done and thrown away: index the filter.
SQL
-- Index the columns you filter, join and sort on.
CREATE INDEX idx_orders_customer   ON orders(customer_id);
CREATE INDEX idx_orders_created    ON orders(created_at DESC);

-- Composite index: order matters. This serves
--   WHERE customer_id = ?
--   WHERE customer_id = ? AND status = ?
--   WHERE customer_id = ? AND status = ? ORDER BY created_at DESC
-- but NOT a query filtering only on status.
CREATE INDEX idx_orders_lookup ON orders(customer_id, status, created_at DESC);

-- Partial index: smaller and faster when you always filter the same way
CREATE INDEX idx_orders_pending ON orders(created_at)
WHERE status = 'pending';

-- Covering index: the query is answered from the index alone (an "index-only scan")
CREATE INDEX idx_orders_covering ON orders(customer_id) INCLUDE (total, status);

-- Indexes are not free: every INSERT, UPDATE and DELETE has to maintain them,
-- and they take disk space. Find the ones nobody uses:
SELECT relname, indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY relname;
SQL
-- Patterns that silently disable an index:

-- BAD: a function on the indexed column
SELECT * FROM users WHERE LOWER(email) = 'ada@example.com';
-- FIX: index the expression instead
CREATE INDEX idx_users_email_lower ON users(LOWER(email));

-- BAD: leading wildcard cannot use a normal B-tree index
SELECT * FROM products WHERE name LIKE '%phone%';
-- FIX: a trigram index or a full-text search column
CREATE INDEX idx_products_name_trgm ON products USING GIN (name gin_trgm_ops);

-- BAD: arithmetic on the column
SELECT * FROM orders WHERE total * 1.23 > 100;
-- FIX: move the maths to the other side
SELECT * FROM orders WHERE total > 100 / 1.23;

-- BAD: SELECT * when you need two columns — more I/O, no index-only scan
SELECT * FROM orders WHERE customer_id = 42;
SELECT id, total FROM orders WHERE customer_id = 42;   -- better

-- BAD: OFFSET paging deep into a large table (it still reads every skipped row)
SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 100000;
-- FIX: keyset pagination
SELECT * FROM orders WHERE id > 100000 ORDER BY id LIMIT 20;
Finished reading?Mark it done to track your progress.

Tip: use the ← and → keys to move between lessons.