Fatskills
Practice. Master. Repeat.
Study Guide: TECH **SQL for Data Analysis: EXPLAIN, EXPLAIN ANALYZE, and Cost-Based Optimization**
Source: https://www.fatskills.com/data-science/chapter/tech-sql-for-data-analysis-explain-explain-analyze-and-cost-based-optimization

TECH **SQL for Data Analysis: EXPLAIN, EXPLAIN ANALYZE, and Cost-Based Optimization**

By Fatskills Exam Guides Team — the exam nerds behind 28,500+ quizzes and 2.1M practice questions across 500+ global exams.

⏱️ ~9 min read

SQL for Data Analysis: EXPLAIN, EXPLAIN ANALYZE, and Cost-Based Optimization

Hyper-practical guide for debugging slow queries, optimizing performance, and acing hands-on SQL exams


1. What This Is & Why It Matters

You’re a data analyst at a mid-sized e-commerce company. Your dashboard, which used to load in 2 seconds, now takes 30 seconds—and your boss is breathing down your neck. The query looks fine, but PostgreSQL (or MySQL, or SQL Server) is choking on it. How do you fix it?

This is where EXPLAIN and EXPLAIN ANALYZE come in. They’re your X-ray machine for SQL queries—letting you see exactly how the database executes a query, where it’s wasting time, and how to optimize it.

Why this matters in production:
- Slow queries = unhappy users. A 5-second query run 10,000 times/day costs CPU, memory, and money (especially in cloud databases like AWS RDS or Google BigQuery).
- Cost-based optimization (CBO) is how modern databases automatically pick the fastest way to run a query—but it only works if you feed it the right data (indexes, statistics, etc.).
- Ignoring EXPLAIN = flying blind. You’ll waste hours tweaking queries that look slow but are actually bottlenecked by missing indexes, full table scans, or inefficient joins.

Real-world scenario:
You inherit a legacy SQL query that aggregates sales data. It runs in 12 minutes on a 10GB table. Your boss wants it under 30 seconds. Without EXPLAIN, you’re guessing. With it, you’ll see the bottleneck in 60 seconds (e.g., a missing index on order_date or a nested loop join killing performance).


2. Core Concepts & Components


1. EXPLAIN

  • Definition: A command that shows the query execution plan without running the query.
  • Production insight: Always run EXPLAIN before EXPLAIN ANALYZE—it’s faster and tells you if the plan is obviously bad (e.g., full table scans).
  • Example:
    sql EXPLAIN SELECT * FROM orders WHERE customer_id = 100; Output: Seq Scan on orders (cost=0.00..15.00 rows=5 width=36)
    Filter: (customer_id = 100)
    ⚠️ Red flag: Seq Scan (sequential scan) = full table scan. If orders has 1M rows, this is slow.


2. EXPLAIN ANALYZE

  • Definition: Runs the query and shows the actual execution plan + timing metrics.
  • Production insight: Use this to compare estimated vs. actual costs (e.g., the database thought it would scan 100 rows but actually scanned 1M).
  • Example:
    sql EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 100; Output: Seq Scan on orders (cost=0.00..15.00 rows=5 width=36) (actual time=0.123..1.456 rows=3 loops=1)
    Filter: (customer_id = 100)
    Rows Removed by Filter: 999997 Planning Time: 0.059 ms Execution Time: 1.478 ms
    ⚠️ Red flag: Rows Removed by Filter: 999997 = the database scanned 1M rows to find 3 matches. Fix: Add an index on customer_id.


3. Cost-Based Optimization (CBO)

  • Definition: The database’s automated query planner that picks the fastest execution plan based on statistics (e.g., table size, index selectivity, data distribution).
  • Production insight: If statistics are outdated, the CBO will make terrible decisions (e.g., picking a nested loop join when a hash join would be 100x faster).
  • How it works:
  • The database estimates cost (a unitless number representing CPU + I/O effort).
  • Lower cost = better plan.
  • Example:
    sql
    EXPLAIN SELECT * FROM orders WHERE order_date > '2023-01-01';

    Output:
    Bitmap Heap Scan on orders (cost=12.34..56.78 rows=1000 width=36)
    Recheck Cond: (order_date > '2023-01-01'::date)
    -> Bitmap Index Scan on idx_orders_date (cost=0.00..12.09 rows=1000 width=0)
    Index Cond: (order_date > '2023-01-01'::date)

    Good: Uses an index (Bitmap Index Scan).
    Bad: If the plan shows Seq Scan instead, the CBO thinks a full scan is cheaper (likely due to outdated statistics).


4. Key Plan Nodes (What You’ll See in EXPLAIN)

Node Type What It Means Production Insight
Seq Scan Full table scan. Bad for large tables. Fix: Add an index.
Index Scan Uses an index to find rows. Good. Fast for selective queries (e.g., WHERE id = 100).
Index Only Scan Only reads from the index (no table access). Best. Works if all needed columns are in the index.
Bitmap Heap Scan Uses a bitmap to fetch rows from a table after an index scan. Good for range queries (e.g., WHERE date BETWEEN ...).
Nested Loop Joins two tables by looping through one and probing the other. Bad for large tables. Fix: Use Hash Join or Merge Join if possible.
Hash Join Builds a hash table for one table, then probes it with the other. Good for large joins. Requires enough memory.
Merge Join Sorts both tables, then merges them. Good for pre-sorted data (e.g., if both tables are indexed on the join key).
Sort Explicitly sorts data (e.g., for ORDER BY). Expensive. Avoid if possible (e.g., use an index to avoid sorting).
Aggregate Computes GROUP BY or COUNT(). Can be slow on large datasets. Consider pre-aggregating data.


5. Statistics (The Fuel for CBO)

  • Definition: Metadata the database collects about tables (e.g., row count, column distribution, index selectivity).
  • Production insight: If statistics are stale, the CBO will pick bad plans. Example:
  • A table grows from 10K to 1M rows, but statistics aren’t updated → CBO still thinks it’s small → picks a nested loop join instead of a hash join.
  • How to update statistics:
    ```sql -- PostgreSQL ANALYZE orders;

-- MySQL ANALYZE TABLE orders;

-- SQL Server UPDATE STATISTICS orders; ```


6. Index Selectivity

  • Definition: How unique values in a column are. High selectivity = good for indexing.
  • Example: customer_id (unique) has high selectivity → great for indexing.
  • gender (only 2 values) has low selectivity → bad for indexing.
  • Production insight: Adding an index to a low-selectivity column wastes space and slows down writes without helping reads.


7. Query Hints (Forcing the CBO’s Hand)

  • Definition: Override the CBO’s plan with hints (e.g., force a hash join).
  • Production insight: Use sparingly—hints can backfire if data changes. Example: ```sql -- PostgreSQL: Force a hash join SELECT /+ HashJoin(orders customers) / * FROM orders JOIN customers ON orders.customer_id = customers.id;

-- MySQL: Force an index SELECT * FROM orders FORCE INDEX (idx_customer_id) WHERE customer_id = 100; ```


3. Step-by-Step Hands-On: Debugging a Slow Query


Prerequisites

  • A PostgreSQL/MySQL/SQL Server database (local or cloud).
  • A table with at least 100K rows (use a sample dataset like TPC-H or generate one with generate_series).
  • Basic SQL knowledge (you can write SELECT, JOIN, GROUP BY).

Task: Optimize a Slow Sales Query

Scenario:
You have a query that aggregates daily sales:


SELECT
date_trunc('day', order_date) AS day,
COUNT(*) AS orders,
SUM(amount) AS revenue FROM orders WHERE order_date BETWEEN '2023-01-01' AND '2023-12-31' GROUP BY day ORDER BY day;

It takes 15 seconds to run. Your goal: Get it under 1 second.


Step 1: Run EXPLAIN to See the Plan

EXPLAIN
SELECT
date_trunc('day', order_date) AS day,
COUNT(*) AS orders,
SUM(amount) AS revenue FROM orders WHERE order_date BETWEEN '2023-01-01' AND '2023-12-31' GROUP BY day ORDER BY day;

Expected output (PostgreSQL):


Sort  (cost=1234.56..1234.57 rows=1 width=24)
  Sort Key: (date_trunc('day'::text, order_date))
  ->  HashAggregate  (cost=1234.00..1234.50 rows=1 width=24)
Group Key: (date_trunc('day'::text, order_date))
-> Seq Scan on orders (cost=0.00..1000.00 rows=10000 width=12)
Filter: ((order_date >= '2023-01-01'::date) AND (order_date <= '2023-12-31'::date))

⚠️ Red flags:
1. Seq Scan = full table scan (slow on large tables).
2. Filter = no index used for order_date.


Step 2: Run EXPLAIN ANALYZE to Get Actual Timings

EXPLAIN ANALYZE
SELECT
date_trunc('day', order_date) AS day,
COUNT(*) AS orders,
SUM(amount) AS revenue FROM orders WHERE order_date BETWEEN '2023-01-01' AND '2023-12-31' GROUP BY day ORDER BY day;

Expected output:


Sort  (cost=1234.56..1234.57 rows=1 width=24) (actual time=14567.890..14567.891 rows=365 loops=1)
  Sort Key: (date_trunc('day'::text, order_date))
  Sort Method: external merge  Disk: 24kB
  ->  HashAggregate  (cost=1234.00..1234.50 rows=1 width=24) (actual time=14567.800..14567.850 rows=365 loops=1)
Group Key: (date_trunc('day'::text, order_date))
-> Seq Scan on orders (cost=0.00..1000.00 rows=10000 width=12) (actual time=0.123..12345.678 rows=1000000 loops=1)
Filter: ((order_date >= '2023-01-01'::date) AND (order_date <= '2023-12-31'::date))
Rows Removed by Filter: 0 Planning Time: 0.123 ms Execution Time: 14568.012 ms

⚠️ Key insights:
1. actual time=14567.890..14567.891 = 14.5 seconds spent sorting.
2. Seq Scan took 12.3 seconds and scanned 1M rows.
3. Rows Removed by Filter: 0 = no rows were filtered out (the WHERE clause matched all rows).


Step 3: Add an Index to Fix the Full Scan

CREATE INDEX idx_orders_order_date ON orders(order_date);

Now re-run EXPLAIN ANALYZE:


EXPLAIN ANALYZE
SELECT
date_trunc('day', order_date) AS day,
COUNT(*) AS orders,
SUM(amount) AS revenue FROM orders WHERE order_date BETWEEN '2023-01-01' AND '2023-12-31' GROUP BY day ORDER BY day;

New output:


Sort  (cost=567.89..567.90 rows=1 width=24) (actual time=45.678..45.679 rows=365 loops=1)
  Sort Key: (date_trunc('day'::text, order_date))
  Sort Method: quicksort  Memory: 45kB
  ->  HashAggregate  (cost=567.00..567.50 rows=1 width=24) (actual time=45.600..45.650 rows=365 loops=1)
Group Key: (date_trunc('day'::text, order_date))
-> Bitmap Heap Scan on orders (cost=12.34..500.00 rows=10000 width=12) (actual time=0.123..23.456 rows=1000000 loops=1)
Recheck Cond: ((order_date >= '2023-01-01'::date) AND (order_date <= '2023-12-31'::date))
-> Bitmap Index Scan on idx_orders_order_date (cost=0.00..12.09 rows=10000 width=0) (actual time=0.050..0.051 rows=1000000 loops=1)
Index Cond: ((order_date >= '2023-01-01'::date) AND (order_date <= '2023-12-31'::date)) Planning Time: 0.100 ms Execution Time: 45.800 ms

Improvements:
1. Bitmap Heap Scan + Bitmap Index Scan = uses the index (no full scan).
2. Execution time dropped from 14.5s → 45ms (300x faster!).
3. Sort Method: quicksort = in-memory sort (faster than external merge).


Step 4: Optimize the GROUP BY (If Still Slow)

If the query is still slow, try: 1. Pre-aggregating data (e.g., materialized views).
2. Covering index (include amount in the index to avoid table access):
sql
CREATE INDEX idx_orders_covering ON orders(order_date) INCLUDE (amount);
3. Partitioning (if the table is huge, e.g., 100M+ rows).


4. ? Production-Ready Best Practices


Security

  • Least privilege: Don’t grant EXPLAIN to all users—it can expose query plans (which might reveal sensitive data patterns).
  • Mask sensitive columns: If EXPLAIN shows Filter: (ssn = '123-45-6789'), you’re leaking PII.

Cost Optimization

  • Index wisely:
  • Add indexes for high-selectivity columns (e.g., customer_id, order_date).
  • Avoid indexes on low-selectivity columns (e.g., gender, status).
  • Update statistics regularly:
    ```sql -- PostgreSQL: Auto-vacuum updates stats, but you can force it ANALYZE orders;

-- MySQL: Run this weekly ANALYZE TABLE orders; `` - AvoidSELECT `:* It prevents index-only scans.

Reliability & Maintainability

  • Document slow queries: Add comments like: sql -- This query uses idx_orders_order_date. If it slows down, check: -- 1. Is the index still there? (DROP INDEX can happen accidentally) -- 2. Are statistics up to date? (ANALYZE orders) SELECT ... FROM orders WHERE order_date BETWEEN ...;
  • Use query hints sparingly: They can backfire if data changes.
  • Monitor query performance: Set up alerts for:
  • Queries taking > 1s.
  • Full table scans (Seq Scan in PostgreSQL, Full Table Scan in Oracle).

Observability

  • Log slow queries:
    ```sql -- PostgreSQL: Log queries taking > 1s ALTER SYSTEM SET log_min_duration_statement = 1000;

-- MySQL: Log slow queries SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 1; `` - Track plan changes: If a query suddenly slows down, compareEXPLAIN` plans before/after.


5. ⚠️ Common Mistakes & Traps

Mistake Symptom Fix/Prevention
Missing indexes EXPLAIN shows Seq Scan on large tables. Add indexes on high-selectivity columns (e.g., WHERE customer_id = 100).
Outdated statistics CBO picks a bad plan (e.g., nested loop instead of hash join). Run ANALYZE regularly (or set up auto-vacuum).
Over-indexing Write queries slow down (indexes add overhead to INSERT/UPDATE/DELETE). Only index columns used in WHERE, JOIN, or ORDER BY.
Ignoring `EXPLA


ADVERTISEMENT