Skip to content
Backend & systems

PostgreSQL EXPLAIN ANALYZE: Read Plans and Choose Useful Indexes

A slow query does not automatically need another index. It needs an explanation of where work accumulates. EXPLAIN ANALYZE lets you compare the planner's expectations with execution. Use that evidence to form an index hypothesis, then test whether the same query returns the same result with less work.

By Published

4 min readEditorial analysisUpdated
  • PostgreSQL
  • SQL
  • Indexes
  • Query performance
What to remember

Read estimates, actual rows, loops, and buffer activity together. Design an index around the query's filters and ordering; compare representative executions before accepting it.

Know what you are measuring

EXPLAIN shows a proposed plan. Adding ANALYZE runs the statement and records execution statistics. Estimated cost uses planner units; actual time uses milliseconds. BUFFERS adds block activity, including cache hits and reads. These values describe different things, so a cost of 200 is not a prediction of 200 milliseconds.

Because ANALYZE executes the statement, start this exercise in a disposable database. An analyzed UPDATE still updates rows; the word EXPLAIN does not make a write read-only.

Reproduce a latest-jobs query

Our original example selects the latest twenty ready jobs for one tenant. The fixture contains 100,000 rows and spreads ready jobs across tenants. Run the setup once in an empty PostgreSQL 17 database. EXPLAIN shows the plan, not query results: also run the SELECT without EXPLAIN to save the identifiers. The first three are 99041, 98041, and 97041.

The query has two equality filters and a descending order. Notice that id breaks timestamp ties. Returning the right twenty rows matters alongside speed.

CREATE TABLE jobs (
  id bigint PRIMARY KEY,
  tenant_id integer NOT NULL,
  state text NOT NULL,
  created_at timestamptz NOT NULL
);

INSERT INTO jobs
SELECT g, (g % 100) + 1,
       CASE WHEN (g / 100) % 10 = 0 THEN 'ready' ELSE 'done' END,
       TIMESTAMPTZ '2026-01-01 00:00:00+00' + g * INTERVAL '1 second'
FROM generate_series(1, 100000) AS g;

ANALYZE jobs;

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, created_at
FROM jobs
WHERE tenant_id = 42 AND state = 'ready'
ORDER BY created_at DESC, id DESC
LIMIT 20;

Look for work that does not survive the query

Trace rows from the scan toward the top of the plan. Rows Removed by Filter shows discarded rows. A Sort before Limit can reveal work needed before the first page is available. Compare estimated and actual row counts to identify a possible statistics problem. When a node runs repeatedly, its reported rows and times are per-loop averages; account for loops instead of reading them as totals.

Do not add parent and child times as independent costs: parent execution includes work below it. Look for a specific expensive path, not the most alarming-looking number.

Match the index to filtering and ordering

For this query, try a B-tree whose first keys are tenant_id and state, followed by created_at and id in the requested order. The equality conditions narrow the leading keys; the remaining keys describe the ordered slice. This is a hypothesis for this access pattern, not a rule to index every WHERE column.

In our isolated PostgreSQL 17.9 check, the fixture changed from a sequential scan plus sort to an index-only scan under Limit, preserving the same twenty rows. Exact timings and the selected scan depend on the environment and table state. An index-only scan is not guaranteed.

CREATE INDEX jobs_tenant_state_order_idx
ON jobs (tenant_id, state, created_at DESC, id DESC);

ANALYZE jobs;

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, created_at
FROM jobs
WHERE tenant_id = 42 AND state = 'ready'
ORDER BY created_at DESC, id DESC
LIMIT 20;

Choose the next check from the evidence

An index name in the plan is not a success criterion. The following decisions help distinguish avoidable work from a sensible scan. Each points to a check rather than an automatic configuration change.

Plan evidence and the next experiment
ObservationNext check
Many rows discardedFilter-selective index
Sort before a small limitIndex matching order
Estimates far from actual rowsStatistics and data skew
Most of the table requestedSequential scan may fit

Test the workload, not one lucky run

Refresh statistics after loading the fixture. Repeat measurements and note cache conditions. Then test tenants with different row counts and queries returning larger portions of the table. PostgreSQL's index-usage guidance emphasizes realistic data: an artificial distribution only establishes what works for that distribution.

  • Check identical rows and ordering before and after the change.
  • Compare discarded rows, sorting, buffers, and execution time.
  • Measure index size and the effect on inserts and updates.
  • Explain which observed bottleneck the index addresses.

Quick answers

Frequently asked questions

Is a sequential scan always bad?

No. Reading a small table or a large share of its rows can make a sequential scan sensible. Compare actual work for the query and data distribution.

Why did an index not improve my query?

Its keys may not match the filters or order, estimates may be inaccurate, or the query may need too many rows. Inspect the plan before forcing index usage.

Does EXPLAIN ANALYZE measure API response time?

No. It measures database execution with profiling overhead; it does not include the complete application and network path. Measure the endpoint separately.

Source notes

References and review policy

Information checked on October 4, 2026. Section links identify sources for factual claims and technical explanations. Interpretations, practice scenarios and preparation recommendations are RecallDeck’s editorial work.

From reading to recall

Practice the full interview loop.

RecallDeck schedules the concepts you miss and keeps coding, design, and behavioral fundamentals available when the interviewer changes direction.

Start studying

Keep going