EXPLAIN returns a tree of plan nodes, one line per node, with the topmost line reporting the estimated total cost for the whole plan the PostgreSQL documentation on using EXPLAIN. Add ANALYZE and Postgres runs the statement for real, attaching actual timing and row counts to every node, so you stop guessing the PostgreSQL documentation on using EXPLAIN. The nodes at the bottom of the tree are scan nodes that return raw rows from a table, and everything above them (joins, sorts, aggregates) works on that output the PostgreSQL documentation on using EXPLAIN.
The discipline that matters is attribution. Decide which node spends the time, and how much. A worked example on a seeded issue-tracking table shows the shape: a count query with no supporting index ran as a parallel sequential scan that read every row and discarded most of them by filter, finishing in 17.066 ms the indexing walkthrough on milanjovanovic.tech. A published plan over a Geoname dataset shows the same pattern at larger scale, with a parallel sequential scan filtering on a text name and a large Rows Removed by Filter count the Crunchy Data EXPLAIN ANALYZE introduction.
Here is the plan used throughout this article. Assume an orders table of roughly 4 million rows:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, total_cents, created_at
FROM orders
WHERE customer_id = 88123
AND status = 'open'
AND created_at >= now() - interval '30 days'
ORDER BY created_at DESC
LIMIT 50;
A plausible first plan:
Limit (cost=218340.55..218340.68 rows=50 width=28) (actual time=1840.221..1840.230 rows=50 loops=1)
-> Sort (cost=218340.55..218342.90 rows=941 width=28) (actual time=1840.219..1840.225 rows=50 loops=1)
Sort Key: created_at DESC
Sort Method: top-N heapsort Memory: 27kB
-> Gather (cost=1000.00..218301.00 rows=941 width=28) (actual time=2.101..1839.402 rows=612 loops=1)
Workers Planned: 3
-> Parallel Seq Scan on orders (cost=0.00..217207.00 rows=304 width=28) (actual time=1.201..1832.107 rows=204 loops=4)
Filter: ((status = 'open'::text) AND (customer_id = 88123) AND (created_at >= (now() - '30 days'::interval)))
Rows Removed by Filter: 1041921
Execution Time: 1840.512 ms
Three things stand out. First, the parallel sequential scan is where the time goes; the Sort above it is a top-N heapsort over 612 rows and costs nearly nothing. Second, Rows Removed by Filter dwarfs the 612 rows returned, which is the classic signal that the predicate is selective but nothing is narrowing the search. Third, check the estimates: the planner estimated 941 rows for the Gather node and got 612. An estimate in the same order of magnitude means the statistics are usable, so the plan was chosen on reasonable grounds. If estimates were wildly off (say 50 estimated, 600,000 actual), fix statistics before touching indexes, because index choice depends on them.
A bitmap plan illustrates the same idea from another angle: a bitmap index scan finds row locations and a bitmap heap scan fetches the matching rows in physical order to reduce random access the Thoughtbot guide to reading a plan. Bitmap scans help when an index is selective but returns enough rows that random heap access hurts. They are not the answer when one customer has a few hundred matching orders and you also want ordering.
The one-node decision: this query wants a single index that satisfies the filter for one customer and one status, and hands back rows already sorted by time.



