Query Execution Plans: Reading EXPLAIN Output Like a Pro

Learn to decode PostgreSQL EXPLAIN output, understand sequential vs index scans, optimize join orders, and compare bitmap heap scans with index-only scans.

published: reading time: 28 min read author: GeekWorkBench updated: June 17, 2026
Quick Summary

PostgreSQL EXPLAIN shows the plan a query planner selected, including estimated costs, row counts, scans, and joins; EXPLAIN ANALYZE adds measurements from execution. Learn how sequential, index, bitmap, and index-only scans differ, and how to read join nodes, buffers, and estimate errors. The guide also covers practical troubleshooting, plan-related failure patterns, and safe ways to inspect production queries so you can target optimizations with evidence.

Query Execution Plans: Reading EXPLAIN Output Like a Pro

Introduction

When a query runs slowly, PostgreSQL’s EXPLAIN output shows the plan the database chose and the estimates behind it. Learning to read that output helps explain why a scan, join order, or other operation is taking more work than expected.

For example, EXPLAIN SELECT * FROM orders WHERE customer_id = 42; shows whether PostgreSQL chose an index scan or another path for that filter. This guide walks through plan structure, scan types, joins, key EXPLAIN fields, and practical examples, then explains common plan failures and how to investigate them.

How a Query Plan Takes Shape

flowchart TD
    A[SQL Query Received] --> B[Parser creates AST]
    B --> C[Rewriter applies rules]
    C --> D[Planner generates plans]
    D --> E{Table stats available?}
    E -->|No| F[Use default estimates]
    E -->|Yes| G[Use histogram & statistics]
    F --> H{Indexes available?}
    G --> H
    H -->|Yes| I[Consider index scans]
    H -->|No| J[Sequential scan only]
    I --> J
    J --> K[Estimate rows per plan]
    I --> K
    K --> L[Cost each plan]
    L --> M[Pick lowest cost plan]
    M --> N[Execute plan]

The planner considers scan types, join orders, and join algorithms. It estimates row counts using statistics, costs each approach, and picks the cheapest. If statistics are stale, estimates are wrong and the plan is bad.

What is EXPLAIN, Really?

EXPLAIN shows you the execution plan PostgreSQL’s query planner generates for a given SQL statement. It does not run the query — it just shows you the plan.

EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
                         QUERY PLAN
------------------------------------------------------------
 Index Scan using idx_orders_customer_id on orders  (cost=0.43..8.45 rows=1 width=89)
   Index Cond: (customer_id = 42)

The planner estimates the cost of each operation based on statistics it maintains about your data. Lower cost is better, but relative values matter more than absolute numbers.

Understanding Sequential Scans vs Index Scans

Sequential Scans

A sequential scan reads every row in the table, one after another. The database reads the entire table from disk.

Seq Scan on orders  (cost=0.00..4582.00 rows=100000 width=89)
  Filter: (customer_id = 42)

You see this when the table is small, the query returns a large percentage of the table, no index exists on the filtered column, or your statistics are stale.

Sequential scans are not always bad. If you need 80% of the table, reading the whole thing with sequential I/O is faster than bouncing around an index.

Index Scans

An index scan walks the index tree to find matching rows, then fetches the actual data from the heap.

Index Scan using idx_orders_customer_id on orders  (cost=0.43..8.45 rows=1 width=89)
  Index Cond: (customer_id = 42)

The planner picks this when your query is selective, meaning few rows match, and the index covers the join or the heap fetch is cheap enough.

Index Only Scans

If all columns in your query exist in the index, PostgreSQL can skip the heap fetch entirely.

Index Only Scan using idx_orders_customer_id on orders  (cost=0.43..8.45 rows=1 width=89)
  Index Cond: (customer_id = 42)

This works because PostgreSQL maintains a visibility map for each table. If all the rows you need are marked visible to your current transaction, the heap fetch gets skipped. On tables with heavy UPDATE traffic, this optimization degrades because visibility information becomes stale faster.

Bitmap Heap Scans

When an index returns many row pointers, PostgreSQL switches to bitmap mode. It collects all matching heap locations first, then sorts them and reads the heap in physical order.

Bitmap Heap Scan on orders  (cost=412.00..5218.00 rows=5000 width=89)
  Recheck Cond: (customer_id = 42)
  ->  Bitmap Index Scan on idx_orders_customer_id  (cost=0.00..412.00 rows=5000 width=0)

The advantage: it reduces random I/O by sorting heap locations and reading them sequentially. It’s usually faster than index scan when many rows match.

Understanding Join Order Impact

The order in which tables are joined has a huge impact on performance. Consider:

SELECT *
FROM customers c
JOIN orders o ON c.id = o.customer_id
JOIN products p ON o.product_id = p.id
WHERE c.region = 'NORTH';

Default Behavior

PostgreSQL considers all possible join orders and picks the cheapest one. Here’s what it might choose:

EXPLAIN SELECT *
FROM customers c
JOIN orders o ON c.id = o.customer_id
JOIN products p ON o.product_id = p.id
WHERE c.region = 'NORTH';
Hash Join  (cost=5218.00..8942.00 rows=5000)
  Hash Cond: (o.customer_id = c.id)
  ->  Seq Scan on orders
  ->  Hash  (cost=4682.00..4682.00 rows=30000)
        ->  Hash Join  (cost=42.00..4682.00 rows=30000)
              Hash Cond: (o.product_id = p.id)
              ->  Seq Scan on products
              ->  Hash  (cost=30.00..30.00 rows=1000 width=89)
                    ->  Seq Scan on customers
                          Filter: (region = 'NORTH')

Nested Loop Joins

For small tables or when you have good indexes on the join columns, nested loops work well:

Nested Loop  (cost=0.43..150.00 rows=50)
  ->  Index Scan on customers
        Index Cond: (region = 'NORTH')
  ->  Index Scan on orders
        Index Cond: (customer_id = c.id)

Hash Joins

For larger tables where sorting would be expensive, hash joins scale better. PostgreSQL builds a hash table on the smaller relation:

Hash Join  (cost=3000.00..8000.00 rows=50000)
  Hash Cond: (o.customer_id = c.id)
  ->  Seq Scan on orders
  ->  Hash  (cost=2000.00..2000.00 rows=50000 width=45)
        ->  Seq Scan on customers

Merge Joins

When inputs are already sorted on the join key, merge joins are efficient and avoid the hash table overhead:

Merge Join  (cost=4500.00..9000.00 rows=50000)
  Merge Cond: (o.customer_id = c.id)
  ->  Sort
        Sort Key: c.id
        ->  Seq Scan on customers
  ->  Sort
        Sort Key: o.customer_id
        ->  Seq Scan on orders

Key EXPLAIN Output Fields

Cost

The first number is the startup cost (cost before first row can be returned). The second is the total cost (cost to return all rows).

Index Scan (cost=0.43..8.45 rows=1 width=89)
            ↑ startup  ↑ total

Costs are estimates in planner units, combining expected I/O and CPU work; they are not elapsed time.

Rows

The rows estimate is the most consequential number in the plan. Every major decision flows from the planner’s row count guess: scan type, join algorithm, sort strategy. When it estimates 10 rows, nested loop join looks cheap. When it estimates 10 million, hash join wins.

Row estimates can go wrong when statistics have not caught up with a major data change, or when a column has a skewed distribution that a basic histogram does not represent well. Bulk loads may need a manual ANALYZE before autovacuum refreshes statistics. Expressions in a WHERE clause can make a plain index on the underlying column unusable unless a matching expression index exists; they can also make selectivity harder to estimate. Check pg_stats for column-level statistics when estimates look wrong.

When actual rows diverge sharply from estimates, the plan degrades. Run EXPLAIN ANALYZE and compare the rows column against actual rows on each node. A hash join with far more actual rows than estimated may use more memory than expected and can spill to disk if it exceeds its memory allowance. A nested loop with a misestimated outer cardinality runs the inner scan thousands of extra times. The fix is usually ANALYZE, a more selective index, or raising the statistics target for skewed columns with ALTER TABLE orders ALTER COLUMN status SET STATISTICS 500.

Width

Width is the planner’s estimate of average row size in bytes, derived from column types and null bitmap overhead. It feeds into memory consumption estimates for sorts, hashes, and materialization nodes. A row width of 500 bytes means a 10,000-row sort needs roughly 5MB of work_mem.

Wide rows amplify the cost of heap fetches during index scans. If each row is 500 bytes and the index returns 1,000 row pointers, that is 500KB of heap data to fetch. Index-only scans sidestep this entirely: when all selected columns live in the index, width becomes irrelevant for I/O. This is why covering indexes matter more for wide tables.

Width also affects sequential scan cost. A table with 1 million rows at 200 bytes per row has a different I/O profile than one with 50-byte rows, even at the same page count. The planner uses width to estimate sequential scan time, though the correlation is loose since page density, alignment padding, and TOAST compression for out-of-line values all introduce variation.

Buffers

With EXPLAIN (ANALYZE, BUFFERS), you see actual buffer usage:

Buffers: shared hit=1234 read=567

hit means pages found in PostgreSQL’s shared buffers. read means pages PostgreSQL had to load into shared buffers; the operating system may satisfy that read from its own cache, so it does not prove a physical disk read. Compare buffer counts with timing and other measurements before diagnosing storage I/O.

The hit-to-read ratio can help compare runs, but it is not a universal performance threshold. A high read count may reflect a cold PostgreSQL buffer cache even when the operating system serves the pages from memory. Pair buffer counts with execution time and workload context.

Cost estimates do not tell the whole story. A hash join with a low estimated cost can still hammer disk if it spills because it exceeded work_mem. A sequential scan might show near-zero reads if the table is already cached from a prior query. Looking at BUFFERS on each node tells you where the actual work happens, not just where the planner thinks it is expensive.

The hit count covers both heap and index pages. In an index scan, you typically see index pages hit before heap pages — the index gets read first, then the heap fetch follows. Buffer counts on index and heap scan nodes help show which relations needed more pages loaded into shared buffers. They do not by themselves establish whether those pages came from physical storage or an operating-system cache.

Practical Example

Here’s a reporting query that was running slow:

EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, o.created_at, c.name, p.title
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN products p ON o.product_id = p.id
WHERE o.created_at > '2026-01-01'
  AND c.region = 'NORTH';

The problematic plan looked like:

Nested Loop  (cost=0.43..15234.00 rows=50000)
  Buffers: shared hit=2345 read=8900
  ->  Seq Scan on customers
        Filter: (region = 'NORTH')
        Rows Removed by Filter: 45000
  ->  Index Scan using idx_orders_customer_id
        Index Cond: (customer_id = c.id)

Three things jump out: sequential scan on customers, 45,000 rows thrown away by the filter, and 8,900 buffer reads. The planner thought it would return 50,000 rows when in reality it was discarding most of the table.

The fix was adding an index:

CREATE INDEX idx_customers_region ON customers(region);

After that, the same query used:

Nested Loop  (cost=0.43..1234.00 rows=5000)
  Buffers: shared hit=1234 read=45
  ->  Index Scan on customers
        Index Cond: (region = 'NORTH')
  ->  Index Scan using idx_orders_customer_id
        Index Cond: (customer_id = c.id)

Rows estimate dropped from 50,000 to 5,000, and buffer reads went from 8,900 to 45. The query got faster.

Scan Type Selection: When Each Applies

Scan Type Choose when Avoid when
Sequential Scan Query needs most of the table, table is small, no useful index exists, or statistics are stale Query is highly selective (few rows match)
Index Scan Highly selective query, index covers join columns, heap fetch is cheap Query returns large % of table
Index Only Scan Query columns are all in the index, table has good visibility map coverage Heavy UPDATE traffic makes visibility map stale
Bitmap Heap Scan Moderate selectivity (many rows match), reduces random I/O Very selective queries (few rows match)

Sequential scans get a bad reputation but are often the right choice for small tables and for queries that need most of the data anyway.

Common Production Failures

Stale statistics causing wrong plans: After a large load or delete, planner statistics may no longer represent the table. That can lead to poor row estimates and plan choices. Check estimated versus actual rows, then run ANALYZE when statistics are stale; autovacuum may update them later based on its thresholds.

Missing index causing full table scans: A query filtering on status with no index on status seq scans the table. On small tables this is fine. On 100 million rows it is not. Use EXPLAIN to find the seq scans, then add indexes.

Planner choosing nested loop on large tables: Nested loop is correct for small tables with good indexes, but catastrophic when the inner table is large. If you see nested loop on a large join and estimated rows are far off, the inner index is likely wrong or missing.

“Rows Removed by Index Recheck” accumulating: Bitmap heap scans may recheck tuples when bitmap entries are lossy, often because the bitmap could not fit exact tuple locations in its memory budget. Inspect the bitmap heap scan’s lossy block counts and consider whether a query-specific work_mem adjustment is safe. Visibility-map state affects index-only scans, not bitmap index rechecks.

Join order catastrophe: The planner joins a small table to a large one first when it should do the opposite. This produces enormous intermediate result sets. Check that statistics are current on all tables in the join.

Query Plan Observability Checklist

  • Find high-impact queries: Use pg_stat_statements to rank normalized queries by total time and call count. Track latency percentiles and failures by query fingerprint in application telemetry.
  • Check for regressions: Compare latency and plan shape for important queries after deployments, schema or index changes, and statistics refreshes. A sequential scan alone is not an incident; confirm it affects real workload performance.
  • Inspect actual work: For a representative, bounded query, compare estimated and actual rows in EXPLAIN (ANALYZE, BUFFERS). Check loops, buffer reads, temporary I/O, and hash batches to find repeated work or spills.

Security and Compliance Notes

Query plans are diagnostic artifacts and can expose schema and index names, predicates, and literal values. Redact tenant identifiers and sensitive predicates before sharing a plan in a ticket or log, and limit access and retention for stored plans.

EXPLAIN ANALYZE executes the statement and does real work. Use a role with only the needed access, set a suitable statement timeout for expensive queries, and analyze write statements on isolated data rather than production. Rolling back a test transaction does not undo external effects a trigger or extension may have caused.

For row-level security or tenant-isolated tables, capture the plan using the application role and tenant context being investigated. A plan collected as an administrator may not reflect the same policies or visible rows.

Trade-off Analysis: Sequential vs Index Scan

The choice between scan types involves trade-offs that depend on data size, selectivity, and I/O patterns.

Scenario Sequential Scan Index Scan
Data fits in cache 10-50ms for full table 1-5ms for targeted lookup
Data larger than cache 500ms+ sequential read (predictable pattern) 200ms+ random reads (unpredictable, cache misses dominate)
Selectivity > 5% Correct — covering entire table is faster than random I/O Usually wrong — random I/O cost exceeds sequential
Selectivity < 0.1% Usually wrong — reading entire table to find 10 rows Correct — finding 10 rows with minimal I/O
Wide rows High per-row cost Index-only scan avoids heap entirely
Hot data (frequently accessed) Cache-friendly re-reads B-tree traversal adds overhead per access

The planner compares random_page_cost with seq_page_cost using configuration values that may not match a particular storage system. On SSD-backed or cached workloads, random reads may be cheaper than the defaults imply, but tune these values only after measuring representative queries and workload effects.

Quick Recap Checklist

  • Sequential scans are correct when query returns most of the table
  • Index scans are correct when query is highly selective
  • Index-only scans require all-columns-in-index AND a current visibility map
  • Bitmap heap scans reduce random I/O when many rows match
  • Nested loop joins suit small tables with good indexes on join columns
  • Hash joins scale better for larger tables without sorting requirements
  • Merge joins need pre-sorted inputs, avoiding hash table overhead
  • Cost = startup cost (before first row) + total cost (all rows)
  • Rows estimate drives every planner decision
  • Run ANALYZE after bulk loads to update statistics
  • Run EXPLAIN ANALYZE with BUFFERS to see actual vs estimated performance
  • VACUUM keeps visibility maps current for index-only scan efficiency
  • Compare random_page_cost and seq_page_cost between environments

Common Problems and Fixes

Problem Fix
Seq Scan on large table Add or rebuild index on filtered column
Bitmap Heap Scan on small result Increase statistics or use index-only scan
Hash Join on unsorted data Add ORDER BY on join column to enable merge join
Wrong join order Check estimates and planner settings for large joins
Stale statistics Run ANALYZE or VACUUM ANALYZE

Interview Questions

1. A query runs fast in development but slow in production. The table has 10x more rows. EXPLAIN shows a sequential scan. What do you do?
First, confirm the production plan matches what you expect. Run EXPLAIN ANALYZE with BUFFERS to see actual timing, not just estimates. A sequential scan on a 10x larger table can be correct if the query is returning 30% of the rows — index lookups would cause more random I/O than a sequential scan. But if the query is selective and should use an index, the statistics are likely stale. Run ANALYZE on the table. If that does not fix it, check whether an index was dropped or whether the query planner is choosing a different plan because of different session settings (like enable_seqscan). Compare the random_page_cost and seq_page_cost settings between environments — cloud storage often has lower random read penalties than the defaults assume.
2. You see "Rows Removed by Index Recheck: 45000" in an EXPLAIN ANALYZE output. What does this mean and how do you fix it?
Bitmap heap scans may recheck tuples when the bitmap contains lossy page entries, often because its memory budget could not hold exact tuple locations. Inspect the plan for a Bitmap Heap Scan and compare exact versus lossy heap blocks. If many blocks are lossy, a carefully scoped increase to work_mem may retain more exact locations, though it also increases memory use across concurrent operations. This counter does not indicate a stale visibility map; that map affects heap visits during index-only scans.
3. Explain the difference between cost=0.43..8.45 in EXPLAIN output.
The two numbers are startup cost and total cost. Startup cost is the work before the first row can be returned — for an index scan this includes walking the B-tree to the first matching leaf page. Total cost is the estimated work to return all rows. For a query returning one row, the difference between startup and total cost tells you how expensive it is to find the first row. For a sorted query, startup cost includes the sort. For a LIMIT query, the planner uses the startup cost to estimate whether returning the first N rows is cheap even if total cost is high.
4. A three-table join is producing a bad plan. The planner is joining a small lookup table first instead of starting with the filtered result set. Why?
join_collapse_limit controls how far PostgreSQL flattens explicit JOIN syntax while considering join orders; it is not a simple cap after which the planner picks the first viable order. For sufficiently large join problems, PostgreSQL can use the Genetic Query Optimizer (GEQO), which searches a subset of possible plans. First compare estimated and actual rows and confirm statistics are current. If explicit join order is important, join_collapse_limit = 1 preserves the written order for explicit joins; test any planner-setting change against representative queries.
5. A query uses nested loop join but takes seconds instead of milliseconds. EXPLAIN shows the inner table has no index on the join column. How do you fix it?
Nested loop join requires an efficient index on the inner (driven) table's join column. Without it, the inner table scan executes once per outer row, creating an N+1 problem. If the outer table returns 10,000 rows, the inner scan runs 10,000 times. Add an index on the join column of the inner table — CREATE INDEX idx_inner ON inner_table(join_column). After the index exists, the planner should switch to index scan on the inner table, turning the nested loop into an efficient index lookup per outer row.
6. After a bulk load of 10 million rows, queries are running slower than before. EXPLAIN shows unexpected sequential scans. What happened and what do you do?
A bulk load can leave statistics describing the old table size until ANALYZE runs. Autovacuum may refresh them after its analyze threshold is reached, but that may be too late for an immediate workload change. Compare estimated and actual rows; if the estimates are stale, run ANALYZE on the affected table. The updated row counts and distributions help the planner choose scans and joins for the new data volume.
7. You notice "Rows Removed by Index Recheck" is growing over time on a heavily-updated table. EXPLAIN ANALYZE shows significant heap fetches that return no matching rows. What is happening?
That counter is associated with predicate rechecks, commonly when a bitmap heap scan has lossy page entries and must test tuples on those pages. It does not indicate a stale visibility map; visibility-map checks affect index-only scans. Inspect the plan for a Bitmap Heap Scan and its exact versus lossy heap blocks. If many blocks are lossy, a carefully scoped increase to work_mem may let PostgreSQL retain more exact tuple locations, though it can increase memory use across concurrent operations.
8. You run EXPLAIN on the same query in two identical PostgreSQL environments but get different plans. Both have similar data volumes. What settings could cause this?
Several session-level settings affect planner behavior: enable_seqscan, enable_hashjoin, enable_nestloop can force or disable specific plan types. random_page_cost and seq_page_cost affect the planner's estimate of I/O costs — SSDs should have random_page_cost set close to seq_page_cost, but defaults assume spinning disks. work_mem affects when the planner prefers hash operations over sorting. Check SHOW ALL for non-default settings and compare them between environments. Also check search_path and schema usage — if the planner is hitting a different index or table in each environment, there may be schema differences.
9. An index-only scan is not being chosen even though all columns in the SELECT are in the index. The query filters on an indexed column. Why is the heap still being touched?
An index-only scan can still fetch heap pages for rows whose pages are not marked all-visible in the visibility map. Heavy UPDATE activity can reduce all-visible coverage until vacuum updates the map. Check the plan's Heap Fetches count and visibility-map coverage; vacuum may reduce heap visits when enough rows are now visible. The planner may still choose an index-only scan even when some heap fetches are needed.
10. You see actual time values in EXPLAIN ANALYZE that differ significantly from what the cost estimates would suggest. A node with lower cost takes longer than a higher-cost node. What does this tell you?
Cost estimates are in arbitrary units, not milliseconds. They model the planner's assumptions about I/O and CPU costs, not actual wall-clock time. A lower-cost node taking longer usually means the planner's model does not match reality: the node returns many more rows than estimated (causing more work), the data is not in cache (causing physical I/O the cost model underestimated), or the operation is CPU-intensive in ways the cost model does not capture well (like complex expressions or function calls). Look at rows vs estimated rows — if actual rows far exceed estimates, the statistics are stale.
11. A query with a WHERE clause on an indexed column still does a sequential scan. The index exists and ANALYZE has been run. What else could be causing this?
Several possibilities: the index may be on a low-selectivity column where the planner correctly determines a sequential scan is faster — if the index returns 40% of the table, the random I/O of index access outweighs the sequential scan. Check the column statistics and selectivity — if status has only 3 values and each represents 30% or more of rows, the index is not helpful. Also check enable_indexscan and other planner flags that might be forcing sequential scan. Finally, if the table is small enough to fit in a single page, PostgreSQL may always choose sequential scan regardless of index availability.
12. You run EXPLAIN ANALYZE on a query and see that actual rows are 10x the estimated rows for a hash join. What does this tell you about the statistics?
The estimates may be inaccurate or the statistics stale. PostgreSQL uses them to estimate join input sizes, which influences the join strategy and expected memory use. Run ANALYZE on the tables involved and compare estimated with actual rows on the relevant plan nodes. For skewed columns, inspect pg_stats; if the statistics target is too low, raise it for that column, then analyze again. A hash join can spill when its hash table exceeds available memory, but a 10x estimate error alone does not prove that it did.
13. What is the difference between EXPLAIN and EXPLAIN ANALYZE? When would you use one over the other?
EXPLAIN shows the planned cost and row estimates without running the query — it is fast and safe for production queries. EXPLAIN ANALYZE executes the query and shows actual timing, buffer usage, and row counts alongside estimates — it reveals whether the planner's estimates were accurate. Use EXPLAIN when you want a quick check of the plan without running the statement. Use EXPLAIN ANALYZE when you need actual timing and row counts to diagnose performance. It executes the statement, including writes; a transaction rollback can undo database changes, but triggers or extensions may have external effects, so use isolated data for write queries.
14. A bitmap heap scan appears in your plan but you expected a regular index scan. Under what conditions does PostgreSQL choose bitmap heap scan over index scan?
Bitmap heap scan is chosen when the index returns many row pointers — too many for an efficient index scan where each row requires a separate heap fetch. Instead of fetching each row individually (random I/O), the bitmap collector gathers all matching row locations, sorts them by physical page order, then reads the heap sequentially. This reduces random I/O at the cost of memory for the bitmap and rechecking the index condition per row. It typically appears when the index is not highly selective or when random_page_cost is set high relative to seq_page_cost, making random access seem expensive.
15. Your query plan shows a Materialize node. What does it mean and when does it hurt performance?
A Materialize node means PostgreSQL is buffering the result of a subplan in memory (or to disk if too large) so it can be iterated multiple times. It appears in nested loop joins where the inner plan is computed once and then used for each outer row. Materialize hurts performance when the materialized result is large — it consumes memory and the iteration becomes expensive. It also appears in CTEs that are referenced multiple times. If you see Materialize on a large intermediate result, consider whether the CTE materialization is necessary or whether the query can be rewritten to avoid buffering.
16. What does a Sort node with Sort Method: external merge disk tell you? How do you fix it?
The sort operation spilled to disk because work_mem was insufficient to hold the entire sort in memory. PostgreSQL uses a merge sort algorithm and when the data exceeds work_mem, it splits into batches written to disk. Fix it by increasing work_mem for the session or query — SET work_mem = '256MB' — or by adding an index on the ORDER BY column so the data comes pre-sorted. Be careful not to set work_mem too high globally because it is per-sort, not global, and a query with many concurrent sorts can exhaust memory. For one-off large sorts, increasing work_mem just for that query is safer.
17. A query plan shows a sequential scan on a large table even though an index exists on that column. The column has high cardinality. What is happening?
The planner may estimate that an index scan would require more I/O than a sequential scan. Even with high cardinality, a query that returns a large share of the table can favor sequential access. Compare estimated and actual row counts, confirm the index matches the predicate, and review storage cost settings against measured workload behavior. An index-only scan can also perform heap fetches when pages are not marked all-visible, but that does not by itself explain a sequential scan.
18. Explain what Buffers: shared hit=123 read=456 means in EXPLAIN ANALYZE output.
Buffers: shared hit=123 read=456 summarizes buffer activity for the plan node. hit=123 means 123 pages were already in PostgreSQL's shared buffers. read=456 means PostgreSQL loaded 456 pages into shared buffers; the operating system may serve those reads from its cache, so the count does not prove physical disk I/O. Compare buffer counts with execution time and workload context rather than treating a particular hit ratio as a universal threshold.
19. Your query plan shows a hash join but one input is marked with Batches: 4. What does this mean and when does it happen?
Hash join batches appear when the hash table does not fit in work_mem. PostgreSQL partitions the larger relation into batches that can be processed within work_mem. Batches: 4 means the hash join spilled to disk and required 4 passes. This is far slower than a single-batch hash join because each batch may be written to and read from disk. The fix is to increase work_mem until the hash join fits in a single batch, or reduce the data volume by adding filters earlier in the query. You can see the number of batches in EXPLAIN ANALYZE output — if it exceeds 1, performance is degrading due to insufficient work_mem.
20. A query with LIMIT 10 is taking longer than a query without LIMIT even though they use the same plan. What could be causing this?
A LIMIT query taking longer than a full scan with no LIMIT is counter-intuitive but can happen: if the planner chooses a different (worse) plan because of the LIMIT, estimates change. LIMIT reduces the planner's cost estimate for returning a few rows, making it prefer index scans or nested loop joins over hash joins even when hash join would be faster for full execution. Also check whether the LIMIT is inside a subquery — the outer LIMIT may not push down to the inner plan, causing the inner query to execute fully then limit. Check with EXPLAIN ANALYZE comparing both plans and look for different join strategies or scan types being chosen.

Further Reading

For related guidance, see Index Design Clinic, Indexes in Databases, and Joins and Relationships.

Conclusion

Reading EXPLAIN output is a skill that improves with practice. Start with the cost estimates and row counts—they drive every decision the planner makes. Look for sequential scans that process too many rows, bitmap operations on small result sets, and join orders that force unnecessary work.

Category

Related Posts

Database Indexes: B-Tree, Hash, Covering, and Beyond

A practical guide to database indexes. Learn when to use B-tree, hash, composite, and partial indexes, understand index maintenance overhead, and avoid common performance traps.

#database #indexes #performance

Denormalization

When to intentionally duplicate data for read performance. Tradeoffs with normalization, update anomalies, and application-level denormalization strategies.

#database #denormalization #performance

Index Design Clinic: Composite Indexes, Covering Indexes, and Partial Indexes

Master composite index column ordering, covering indexes for query optimization, partial indexes for partitioned data, expression indexes, and selectivity.

#database #indexes #composite-indexes