Paths Subjects Questions Quizzes Pricing Search
Intermediate Open Free

Diagnosing and Fixing a Production Seq Scan

A reporting endpoint runs this query against a payments table with 38 million rows, and it has gone from ~60ms to over 4 seconds as the table grew:

SELECT id, amount_cents, created_at
FROM payments
WHERE merchant_id = 9821
  AND status = 'settled'
ORDER BY created_at DESC
LIMIT 25;

EXPLAIN ANALYZE output:

Limit  (cost=812004.10..812004.17 rows=25 width=24)
       (actual time=3991.203..3991.209 rows=25 loops=1)
  ->  Sort  (cost=812004.10..814127.55 rows=849380 width=24)
             (actual time=3991.201..3991.204 rows=25 loops=1)
        Sort Key: created_at DESC
        Sort Method: top-N heapsort  Memory: 27kB
        ->  Seq Scan on payments  (cost=0.00..798411.00 rows=849380 width=24)
                                  (actual time=0.018..3862.442 rows=847221 loops=1)
              Filter: (merchant_id = 9821 AND status = 'settled'::text)
              Rows Removed by Filter: 37152779
Planning Time: 0.201 ms
Execution Time: 3991.298 ms

There is currently no index on payments other than the primary key on id.

  1. Diagnose exactly what the plan shows, node by node, and explain why the estimated rows=849380 for the seq scan is suspicious on its own, independent of the slowness.
  2. Design a specific index (name the columns and their order) that fixes this query, and justify the column order using the rules for composite indexes.
  3. Explain what specifically about your index also eliminates the separate Sort node in the plan, and what the resulting plan should look like at a high level.
Solution

1. Diagnosis

Reading bottom-up: Seq Scan on payments reads all 38 million rows and applies merchant_id = 9821 AND status = 'settled' as a post-read Filter, discarding 37,152,779 of them (Rows Removed by Filter) to keep 847,221. That scan alone accounts for essentially all the wall-clock time (3862ms of the 3991ms total). On top of it, a Sort node orders all 847,221 surviving rows by created_at DESC before the Limit takes the top 25 — even though only 25 rows are ultimately needed, the plan currently has to materialize and sort the full filtered set first, because nothing handed it the rows pre-sorted.

The estimated rows=849380 is suspicious independent of the slowness because it implies the planner thinks roughly 2.2% of a 38-million-row table matches merchant_id = 9821 AND status = 'settled' — a very large fraction for what's presumably a single-merchant equality filter combined with one status value out of presumably several. If that estimate is close to actual (and here actual rows=847221 confirms it's not a stale-statistics problem — estimated and actual are close), it tells us status = 'settled' is not very selective on its own (a large share of payments are settled) and merchant_id = 9821 may be a fairly high-volume merchant — worth knowing because it affects whether a plain B-tree index on (merchant_id, status, created_at) will be selective enough to be worth using here, versus one where an index scan fetching 847k rows individually could itself become expensive if it weren't for the LIMIT 25 letting the engine stop early once sorted order is available.

2. Index design

CREATE INDEX idx_payments_merchant_status_created
  ON payments (merchant_id, status, created_at DESC);

Column order follows the composite-index rule directly: merchant_id and status are both tested with equality, so they come first (in either order relative to each other, but both before the range/sort column) to narrow the search as much as possible; created_at is the column used for ORDER BY, not for an equality/range filter here, so it goes last, sorted DESC to match the query's ORDER BY created_at DESC exactly. Putting created_at before status would have wasted status's ability to narrow further, since once the index applies a non-equality dimension it can no longer use a trailing column to narrow within it the same way.

3. Why the Sort node disappears

Because the index is built with created_at DESC as its trailing key, rows matching a given (merchant_id, status) prefix are already stored in exactly the order the query's ORDER BY wants — the database can walk the index in its natural stored order and stop after 25 rows, with no separate sort step needed at all. The resulting plan should look roughly like:

Limit
  ->  Index Scan using idx_payments_merchant_status_created on payments
        Index Cond: (merchant_id = 9821 AND status = 'settled'::text)

No Sort node, because the index already produced sorted output; no full materialization of 847k rows, because the index scan can stop as soon as 25 rows satisfying the LIMIT have been produced in the correct order. This is the same mechanism as the keyset-pagination technique: a composite index whose trailing column matches ORDER BY turns an expensive materialize-then-sort into a scan that's already in the right order.

Share this question

← Back to SQL Query Optimization & Indexing practice

We use cookies for product analytics to improve OmniAtlas. See our Privacy Policy.