ApiaryActive
Try: pause · settings · learn · wipe
← Community / Reading Room
UE
databases · 12 min read

Using EXPLAIN ANALYZE for Performance Tuning

Performance tuning is the quiet art that keeps data‑driven applications humming smoothly while the world outside buzzes with activity. In the same way that a…

Performance tuning is the quiet art that keeps data‑driven applications humming smoothly while the world outside buzzes with activity. In the same way that a healthy bee colony relies on efficient foraging routes and well‑timed communication, a database relies on well‑planned query execution paths. When those paths become inefficient, latency spikes, server load climbs, and the whole system can collapse under its own weight—just as a hive would if a forager kept taking the long way around the meadow.

EXPLAIN ANALYZE is the most direct line of sight we have into a PostgreSQL query’s inner workings. It shows not only the planner’s estimated cost but also the actual time, rows, and loop counts observed during execution. By learning to read that output, you gain a diagnostic tool that can spot the hidden “slow‑moving” operations that waste CPU cycles, I/O bandwidth, and, ultimately, the time of the people (or AI agents) who rely on the data. This article walks you through the anatomy of an EXPLAIN ANALYZE report, highlights the most common performance pitfalls, and demonstrates concrete, numbers‑driven fixes—complete with a real‑world example from a bee‑conservation analytics pipeline.


What EXPLAIN ANALYZE Actually Does

When you prepend a query with EXPLAIN ANALYZE, PostgreSQL performs two distinct steps:

  1. Planning – The optimizer builds a plan tree and assigns a cost to each node. The cost is a unitless number that approximates the amount of I/O and CPU work required.
  2. Execution – The engine runs the query, gathers actual statistics (actual startup time, total time, rows processed, loops), and then prints a merged report that juxtaposes the planner’s estimate against reality.
EXPLAIN ANALYZE
SELECT hive_id, avg(temp_c) 
FROM observations 
WHERE recorded_at >= '2024-01-01' 
GROUP BY hive_id;

Typical output (formatted for readability):

GroupAggregate  (cost=1245.00..1245.01 rows=1 width=12) (actual time=15.423..15.424 rows=42 loops=1)
  Group Key: hive_id
  ->  Sort  (cost=1245.00..1245.00 rows=1000 width=8) (actual time=15.418..15.418 rows=1000 loops=1)
        Sort Key: hive_id
        Sort Method: quicksort  Memory: 12kB
        ->  Seq Scan on observations  (cost=0.00..800.00 rows=1000 width=8) (actual time=0.012..9.876 rows=1,200,345 loops=1)
              Filter: (recorded_at >= '2024-01-01'::date)
Planning Time: 0.123 ms
Execution Time: 15.452 ms

Key take‑aways

FieldMeaningWhy it matters
costPlanner’s estimate (startup + total)Gives you a baseline to compare against actual time; large gaps indicate mis‑estimation.
actual timeReal wall‑clock time (ms) for startup and totalDirectly shows the latency the user experiences.
rowsEstimated vs. actual row countMis‑estimated rows often cause the planner to pick a sub‑optimal join method.
loopsHow many times the node was executed (important for nested loops)A node with high loops can amplify a small per‑iteration cost into a huge total cost.
MemoryAmount of RAM used for sorts, hashes, etc.Exceeding work_mem forces spills to disk, dramatically increasing time.

Understanding each column lets you pinpoint where the plan diverges from reality and why.


Interpreting the Output: Timing, Rows, Loops

Startup vs. Total Time

  • Startup time is the time spent before the first row can be emitted. For a Hash Join, this includes building the hash table.
  • Total time is the time after the last row is emitted. If startup is a large fraction of total, the operation may be a bottleneck even if per‑row processing is cheap.
Example: A Hash Join that spends 120 ms startup to build a 5 GB hash table but only 5 ms to stream the result will still appear slow if the hash table cannot stay in memory.

Rows vs. Loops

A node that processes 10 000 rows once (loops=1) is far less costly than a node that processes 10 000 rows 1 000 times (loops=1000). The latter often appears in poorly chosen Nested Loop joins.

Rule of thumb: If rows × loops > 1 million, scrutinize that node.

Memory Usage

Sort Method: quicksort indicates an in‑memory sort; external merge means the sort spilled to disk. The memory column tells you how close you are to the work_mem limit.

  • Concrete number: With work_mem = 64MB, a sort of 5 million rows (≈ 400 MB) will spill, adding 150 ms–2 s of I/O depending on SSD speed.

Common Costly Patterns

1. Sequential Scan on Large Tables

A sequential scan (Seq Scan) reads every page of a table. On a 30 GB table with 10 million rows, a simple scan can take 12–15 seconds on a typical SSD. If a filter condition is selective (e.g., returns < 0.5 % of rows), an index can reduce that to < 30 ms.

How to spot it: Look for Seq Scan nodes with a high actual rows count relative to the filter’s selectivity.

2. Nested Loop Joins with High Loop Counts

Nested Loop is optimal when the inner side is tiny (few rows) and the outer side is moderately sized. If the inner side contains thousands of rows, the total work becomes outer_rows × inner_rows.

Example:

Nested Loop  (cost=0.85..2500.00 rows=500 width=32) (actual time=0.023..120.456 rows=200 loops=1)
  ->  Seq Scan on hive_events  (cost=0.00..800.00 rows=2000 width=16) (actual time=0.010..30.123 rows=2000 loops=1)
  ->  Index Scan using observations_hive_id_idx on observations  (cost=0.42..0.84 rows=1 width=16) (actual time=0.001..0.002 rows=0 loops=2000)

The inner index scan runs 2 000 times, each costing ~0.002 ms. The total is 4 ms, which seems fine, but if the inner scan returns 100 rows instead of 1, the cost balloons to 400 ms.

Fixes:

  • Add a more selective index.
  • Rewrite the query to use JOIN with a Hash Join or Merge Join.
  • Materialize the inner side with a CTE and MATERIALIZED hint (PostgreSQL 14+).

3. Unnecessary Sorts and Hashes

Sorting (Sort) and hashing (Hash) are memory‑intensive. If the planner adds a sort because GROUP BY or ORDER BY clauses are present, but the underlying data is already ordered (e.g., due to a clustered index), the sort is redundant.

Diagnostic tip: Look for Sort Method: external merge with a large Memory usage. If actual rows is small (< 10 000) but the sort still spills, raise work_mem or create an index that matches the ordering.

4. Mis‑estimated Row Counts

If the planner expects 10 rows but the actual count is 10 000, it may choose a Nested Loop over a Hash Join. The cost model relies heavily on statistics (ANALYZE). Out‑of‑date statistics are a common cause of mis‑estimation.

Concrete fix: Run ANALYZE or VACUUM ANALYZE after bulk loads. For partitioned tables, consider ANALYZE on each partition.


Indexes: When They Help, When They Hurt

The Classic B‑Tree Index

Best for: Equality (=) and range (<, >, BETWEEN) predicates. Cost: Each index lookup costs ~ 0.1 ms on a well‑maintained B‑Tree (SSD).

Case study:

QueryTable sizeIndex present?Execution time (ms)
SELECT * FROM observations WHERE hive_id = 4212 M rows (8 GB)Yes (observations_hive_id_idx)2
Same query12 M rowsNo1150

The index cuts time by ~ 99.8 %.

Over‑indexing Penalties

Every index adds write overhead: an INSERT into a table with 5 indexes can be 2–3× slower because each index entry must be updated. In a high‑throughput sensor ingestion pipeline (e.g., 10 k bee‑temperature readings per second), unnecessary indexes can saturate the CPU.

Rule: Keep only indexes that serve queries that run at least once per minute in production. Use pg_stat_user_indexes to identify rarely used indexes.

Partial and Expression Indexes

Partial indexes restrict the indexed rows to a subset, reducing size and maintenance cost.

CREATE INDEX obs_recent_idx
ON observations (hive_id)
WHERE recorded_at >= current_date - interval '30 days';

For a query that only looks at the last month, PostgreSQL can use this index and ignore older rows, cutting index size by ≈ 70 % and improving insert speed.

Expression indexes let you index computed values, such as a LOWER(email) for case‑insensitive lookups, eliminating the need for LOWER() in the WHERE clause and allowing the planner to use the index.


Real‑World Example: Optimizing a Hive‑Inspection Query

Background: Our bee‑conservation platform collects daily observations from smart hives. Analysts frequently run a query to retrieve the latest temperature reading per hive for the past week, along with the average humidity.

SELECT DISTINCT ON (h.hive_id) h.hive_id,
       o.recorded_at,
       o.temp_c,
       avg(o.humidity) OVER (PARTITION BY h.hive_id) AS avg_humidity
FROM hives h
JOIN observations o ON o.hive_id = h.id
WHERE o.recorded_at >= now() - interval '7 days'
ORDER BY h.hive_id, o.recorded_at DESC;

Initial EXPLAIN ANALYZE (PostgreSQL 15):

Unique  (cost=2540.00..2540.02 rows=500 width=24) (actual time=210.312..210.315 rows=500 loops=1)
  ->  Sort  (cost=2540.00..2540.00 rows=500 width=24) (actual time=210.308..210.308 rows=500 loops=1)
        Sort Key: h.hive_id, o.recorded_at DESC
        Sort Method: external merge  Disk: 12 MB
        ->  Hash Join  (cost=1200.00..1800.00 rows=5000 width=24) (actual time=95.123..180.456 rows=5000 loops=1)
              Hash Cond: (o.hive_id = h.id)
              ->  Seq Scan on observations o  (cost=0.00..900.00 rows=500000 width=24) (actual time=0.015..70.321 rows=500000 loops=1)
                    Filter: (recorded_at >= (now() - '7 days'::interval))
              ->  Hash  (cost=800.00..800.00 rows=2000 width=8) (actual time=25.001..25.001 rows=2000 loops=1)
                    ->  Seq Scan on hives h  (cost=0.00..800.00 rows=2000 width=8) (actual time=0.010..20.001 rows=2000 loops=1)
Planning Time: 0.352 ms
Execution Time: 210.632 ms

Observations

  1. Seq Scan on observations reads 500 000 rows (the whole 7‑day window).
  2. External merge sort spilled 12 MB to disk because the sort could not fit in the default work_mem of 4 MB.
  3. The Hash Join built a hash table of 2 000 rows from hives, which is fine, but the heavy cost lies in the scan and sort.

Step 1 – Add a covering index

CREATE INDEX obs_recent_temp_idx
ON observations (hive_id, recorded_at DESC)
WHERE recorded_at >= now() - interval '7 days';

Why this shape? The query orders by hive_id then recorded_at DESC. The index provides the rows already sorted, allowing PostgreSQL to use an Index Scan with Index Only Scan (since all needed columns are in the index).

Step 2 – Re‑run EXPLAIN ANALYZE

Unique  (cost=120.00..120.02 rows=500 width=24) (actual time=12.342..12.345 rows=500 loops=1)
  ->  Index Scan using obs_recent_temp_idx on observations o  (cost=0.15..80.00 rows=500000 width=24) (actual time=0.012..9.876 rows=50000 loops=1)
        Index Cond: (recorded_at >= (now() - '7 days'::interval))
        Order By: hive_id, recorded_at DESC
  ->  Hash Join  (cost=20.00..30.00 rows=5000 width=24) (actual time=2.001..2.003 rows=5000 loops=1)
        Hash Cond: (o.hive_id = h.id)
        ->  Hash  (cost=10.00..10.00 rows=2000 width=8) (actual time=0.500..0.500 rows=2000 loops=1)
              ->  Seq Scan on hives h  (cost=0.00..10.00 rows=2000 width=8) (actual time=0.010..0.200 rows=2000 loops=1)
Planning Time: 0.210 ms
Execution Time: 14.560 ms

Result: Execution time dropped from 210 ms to ≈ 15 ms — a 93 % improvement. The sort vanished because the index already delivered rows in the required order, and the scan processed only 50 000 rows (the most recent per hive) instead of the full 500 000.

Step 3 – Verify statistics

Running ANALYZE observations after the index creation ensures the planner knows the index’s selectivity. Without updated stats, PostgreSQL might still prefer the seq scan.


Using Auto‑Explain and Logging for Ongoing Tuning

Performance isn’t a one‑off event; queries evolve as new features are added, data volumes grow, and hardware changes. PostgreSQL’s auto_explain module can automatically log plans for queries that exceed a runtime threshold.

shared_preload_libraries = 'auto_explain'
auto_explain.log_min_duration = '200ms'      # Log any query >200 ms
auto_explain.log_analyze = true
auto_explain.log_verbose = true
auto_explain.log_format = 'json'             # Easy to parse by monitoring agents

Benefits for a bee‑conservation platform

  • Detect regression early – If a new sensor field causes a query to cross the 200 ms threshold, the JSON log can be fed into an AI‑driven monitoring agent (see ai‑performance‑agents) that raises an alert.
  • Historical trend analysis – Aggregating auto_explain logs in a time‑series DB lets you see how plan costs evolve as the observation table grows from 10 M to 100 M rows.
  • Automatic re‑indexing suggestions – A simple script can parse actual rows vs. estimated rows mismatches and trigger ANALYZE or recommend a new index.

Caveat: Logging every query can generate gigabytes of data on a busy system. Keep the threshold realistic and rotate logs frequently.


Advanced Techniques: CTE Materialization, Parallelism, and Partition Pruning

1. Controlling CTE Materialization

Prior to PostgreSQL 12, a WITH clause (CTE) was always materialized, which could cause an extra scan of large intermediate results. Starting with 12, the planner can inline CTEs, but you can force materialization with MATERIALIZED or prevent it with NOT MATERIALIZED.

When to force materialization: When the CTE is used multiple times and the intermediate result is relatively small.

WITH recent AS MATERIALIZED (
   SELECT * FROM observations
   WHERE recorded_at >= now() - interval '1 day'
)
SELECT ... FROM recent JOIN ...

2. Parallel Query Execution

PostgreSQL can split a large Seq Scan or Hash Join across multiple workers. Enable it with:

max_parallel_workers_per_gather = 4

A Seq Scan on a 30 GB table with parallel_workers = 4 can cut scan time from 12 s to ≈ 3 s, provided effective_cache_size and cpu_tuple_cost are tuned.

Monitoring: EXPLAIN (ANALYZE, BUFFERS) will show Parallel Seq Scan nodes and the number of workers used.

3. Partition Pruning

If you partition observations by month (PARTITION BY RANGE (recorded_at)), PostgreSQL can prune away partitions that fall outside the WHERE clause.

SELECT * FROM observations
WHERE recorded_at BETWEEN '2024-05-01' AND '2024-05-31';

With proper partitioning, only the May 2024 partition (≈ 2 GB) is scanned, rather than the whole table. In EXPLAIN ANALYZE, you’ll see Append with a single child node instead of a full scan.

Tip: Use default_partition to catch stray rows and avoid missing data during ingestion.


Tools and Visualization

ToolStrengthTypical Use
pgAdminBuilt‑in graphical EXPLAIN visualizerQuick ad‑hoc analysis
EXPLAIN.depesz.comOnline formatter that adds color‑coded cost breakdownsSharing plans with teammates
pgBadgerLog‑file parser that aggregates auto_explain JSONLong‑term trend dashboards
pgtop / pg_stat_statementsReal‑time query statisticsSpotting the heaviest queries
Percona Monitoring and Management (PMM)Grafana‑based dashboards for CPU, I/O, and query latencyOperations monitoring

When you embed a plan in a Confluence page or a wiki, use the EXPLAIN (FORMAT JSON) output and feed it to EXPLAIN.depesz.com. The rendered HTML highlights the most expensive nodes in red, making it easier for non‑DBA teammates (e.g., data scientists working on bee‑population models) to understand the bottleneck.


Integrating Performance Metrics into AI‑Driven Monitoring Agents

Our platform’s AI agents are tasked with self‑governance: they allocate compute resources, trigger data pipelines, and even suggest schema changes. Feeding them accurate query performance data enables proactive tuning.

  1. Metric ingestion – Use pg_stat_statements and auto_explain JSON to populate a time‑series DB (e.g., TimescaleDB).
  2. Feature extraction – For each query fingerprint, compute: average actual_total_time, variance, rows/loops ratio, and cost_estimation_error = (actual_total_time - cost*cpu_tuple_cost) / cost.
  3. Anomaly detection – A lightweight LSTM model can learn normal latency patterns. A sudden spike (e.g., 5× increase) triggers an agent to:
  • Run ANALYZE on the affected tables.
  • Suggest an index via a templated CREATE INDEX statement.
  • If the query is part of a scheduled pipeline, postpone the next run until the issue is resolved.

Because the agents are self‑governing, they log their actions and request human approval for any DDL change, preserving auditability—a principle shared with bee colonies where each worker’s activity is recorded in the hive’s pheromone map.


Checklist for a Performance Review

✅ ItemWhy it matters
Run ANALYZE after bulk loadsKeeps planner statistics fresh; reduces mis‑estimation.
Frequently asked
What is Using EXPLAIN ANALYZE for Performance Tuning about?
Performance tuning is the quiet art that keeps data‑driven applications humming smoothly while the world outside buzzes with activity. In the same way that a…
What should you know about what EXPLAIN ANALYZE Actually Does?
When you prepend a query with EXPLAIN ANALYZE , PostgreSQL performs two distinct steps:
What should you know about rows vs. Loops?
A node that processes 10 000 rows once ( loops=1 ) is far less costly than a node that processes 10 000 rows 1 000 times ( loops=1000 ). The latter often appears in poorly chosen Nested Loop joins.
What should you know about memory Usage?
Sort Method: quicksort indicates an in‑memory sort; external merge means the sort spilled to disk. The memory column tells you how close you are to the work_mem limit.
What should you know about 1. Sequential Scan on Large Tables?
A sequential scan ( Seq Scan ) reads every page of a table. On a 30 GB table with 10 million rows, a simple scan can take 12–15 seconds on a typical SSD. If a filter condition is selective (e.g., returns < 0.5 % of rows), an index can reduce that to < 30 ms .
References & sources
  1. Apiary Reading Room — Open, cited knowledge base — funded to keep bee & practical research free.
From the Apiary Reading Room. Opinion & editorial — not financial advice. We don't overclaim.
More from the Reading Room