🔥 0
0
Lesson 4 of 5 20 min +200 XP

Query Optimization with EXPLAIN

You've created indexes, but are they being used? EXPLAIN shows exactly how the database executes your query. Master this and you'll never guess about performance again.

Basic EXPLAIN

EXPLAIN SELECT * FROM users WHERE email = 'john@example.com';

Output:

                         QUERY PLAN
-------------------------------------------------------------
 Index Scan using idx_users_email on users  (cost=0.42..8.44 rows=1 width=72)
   Index Cond: (email = 'john@example.com'::text)

Let's break down what this means.

Understanding Scan Types

Scan Type What It Does Performance
Seq Scan Reads every row in table Slow for large tables
Index Scan Uses index, fetches rows from table Fast for selective queries
Index Only Scan Uses index only, no table access Fastest (covering index)
Bitmap Index Scan Builds bitmap of matching rows Good for many matches
Bitmap Heap Scan Fetches rows using bitmap Paired with Bitmap Index Scan

Visual Comparison

Seq Scan

[Row1] [Row2] [Row3]...
Check each row

Index Scan

Index → Row ID → Table
Jump to exact rows

Index Only Scan

Index → Data
No table access!

EXPLAIN ANALYZE: Real Execution Stats

EXPLAIN ANALYZE actually runs the query and shows real timings:
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'john@example.com';

Output:

 Index Scan using idx_users_email on users
   (cost=0.42..8.44 rows=1 width=72)
   (actual time=0.025..0.027 rows=1 loops=1)
   Index Cond: (email = 'john@example.com'::text)
 Planning Time: 0.085 ms
 Execution Time: 0.045 ms

Reading the Numbers

Metric Meaning Example
cost=0.42..8.44 Estimated startup..total cost (arbitrary units) Lower is better
rows=1 Estimated rows returned Planner's guess
actual time=0.025..0.027 Real startup..total time (ms) Actual measurement
rows=1 Actual rows returned Real count
loops=1 Times this step executed Important for nested loops

Common Issues and Solutions

Issue 1: Seq Scan When Index Exists

EXPLAIN SELECT * FROM users WHERE LOWER(email) = 'john@example.com';
 Seq Scan on users  (cost=0.00..20406.00 rows=5000 width=72)
   Filter: (lower(email) = 'john@example.com'::text)
Problem: Function on column prevents index use. Solution: Create a functional index:
CREATE INDEX idx_users_email_lower ON users(LOWER(email));

Issue 2: Index Scan With High Row Count

EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'completed';
 Index Scan using idx_orders_status on orders
   (cost=0.42..15234.56 rows=450000 width=120)
   (actual time=0.052..892.341 rows=456123 loops=1)
Problem: Index finds 450K rows, but must fetch each from table (random I/O). Solutions:
Add covering columns

Include SELECT columns in index to avoid table lookup

Use LIMIT

If you only need first N rows, add LIMIT

Partition table

Split by status if queried frequently

Issue 3: Wrong Index Chosen

-- Index exists on (user_id, created_at)
-- But query uses different index

EXPLAIN SELECT * FROM orders
WHERE user_id = 123 AND status = 'pending'
ORDER BY created_at DESC;
Solution: Check if a better composite index exists or create one:
CREATE INDEX idx_orders_user_status_date
ON orders(user_id, status, created_at DESC);

Issue 4: Outdated Statistics

-- Planner estimates 100 rows, but actually returns 100,000
-- This causes poor plan choices

EXPLAIN ANALYZE SELECT * FROM products WHERE category = 'electronics';
 Seq Scan on products  (cost=0.00..1234.00 rows=100 width=85)
                       (actual rows=98543)
Solution: Update statistics:
ANALYZE products;
-- or for all tables
ANALYZE;

EXPLAIN Cheat Sheet

-- Basic plan (estimates only)
EXPLAIN SELECT ...;

-- With actual timings (runs the query!)
EXPLAIN ANALYZE SELECT ...;

-- With buffer/IO statistics
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;

-- Formatted output options
EXPLAIN (FORMAT JSON) SELECT ...;
EXPLAIN (FORMAT YAML) SELECT ...;

Reading Complex Plans

EXPLAIN ANALYZE
SELECT u.name, COUNT(o.id)
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.status = 'completed'
GROUP BY u.id;
 HashAggregate  (cost=2345.67..2400.00 rows=500 width=40)
   Group Key: u.id
   ->  Hash Join  (cost=123.45..2000.00 rows=15000 width=36)
         Hash Cond: (o.user_id = u.id)
         ->  Index Scan using idx_orders_status on orders o
               (cost=0.42..1500.00 rows=15000 width=8)
               Index Cond: (status = 'completed'::text)
         ->  Hash  (cost=100.00..100.00 rows=1000 width=36)
               ->  Seq Scan on users u  (cost=0.00..100.00 rows=1000)

Read bottom-up, innermost operations first:

  • Seq Scan on users - Reads all users (small table, fast)
  • Hash - Builds hash table from users
  • Index Scan on orders - Finds completed orders using index
  • Hash Join - Matches orders to users using hash table
  • HashAggregate - Groups and counts
Pro Tip: Look for Red Flags
  • Nested Loop with high loops count - Consider better join strategy
  • Sort with high rows - Consider index with sort order
  • Seq Scan on large table - Missing or unused index
  • rows estimate far from actual - Run ANALYZE

Visual EXPLAIN Tools

For complex queries, use visual tools:

  • pgAdmin - Built-in graphical EXPLAIN
  • explain.depesz.com - Paste your EXPLAIN output
  • explain.dalibo.com - Interactive visualization

Key Takeaways

  • Always use EXPLAIN ANALYZE - Estimates can be misleading
  • Seq Scan isn't always bad - Small tables or returning most rows
  • Index Only Scan is the goal - Covering indexes avoid table access
  • Keep statistics updated - Run ANALYZE after major data changes

Next up: Indexing Assessment - Test your knowledge with real-world scenarios.

🧠 Quick Quiz

Test your understanding of this lesson.

1

What does 'Seq Scan' in EXPLAIN output mean?

2

You see 'Index Scan using idx_email' but the query is slow. What might be wrong?

3

EXPLAIN ANALYZE shows 'actual time=0.015..45.230'. What does this mean?