Sequential Scan
Appears in EXPLAIN asSeq Scan
Reads every row of a table from start to finish.
What it is
A Sequential Scan reads the table's heap pages in order, from the first block to the last, handing every row up the plan (after applying any Filter).
When the planner picks it
When there's no usable index for the query's conditions, or when the planner expects to return a large fraction of the table — at which point reading sequentially is cheaper than jumping around an index.
Is it good or bad?
Perfectly healthy for small tables and for queries that genuinely need most of the rows. It's a red flag when it scans a large table only to throw away almost everything via a Filter — that usually means a missing or unusable index.
In depth
How it works under the hood
A Sequential Scan asks the storage layer for every block of the table's heap, in
physical order, starting at block 0. For each block it walks the line pointers
and checks each tuple's visibility against your transaction's snapshot (MVCC),
then applies any Filter before passing surviving rows upward.
Because the reads are sequential, the operating system's read-ahead and PostgreSQL's own ring buffer make this very efficient per byte — far more so than the random I/O of chasing an index. The catch is volume: a sequential scan always pays for the whole table, even if you only want three rows.
Reading the numbers
Consider a scan that throws away almost everything:
Seq Scan on big (cost=0.00..17029.00 rows=18348 width=0)
(actual time=62.7..113.4 rows=80000 loops=1)
Filter: ((cat = 7) AND (val = 999999))
Rows Removed by Filter: 500000
Buffers: shared hit=8329
Three things to look at:
Rows Removed by Filterdwarfs the rows kept. The scan read 580,000 rows to return 80,000 — work an index could have skipped.rowsestimated (18,348) vs actual (80,000) is off by ~4×. Estimates this wrong steer the rest of the plan badly.Buffers: shared hit=8329— 8,329 8 KB pages (~65 MB) touched. On a cold cache those becomeread=instead ofhit=, and the scan gets much slower.
When it's the right plan
Don't reflexively "fix" a sequential scan. It is the correct choice when:
- the table is small (a few hundred pages) — an index adds overhead for nothing;
- the query genuinely needs most of the rows (e.g. a reporting aggregate);
- you're reading the whole table on purpose (
COUNT(*), a full export).
When to act, and how
Act when a large table is scanned to satisfy a selective predicate. Options, roughly in order:
- Add an index on the filter/join columns. A partial index
(
... WHERE status = 'open') is ideal when you always filter the same way. - Make the predicate sargable.
WHERE lower(email) = $1can't use a plain index onemail; an expression index onlower(email)(or a rewrite) can. - Refresh statistics with
ANALYZEif the row estimate is far from actual — sometimes the planner only picks the scan because it mis-estimates. - Consider parallelism. On a big unavoidable scan, more workers (a Parallel Seq Scan under a Gather) cut wall-clock time.
What the analyzer flags here
- Seq Scan hot spot — a sequential scan dominating the query's runtime
- Filter discards most rows — the scan reads far more than it keeps (missing index)
Settings that influence it
effective_cache_sizerandom_page_costwork_mem
FAQ
- Is a sequential scan always bad?
- No. On a small table, or when a query reads most of the rows anyway, a sequential scan is the right and fastest choice. It's only a problem when a big table is scanned to satisfy a selective condition that an index could serve.
- How do I turn a Seq Scan into an Index Scan?
- Create an index whose leading columns match the query's WHERE/JOIN conditions, then make sure the statistics are fresh (ANALYZE). If the planner still avoids it, the condition may not be sargable (e.g. a function wrapped around the column).