Query Optimization & EXPLAIN Plans
In simple words
When you write a database query, you say what you want, not how to get it. The database has a built-in planner (the query optimizer) that decides how: which index to use, and in what order to combine tables. The same query can run in milliseconds or in minutes depending on that choice. EXPLAIN is a command that shows you the plan it chose, so when a query is slow you can see why — usually a missing index, or a step that reads far more rows than it needs — and fix it.
What is Query Optimization?
Query optimization is the process by which the database automatically determines the most efficient way to execute a SQL query, and the manual techniques developers use to help the database make better decisions.
You write SQL (declarative — WHAT you want):
SELECT u.name, COUNT(o.order_id)
FROM users u JOIN orders o ON u.user_id = o.user_id
WHERE o.created_at > '2024-01-01'
GROUP BY u.user_id
ORDER BY COUNT DESC LIMIT 10;
The query optimizer decides HOW to execute it:
Which table to scan first?
Which index to use?
Which join algorithm? (Nested Loop / Hash Join / Merge Join)
What order to apply filters?
Can any operations be skipped?
Same SQL → 10x to 1000x performance difference
depending on whether the optimizer makes good choices
The Query Optimizer — How It Works
SQL Query
↓
Parser (syntax check)
↓
Resolver (validate tables/columns exist)
↓
Query Rewriter (logical transformations — e.g. subquery → join)
↓
Query Optimizer ← THIS IS THE KEY STEP
- Enumerates possible execution plans
- Estimates cost of each plan (using statistics: table size, cardinality, etc.)
- Picks the lowest-cost plan
↓
Execution Engine (runs the chosen plan)
↓
Results
Optimizer Statistics
The optimizer relies on statistics — metadata about the data distribution.
For each table, MySQL/PostgreSQL maintains:
- Row count (approximate)
- Index cardinality (number of distinct values per index)
- Data distribution histograms (in newer versions)
How statistics go stale:
Table has 100 rows → optimizer estimates 100 rows in plan
After bulk insert → table has 10 million rows
Statistics not updated → optimizer still thinks 100 rows
→ Wrong plan chosen → terrible performance
Update statistics:
MySQL: ANALYZE TABLE users;
PostgreSQL: ANALYZE users;
SQL Server: UPDATE STATISTICS users;
Run ANALYZE regularly on tables with frequent inserts/deletes
EXPLAIN — Reading the Execution Plan
EXPLAIN shows you exactly how MySQL plans to execute your query before actually running it.
EXPLAIN SELECT u.name, COUNT(o.order_id) AS order_count
FROM users u
JOIN orders o ON u.user_id = o.user_id
WHERE o.created_at > '2024-01-01'
GROUP BY u.user_id
ORDER BY order_count DESC
LIMIT 10;
EXPLAIN Output — Each Column Explained
┌────┬─────────────┬───────┬────────┬───────────────────┬─────────────┬──────┬────────────────────────┐
│ id │ select_type │ table │ type │ possible_keys │ key │ rows │ Extra │
├────┼─────────────┼───────┼────────┼───────────────────┼─────────────┼──────┼────────────────────────┤
│ 1 │ SIMPLE │ o │ range │ idx_created_at │idx_created │50000 │ Using index condition │
│ 1 │ SIMPLE │ u │ eq_ref │ PRIMARY │ PRIMARY │ 1 │ │
└────┴─────────────┴───────┴────────┴───────────────────┴─────────────┴──────┴────────────────────────┘
Column: type — Most Important Column
The type column shows the access method — how the table is read. From WORST to BEST performance:
ALL → Full table scan. No index. Reads every row.
RED FLAG for large tables. Fix: add an index.
index → Full index scan. Scans entire index (not table).
Better than ALL but still scans everything.
OK for small tables or covering index scenarios.
range → Index range scan. Uses index to find a range of values.
Good. Used for BETWEEN, >, <, IN, LIKE 'prefix%'
ref → Non-unique index lookup. Finds all rows matching a value.
Good. Used for non-unique indexes on equality (user_id in orders).
eq_ref → Unique index lookup. At most ONE row per key value.
Very good. Used for PRIMARY KEY or UNIQUE index join conditions.
const → At most one matching row, read once and treated as constant.
Best for single-row lookups by PK or UNIQUE.
e.g. WHERE user_id = 1 (primary key)
system → Table has exactly one row. Rare special case of const.
NULL → MySQL can answer without accessing the table at all.
e.g. SELECT MIN(id) FROM users (from index alone)
Performance ranking (best to worst):
NULL > system > const > eq_ref > ref > range > index > ALL
Column: key — Which Index Was Used
possible_keys: indexes that COULD be used for this query
key: index the optimizer ACTUALLY chose
key_len: how many bytes of the index were used
(for composite indexes — tells you how many columns were used)
key = NULL → no index used → full table scan → likely needs an index
Column: rows — Estimated Rows Scanned
The optimizer's estimate of rows it will examine.
This is an ESTIMATE based on statistics — not the actual count.
rows = 1 → excellent (single row lookup)
rows = 50000 → acceptable if filtered well after
rows = 10000000 → red flag — scanning most of the table
Column: Extra — Additional Information
Using index → COVERING INDEX hit. No table lookup needed. ✅ Best case.
Using where → WHERE filter applied after index scan (not all index)
Using index condition → Index Condition Pushdown (ICP). Filter pushed into index scan. ✅
Using temporary → Query requires a temp table (GROUP BY, DISTINCT). ❌ Expensive.
Using filesort → Sort cannot use index. External sort needed. ❌ Expensive.
Using join buffer → Join uses a buffer (no index on join column). ❌ Add index on join.
Select tables optimized away → Result computed from index statistics alone. ✅ Very fast.
Impossible WHERE → WHERE condition can never be true. Query returns nothing.
EXPLAIN ANALYZE — Actual Execution (MySQL 8.0+, PostgreSQL)
-- EXPLAIN shows estimated plan
-- EXPLAIN ANALYZE actually RUNS the query and shows real numbers
EXPLAIN ANALYZE
SELECT * FROM orders WHERE user_id = 1 ORDER BY created_at DESC LIMIT 20;
-- MySQL 8.0+ output:
-> Limit: 20 row(s) (actual time=0.523..0.531 rows=20 loops=1)
-> Sort: orders.created_at DESC (actual time=0.520..0.525 rows=20 loops=1)
-> Index lookup on orders using idx_user_id (user_id=1)
(actual time=0.089..0.312 rows=147 loops=1)
-- PostgreSQL output:
Limit (cost=0.43..5.22 rows=20 width=68) (actual time=0.089..0.112 rows=20 loops=1)
-> Index Scan using idx_user_orders on orders
(cost=0.43..38.2 rows=147 width=68) (actual time=0.085..0.105 rows=20 loops=1)
Index Cond: (user_id = 1)
Planning Time: 0.231 ms
Execution Time: 0.156 ms
Key difference:
EXPLAIN: estimated cost and rows (safe, doesn't execute)
EXPLAIN ANALYZE: actual timing and row counts (executes the query!)
For slow queries on large tables → may take long to run
For DELETE/UPDATE → use with caution (actually modifies data)
Wrap in BEGIN/ROLLBACK to safely analyze write queries:
BEGIN;
EXPLAIN ANALYZE DELETE FROM orders WHERE status = 'CANCELLED';
ROLLBACK;
Common Slow Query Patterns and Fixes
Pattern 1 — Missing Index (Full Table Scan)
-- ❌ Slow: no index on email, full scan of 10M rows
SELECT * FROM users WHERE email = 'alice@email.com';
-- EXPLAIN shows: type=ALL, rows=10000000
-- ✅ Fix: add index
CREATE INDEX idx_users_email ON users(email);
-- EXPLAIN shows: type=ref, rows=1
Pattern 2 — Function on Indexed Column (Index Bypass)
-- ❌ Slow: YEAR() function prevents index usage
SELECT * FROM orders WHERE YEAR(created_at) = 2024;
-- EXPLAIN: type=ALL (index on created_at is bypassed)
-- ✅ Fix: use range query instead
SELECT * FROM orders
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';
-- EXPLAIN: type=range (index used)
-- Other common violations:
WHERE LOWER(email) = 'alice@email.com' -- ❌ use index on LOWER(email)
WHERE DATE(created_at) = '2024-01-15' -- ❌ use range query
WHERE CONCAT(first_name,' ',last_name) = X -- ❌ store full_name separately
Pattern 3 — Leading Wildcard (LIKE)
-- ❌ Slow: leading % forces full scan
SELECT * FROM products WHERE name LIKE '%shoes%';
-- EXPLAIN: type=ALL
-- ✅ Fix option 1: Use full-text search for text matching
ALTER TABLE products ADD FULLTEXT INDEX ft_name (name);
SELECT * FROM products WHERE MATCH(name) AGAINST ('shoes');
-- ✅ Fix option 2: Use Elasticsearch/OpenSearch for product search
-- Don't do text search in relational DB at scale
-- Index products into Elasticsearch, query there
-- ✅ Trailing wildcard is OK (uses B-Tree prefix):
SELECT * FROM users WHERE name LIKE 'Ali%';
-- EXPLAIN: type=range (index used)
Pattern 4 — N+1 Query Problem
-- ❌ N+1: 1 query to get 100 users, then 100 queries for their orders
users = SELECT * FROM users LIMIT 100; -- 1 query
FOR each user:
orders = SELECT * FROM orders -- 100 queries!
WHERE user_id = user.id;
-- Total: 101 queries → terrible for 1000 users → 1001 queries
-- ✅ Fix: JOIN to get everything in one query
SELECT u.*, o.*
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
LIMIT 100;
-- Total: 1 query
-- ✅ Fix alternative: IN clause batch fetch
user_ids = [1, 2, 3, ..., 100]
SELECT * FROM orders WHERE user_id IN (1, 2, 3, ..., 100);
-- Total: 2 queries
N+1 is the most common performance killer in ORM-based apps.
Hibernate/JPA: use JOIN FETCH or batch fetching.
Sequelize: use include with eager loading.
Pattern 5 — SELECT * (Over-Fetching)
-- ❌ SELECT * fetches all columns even if you need only 2
SELECT * FROM orders WHERE user_id = 1;
-- Fetches: order_id, user_id, status, amount, shipping_address,
-- payment_method, notes, created_at, updated_at, ...
-- All transferred over network, all materialized in memory
-- ✅ Select only what you need
SELECT order_id, status, amount FROM orders WHERE user_id = 1;
-- Benefits:
-- Less network transfer
-- Smaller memory footprint
-- Enables covering index (if indexed columns match SELECT)
-- Query can avoid touching table entirely if using covering index
Pattern 6 — Inefficient Pagination (OFFSET)
-- ❌ Large OFFSET is slow: skip 1,000,000 rows then return 20
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 1000000;
-- DB must generate first 1,000,020 rows, then discard 1,000,000 → wasteful
-- ✅ Fix: Keyset Pagination (Cursor-based)
-- First page:
SELECT * FROM orders ORDER BY created_at DESC, order_id DESC LIMIT 20;
-- Returns rows: last row has created_at='2024-01-15', order_id=9876
-- Next page: use last row's values as cursor
SELECT * FROM orders
WHERE (created_at, order_id) < ('2024-01-15', 9876)
ORDER BY created_at DESC, order_id DESC
LIMIT 20;
-- Uses index on (created_at, order_id) → O(log n) regardless of page depth
-- ✅ Alternative: Remember the last ID (simpler if sorted by PK)
SELECT * FROM orders WHERE order_id < 9876 ORDER BY order_id DESC LIMIT 20;
Pattern 7 — Missing JOIN Index
-- ❌ Join without index on join column (right side)
SELECT u.name, o.amount
FROM users u
JOIN orders o ON u.user_id = o.user_id; -- user_id in orders has no index
-- EXPLAIN shows: Using join buffer (Block Nested Loop)
-- For each row in users, scan entire orders table → O(n×m)
-- users=1M, orders=10M → 10 trillion comparisons
-- ✅ Fix: index the JOIN column on the many-side table
CREATE INDEX idx_orders_user_id ON orders(user_id);
-- EXPLAIN shows: eq_ref or ref, dramatically fewer rows examined
Pattern 8 — Implicit Type Conversion
-- Table: users.user_id is INT
-- ❌ String passed to INT column
SELECT * FROM users WHERE user_id = '123';
-- MySQL converts '123' to INT → still uses index (MySQL handles this)
-- BUT: if index column is VARCHAR and you pass INT:
-- Table: sessions.token is VARCHAR
SELECT * FROM sessions WHERE token = 12345; -- INT passed to VARCHAR column
-- MySQL must convert every token to INT → full table scan → index bypassed ❌
-- ✅ Always match data types in queries to column definitions
SELECT * FROM sessions WHERE token = '12345'; -- VARCHAR → VARCHAR ✅
Join Algorithms — What the Optimizer Chooses
1. Nested Loop Join (NLJ)
For each row in outer table, scan inner table for matches.
Efficient when inner table is small or well-indexed.
O(n × m) without index, O(n × log m) with index on inner.
EXPLAIN Extra: (nothing special, or Using index)
Best for: small tables, or when inner table has index on join column.
2. Block Nested Loop Join (BNL)
Loads outer table rows into a join buffer (block).
For each buffer load, scans inner table once.
Better than NLJ when inner has no index (fewer scans).
EXPLAIN Extra: Using join buffer (Block Nested Loop)
Sign: index missing on join column → add index to fix.
3. Hash Join (PostgreSQL, MySQL 8.0+)
Build phase: hash table from smaller table on join key.
Probe phase: scan larger table, probe hash table for matches.
O(n + m) — very efficient for large tables with no useful indexes.
EXPLAIN Extra: (hash) or Hash Join in PostgreSQL EXPLAIN
Best for: large tables, equi-joins, no good index available.
Memory intensive: join buffer must fit in memory.
4. Merge Join (Sort-Merge Join)
Sort both tables on join key, then merge like merge-sort.
Efficient when both inputs are already sorted (via index).
O(n log n + m log m) for sort, O(n + m) for merge.
Best for: range joins, both tables large and sortable.
Query Rewriting Techniques
1. Subquery → JOIN (Usually Faster)
-- ❌ Correlated subquery: runs once per row
SELECT * FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.user_id
AND o.created_at > '2024-01-01'
);
-- ✅ Rewrite as JOIN (single pass)
SELECT DISTINCT u.*
FROM users u
JOIN orders o ON u.user_id = o.user_id
WHERE o.created_at > '2024-01-01';
2. OR → UNION (When OR Prevents Index Use)
-- ❌ OR across different indexed columns — optimizer may not use both indexes
SELECT * FROM orders
WHERE user_id = 1 OR product_id = 'P01';
-- ✅ UNION uses each index separately
SELECT * FROM orders WHERE user_id = 1
UNION
SELECT * FROM orders WHERE product_id = 'P01';
-- Each part uses its own index → combined with UNION dedup
3. NOT IN → LEFT JOIN / NOT EXISTS
-- ❌ NOT IN with subquery — slow, and fails silently if subquery returns NULL
SELECT * FROM users
WHERE user_id NOT IN (SELECT user_id FROM orders WHERE status = 'BANNED');
-- ✅ LEFT JOIN (faster and NULL-safe)
SELECT u.*
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id AND o.status = 'BANNED'
WHERE o.user_id IS NULL;
-- ✅ NOT EXISTS (also good, optimizer often converts internally)
SELECT * FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.user_id AND o.status = 'BANNED'
);
Slow Query Log — Finding Problems Proactively
-- Enable slow query log (MySQL)
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1; -- log queries taking > 1 second
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
-- Analyze slow query log
-- mysqldumpslow tool (MySQL)
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
-- -s t: sort by total query time
-- -t 10: top 10 queries
-- pt-query-digest (Percona Toolkit — more powerful)
pt-query-digest /var/log/mysql/slow.log
-- PostgreSQL: pg_stat_statements extension
SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
-- Shows top 10 queries by total execution time
-- Most valuable for finding repeated slow queries
Query Optimization Checklist
Before writing a query:
□ Do I need all these columns? → remove SELECT *
□ Can I filter more rows early? → push WHERE conditions to reduce data early
□ Is this join necessary? → can I denormalize or cache?
After writing a query:
□ Run EXPLAIN — check type column for 'ALL'
□ Check key column — is an index being used?
□ Check rows — is the estimated row count reasonable?
□ Check Extra — 'Using filesort' or 'Using temporary' = problem
□ Are all JOIN columns indexed on the many-side table?
□ Any functions on indexed columns in WHERE clause?
If query is slow despite indexes:
□ Run ANALYZE TABLE to refresh statistics
□ Check if index selectivity is too low
□ Consider composite/covering index
□ Check for lock contention (SHOW PROCESSLIST)
□ Check for N+1 pattern in application layer
□ Consider caching frequently-read results in Redis
System Design Context
Query Optimization at Scale
1 query × 100ms × 1 request/second = 100ms total per second (fine)
1 query × 100ms × 10,000 requests/sec = 1,000,000ms total per second
= 16.7 minutes of DB time per second
→ impossible → DB saturated
Optimization target: p99 latency
p99 = 99th percentile — 99% of queries complete within this time
If p99 = 500ms → 1% of users see 500ms+ → too slow for API
Target: p99 < 50ms for read queries, < 100ms for write queries
Tools used at scale:
Slow query log → find worst offenders
APM tools (Datadog, New Relic) → trace queries per request
Database proxies (ProxySQL) → query analysis, caching, routing
Read replicas → offload read queries from primary
Query result caching (Redis) → bypass DB entirely for hot reads
When Optimization Isn't Enough
Even with perfect indexes, a single DB node has limits:
~10,000-50,000 simple queries/second on modern hardware
Complex queries: much lower
Beyond single-node capacity:
Read replicas → scale reads horizontally (Topic 15)
Sharding → scale writes horizontally (Topic 16)
Caching → eliminate DB calls for hot data (Module 6 in roadmap)
CQRS → separate read model optimized for queries (later topic)
Query optimization is the FIRST step.
Architecture changes (caching, sharding) come when optimization hits its ceiling.
How This Connects
- Indexing - B-Tree & Hash Indexes / Composite & Covering Indexes — 90% of query tuning is "add the right index" or "make the existing one cover / reorder its columns"; EXPLAIN is how you confirm it landed.
- SQL - Joins, Aggregations, Subqueries — the join algorithms (nested loop / hash / merge) and the rewrites (subquery→join, OR→UNION, NOT IN→NOT EXISTS) live at the intersection of the two notes.
- Concurrency Control - Locks & MVCC — a slow query isn't only I/O: lock waits and MVCC version chains (from long transactions) also show up as latency.
- Module 6 — Caching — when the query is already optimal and still too hot, the next layer is Redis result caching, then read replicas.
- Module 4 — Sharding / CQRS — when a single node can't keep up even with perfect plans, you split reads/writes or shard; a denormalized read model removes the expensive join entirely.
- Module 8 — Observability — the slow query log,
pg_stat_statements, and APM traces are how you find the query worth optimizing.
In the Interview
When to say it. Any "this endpoint got slow", any "the DB is at 90% CPU", any follow-up after you propose a schema — "show me the query and how you'd make it fast".
The ~20-second version:
"I start with
EXPLAIN. Thetypecolumn first —ALLmeans a full table scan, so the fix is almost always an index on the WHERE or JOIN columns. TheExtracolumn next —Using filesortmeans the ORDER BY isn't served by an index,Using temporarymeans a GROUP BY or DISTINCT is building a temp table, andUsing indexis the goal: a covering index answering the query without touching the table. Then I look for the classic anti-patterns: a function wrapping an indexed column so the index can't be used, an ORM N+1 that should be one join, deepOFFSETpagination that should be keyset, and unindexed join columns. I runANALYZEto refresh statistics, verify withEXPLAIN ANALYZEthat estimated and actual rows agree, and if the query is already optimal I move up a layer — cache the result, add read replicas, or reshape the data model."
Follow-ups to expect:
- "
WHERE YEAR(created_at) = 2024is slow — why and fix?" — The function makes it non-sargable; the index oncreated_atcan't be traversed. Rewrite ascreated_at >= '2024-01-01' AND created_at < '2025-01-01'. - "Page 50,000 of results loads slowly —
LIMIT 20 OFFSET 1000000." — The DB generates and discards a million rows. Switch to keyset/cursor pagination:WHERE (created_at, id) < (:last_ts, :last_id) ORDER BY ... LIMIT 20. - "EXPLAIN says
rows: 5but the query still takes 2s." — Stale statistics (runANALYZE), or the cost is elsewhere:Using filesort/Using temporary, lock waits, or a huge row width forcing lots of I/O per row. - "N+1 — how do you spot and fix it?" — 1 query for the list + N for each item's children. Fix with a
JOIN FETCH/ eager load, or a singleWHERE id IN (...)batch. - "Optimization isn't enough — what next?" — Redis for hot reads, read replicas for read scaling, denormalized read model / CQRS, then sharding for write scaling.
Mistake to avoid: adding indexes blindly until it's fast. Each one taxes writes, and the planner may still ignore a non-selective or function-shadowed one. Read the plan, make one targeted change, re-measure.
Quick Revision — 7 Lines
- EXPLAIN type column: ALL=full scan❌, range=index range✅, ref=index lookup✅, const=single row✅
- Extra column: 'Using index'=covering index✅, 'Using filesort'=sort needs fix❌, 'Using temporary'=temp table❌
- Functions on indexed columns bypass indexes — use range queries instead
- N+1 problem: 1 query + N queries per row → fix with JOIN or IN batch fetch
- Large OFFSET pagination is O(n) → replace with keyset/cursor pagination
- Always index JOIN columns on the many-side table — missing join index → join buffer (slow)
- Optimization order: indexes first → query rewrite → statistics → caching → read replicas → sharding