This episode covers how to analyze and optimize queries: reading the execution plan with EXPLAIN and EXPLAIN ANALYZE, understanding Sequential Scan Index Scan and Bitmap, join algorithms Nested Loop Hash and Merge Join, and identifying slow queries with pg_stat_statements.

Welcome to episode 15 of the Learn SQL with PostgreSQL series! In episode 14 we learned how to create indexes — but how do we know an index is actually being used? How do we find out why a query is slow? The answer isn't guessing, it's reading the execution plan — the blueprint of how PostgreSQL runs your queries. This skill is a database developer's "superpower".
When there's a slow query, don't jump straight to guessing solutions. Run EXPLAIN ANALYZE, read it carefully, and let the data speak. Is the query doing a full table scan when it should use an index? Did the join pick the wrong algorithm? All the answers are in the execution plan.
In this episode, we'll cover the EXPLAIN and EXPLAIN ANALYZE commands, understand execution plan components like Sequential Scan, Index Scan, and Bitmap Index Scan, the three join algorithms (Nested Loop, Hash Join, Merge Join), how to read the cost, rows, width, actual time, and loops metrics, and how to identify slow queries in production with the pg_stat_statements extension.
EXPLAIN shows the execution plan estimated by the planner — without actually running the query. EXPLAIN ANALYZE runs the real query and shows actual statistics. For real performance analysis, always use EXPLAIN ANALYZE.
EXPLAIN ANALYZE
SELECT * FROM users WHERE email = 'budi@example.com';Index Scan using idx_users_email on users (cost=0.29..8.31 rows=1 width=44)
Index Cond: (email = 'budi@example.com'::text)
Actual Time: 0.032..0.034 rows=1 loops=1
Planning Time: 0.143 ms
Execution Time: 0.045 msRead it inside-out: PostgreSQL uses Index Scan with the Index Cond condition. Execution time is 0.045 ms — very fast. If the output shows Seq Scan on a big table with Actual Time in the hundreds of milliseconds, that's a signal a missing index.
Tip
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) is the "full" version that shows I/O detail per node — very helpful for finding queries that read a lot from disk. For queries that write data, wrap them in a transaction: BEGIN; EXPLAIN ANALYZE UPDATE ...; ROLLBACK; so the changes aren't actually saved.
Three ways PostgreSQL accesses a table:
EXPLAIN ANALYZE SELECT * FROM orders WHERE total > 500000;If the matching rows are very numerous, the planner may choose Seq Scan because it's cheaper than using the index one-by-one. That doesn't mean the index is bad — the planner knows when an index isn't worth it.
The three join algorithms the planner chooses from:
| Algorithm | How it works | Best for |
|---|---|---|
| Nested Loop | For each outer row, find matches in the inner (with an index) | Small datasets or selective joins with an index |
| Hash Join | Build a hash table from the small table, match the big rows | Joining two large datasets without an index condition |
| Merge Join | Merge two already-sorted results | Joins that match an ORDER BY condition |
EXPLAIN ANALYZE
SELECT c.full_name, o.total
FROM customers c
JOIN orders o ON o.customer_id = c.id;The output will show Hash Join or Nested Loop — and from there you can judge whether an orders(customer_id) index needs to be created (a Nested Loop benefits greatly from an index on the inner side).
Each node shows these metrics:
start..total). An abstract unit — compare between plans, not as an absolute value.start..end time per loop, in milliseconds.loops on an inner scan marks an expensive Nested Loop.Warning
The most dangerous sign in an execution plan: an estimate that's far off from actual (e.g. rows=10 but actual rows=1.000.000). That means stale table statistics — run ANALYZE users; to refresh the statistics, or consider raising default_statistics_target for that column.
Not every slow query can be reproduced locally. In production with real load, we need a tool to find which queries consume the most time. The pg_stat_statements extension is the answer.
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;For the extension to work fully, the shared_preload_libraries = 'pg_stat_statements' parameter must be set in postgresql.conf and the server restarted. After that, the extension starts recording all executed queries.
SELECT
query,
calls,
total_exec_time / 1000 AS total_ms,
mean_exec_time / 1000 AS mean_ms,
rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;The results show the queries with the largest total execution time — the top optimization candidates. Pay attention to the calls column (frequency) and mean_exec_time (average): a query called thousands of times with a high average is the biggest problem.
Note
pg_stat_statements normalizes query parameters (literal values are replaced with placeholders), so the same query with different parameters is grouped into one row. To reset the statistics: SELECT pg_stat_statements_reset();. A reset is useful before A/B testing an optimization.
pg_stat_statements or the slow query log (log_min_duration_statement in postgresql.conf).EXPLAIN (ANALYZE, BUFFERS) on that query.Seq Scan on a big table? Do the rows estimates miss the actual?Execution Time before and after.| # | Mistake | Symptom | Solution |
|---|---|---|---|
| 1 | Using EXPLAIN alone | Estimates can mislead | Use EXPLAIN ANALYZE |
| 2 | Stale statistics | rows estimate far from actual | Run ANALYZE |
| 3 | Ignoring high loops | Hidden Nested Loop is very expensive | Check the index on the inner side |
| 4 | Optimizing without measuring | "Guessed" solutions that aren't verified | Compare times before/after |
In this episode 15, we've mastered evidence-based query optimization: reading the execution plan with EXPLAIN and EXPLAIN ANALYZE, understanding the differences between Sequential Scan, Index Scan, and Bitmap Index Scan, the three join algorithms (Nested Loop, Hash Join, Merge Join), the cost, rows, width, actual time, and loops metrics, and finding slow queries in production with pg_stat_statements.
Key takeaways:
EXPLAIN ANALYZE runs the real query — always use it for actual analysis.Seq Scan on a big table = a missing index signal; Bitmap Scan = many scattered matching rows.rows estimate vs actual mismatch indicates stale statistics → ANALYZE.pg_stat_statements is the surveillance camera for queries in production.In the next episode, episode 16, we get into security: Roles, Security Management & Row Level Security (RLS) — from the role concept that combines users and groups, the CREATE ROLE, GRANT, and REVOKE commands, to RLS for multi-tenant security at the row level, and securing pg_hba.conf.