Startup Cost vs Total Cost Under LIMIT #
SELECT … WHERE status = 'failed' ORDER BY created_at DESC LIMIT 20 returns in 2 ms on most days and in 40 seconds on the day nothing has failed recently. The plan is the same both days: walk the created_at index backwards and stop after 20 matches. When the matches are recent, that is a short walk. When they are rare or old, it is a walk through the whole table.
Every node in a plan carries two costs, startup and total, and LIMIT is where the difference between them decides the plan. The general cost arithmetic is in cost estimation models; this page covers the specific trap it creates for top-N queries.
The Cost-Model Condition #
cost=S..T means: S is the estimated cost before the first row is returned, T is the estimated cost to return all rows. A sort has a high startup cost, because it must consume its entire input before emitting anything. An index scan that already returns rows in the requested order has a startup cost near zero.
A Limit node does not know how many rows it will really need to read; it assumes the rows it wants are spread uniformly through its input. If the input is estimated to produce N rows and the limit is k, the planner charges:
startup + (total − startup) × k / N
For the index walk: the index scan returns all created_at order rows, the filter status = 'failed' is estimated to keep 0.5% of them, so N is 0.5% of the table and 20 matches are expected after reading about 4,000 rows. That fraction of the total cost is tiny, so the index walk wins easily against “find all failed rows, then sort, then take 20”, whose high startup cost is paid in full.
The assumption fails when matching rows are not uniformly distributed along the index order: failed rows clustered in the past, a tenant whose rows are all old, a status that is rare in recent data. The walk then reads far more than k / N of its input before finding enough matches. The same trap affects EXISTS subqueries (effectively LIMIT 1), cursors (which plan for cursor_tuple_fraction, default 0.1, of the result) and merge joins chosen because a sorted path has low startup cost.
Annotated EXPLAIN Evidence #
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, created_at FROM jobs
WHERE status = 'failed'
ORDER BY created_at DESC
LIMIT 20;
Limit (cost=0.44..812.30 rows=20 width=16) (actual time=40204.1..40215.9 rows=20 loops=1)
-> Index Scan Backward using jobs_created_at_idx on jobs
(cost=0.44..8120114.00 rows=200011 width=16)
(actual time=40204.1..40215.8 rows=20 loops=1)
Filter: (status = 'failed'::text)
Rows Removed by Filter: 39811204
Buffers: shared hit=1022 read=384910
Execution Time: 40216.3 ms
-- Limit cost 812 ≈ 8.1M × 20 / 200011 : the uniform-spread discount
-- Rows Removed by Filter 39.8M: the failed rows were all old, far down the index
The plan the planner rejected:
Limit (cost=182214.10..182214.15 rows=20 width=16) (actual time=96.2..96.2 rows=20 loops=1)
-> Sort (cost=182214.10..182714.13 rows=200011 width=16)
Sort Key: created_at DESC
Sort Method: top-N heapsort Memory: 26kB
-> Bitmap Heap Scan on jobs (actual rows=198422 loops=1)
Recheck Cond: (status = 'failed'::text)
Execution Time: 96.8 ms
-- startup cost 182K charged in full: looked 224× more expensive, ran 415× faster
Step-by-Step Resolution #
-
Confirm the pattern: a
Limitover an ordered scan with aFilter, hugeRows Removed by Filter, and actual startup time close to total time. -
Check how matches are distributed along the ordering column:
SELECT date_trunc('month', created_at) AS m, count(*) FROM jobs WHERE status = 'failed' GROUP BY 1 ORDER BY 1 DESC LIMIT 6; -
Give the filter and the order one index. The cleanest fix makes the walk read only matching rows:
CREATE INDEX CONCURRENTLY jobs_status_created_idx ON jobs (status, created_at DESC);With equality on
statusfirst, the index returns only failed jobs, already in order, and theLimitstops after exactly 20 entries — the pattern in partial indexes for job queue polling when the status is fixed. -
If an index is not possible, remove the ordered path from consideration for that statement by making the sort key an expression:
SELECT id, created_at FROM jobs WHERE status = 'failed' ORDER BY created_at + interval '0' DESC LIMIT 20;The expression no longer matches the index, so the planner must filter first and sort — which is correct when matches are rare. Comment the reason next to the query.
-
Review cursor-driven queries separately. A client that opens a cursor and fetches everything makes the 10% assumption wrong in the other direction; raising
cursor_tuple_fractionto1.0for that session plans for the full result.
Before and After #
-- BEFORE: backward walk on created_at, filter on status
Limit → Index Scan Backward using jobs_created_at_idx Rows Removed by Filter: 39811204 Execution Time: 40216.3 ms
-- AFTER: index on (status, created_at DESC)
Limit → Index Scan using jobs_status_created_idx Index Cond: (status = 'failed') Execution Time: 0.09 ms
Why statistics cannot fix this #
It is tempting to treat this as an estimate problem and reach for statistics tuning. But the row estimate for status = 'failed' was accurate: about 200,000 failed jobs existed. What the planner lacks is information about where those rows fall in created_at order, and no per-column statistic records that relationship between two columns’ orderings. Extended statistics describe how column values co-occur, not how one column’s values are positioned along another column’s sort order. The only reliable remedies are structural: an index that makes the ordered path read only qualifying rows, or a rewrite that removes the ordered path as an option.
Common Pitfalls #
Adding a single-column index on the filter column. An index on status alone gives the planner a cheap way to find failed rows but not in order; the uniform assumption can still make the backward walk look cheaper. Diagnostic signal: the new index unused. Fix: a composite index with the filter first and the sort column second.
Using OFFSET for pagination. LIMIT 20 OFFSET 10000 charges for 10,020 rows and suffers the same distribution effect. Diagnostic signal: pages slowing linearly with depth. Fix: keyset pagination on the ordering column.
Fixing one parameter value. The trap is data-dependent; a rare tenant or status triggers it while common ones do not. Diagnostic signal: fast for most values, catastrophic for a few. Fix: test the rarest values, not the typical ones.
Disabling index scans for the session. enable_indexscan = off also removes the good bitmap and index plans elsewhere in the query. Diagnostic signal: other nodes regress after the change. Fix: the expression rewrite or composite index scoped to this statement.
Frequently Asked Questions #
What is the difference between startup cost and total cost in EXPLAIN? #
Startup cost is the estimated cost before the node can return its first row; total cost is the estimate to return all rows. A sort has high startup cost because it reads all input first, while an index scan in the right order can return its first row almost immediately.
Why does adding LIMIT make PostgreSQL choose a slower plan? #
The Limit node assumes the rows it needs are spread evenly through its input, so a plan with near-zero startup cost is charged only a small fraction of its total. When matching rows are clustered far from the start of that order, the plan reads much more than assumed.
How do I stop ORDER BY LIMIT from walking the wrong index? #
Create a composite index with the equality filter columns first and the ORDER BY column last, so the ordered scan reads only matching rows. Where that is impossible, make the sort key an expression such as created_at + interval '0' so the ordered index path is no longer available.
Related #
- Cost Estimation Models — parent guide: startup and total cost for every node
- Why Index Scans Sometimes Underperform — related failure modes of ordered index access
- Multicolumn Index Column Ordering — designing the filter-then-sort index