What a table scan does

A table scan — Seq Scan in Postgres's EXPLAIN output — reads the table's storage blocks from beginning to end, in whatever physical order the rows happen to sit in, and evaluates the WHERE clause against every single row it reads. There's no shortcut and no sorted structure involved: it's brute force, but predictable, sequential I/O, which storage hardware handles efficiently in bulk.

table-scan-plan.txt
Seq Scan on orders  (cost=0.00..48291.00 rows=1 width=42)
  Filter: (id = 918204)
  Rows Removed by Filter: 2401879

Notice the plan had to read and discard 2.4 million rows to find the one it wanted — that's the defining cost of a table scan on a large table with a highly selective filter.

What an index scan does

An index scan walks the sorted B-tree structure built by CREATE INDEX (see how a B-tree index is built), locates the entries matching the filter in a handful of comparisons, and then follows a pointer from each matching index entry back to the corresponding table row — an operation sometimes called a "heap fetch" or "lookup."

index-scan-plan.txt
Index Scan using orders_pkey on orders
  (cost=0.43..8.45 rows=1 width=42)
  Index Cond: (id = 918204)

Same table, same query, indexed on the primary key: the plan goes straight to the one row via the index instead of reading millions to find it.

Index seek vs index scan

Some databases (notably SQL Server) further distinguish an index seek from an index scan. A seek navigates directly to a specific value or narrow range using the tree structure — the fastest possible index operation. An index scan reads a wider, still-bounded range of the index sequentially, which is faster than a table scan but slower than a seek, and usually shows up when the predicate is only partially usable by the index (for example, matching the first column of a composite index but not narrowing the second).

Why the optimizer sometimes skips the index

This is the part that surprises people: an index existing is not enough for it to be used. The optimizer estimates how many rows the filter will match — its selectivity — and compares the estimated cost of an index scan (tree walk plus one row lookup per match) against a table scan (one sequential read of everything).

Once a query is expected to match a large share of the table, the per-row lookup cost of the index scan adds up to more than just reading the table straight through. A common rule of thumb: past roughly 10–20% of a table's rows matching, a table scan tends to win, though the exact crossover depends on the storage engine, row width, and how clustered the matching rows are physically.

This is correct optimizer behavior, not a bug. If you see a table scan on an indexed column and the query is returning a large fraction of the table, the optimizer likely made the right call — the fix, if any is needed, is to make the query itself more selective, not to force the index.

Bitmap scans and index-only scans

Two variants worth knowing. A bitmap heap scan (common in Postgres) builds an in-memory map of matching row locations from the index first, sorts that map by physical location, then reads the table in that order — a middle ground that avoids both a full scan and the random-access cost of a plain index scan when moderately many rows match. An index-only scan happens when the index alone contains every column the query needs (a covering index), skipping the table lookup entirely — the fastest of all scan types.

Spotting scan type in EXPLAIN

The operation name in EXPLAIN output tells you directly which scan type was chosen — no interpretation needed:

Plan nodeWhat it means
Seq Scan / Table ScanReads the entire table, row by row
Index ScanWalks an index, then fetches each matching row from the table
Index Only ScanAnswers the query from the index alone — no table fetch
Bitmap Heap ScanUses the index to build a row map, then reads the table in physical order

For the full walkthrough of reading a plan end to end, see EXPLAIN Query Plan – Beginner to Advanced.

Before/after: selectivity flips the plan

Same indexed column, same table — the only thing that changes is how selective the filter is. A rare status value gets an index scan; a common one correctly falls back to a table scan.

psql — low selectivity Slow
SELECT id FROM orders
WHERE status = 'completed';
-- matches 71% of rows

Seq Scan on orders  (rows=1,705,301)
  Filter: status = 'completed'
Execution time 398.1 ms
psql — high selectivity Fast
SELECT id FROM orders
WHERE status = 'refund_pending';
-- matches 0.03% of rows

Index Scan using idx_orders_status
  (rows=721)
  Index Cond: status = 'refund_pending'
Execution time 2.1 ms

~190x faster identical index on the same column — the optimizer switched plans purely because selectivity changed

Key takeaways

  • A table scan reads every row; an index scan walks a sorted structure and fetches only matches.
  • The optimizer chooses based on estimated selectivity, not on whether an index simply exists.
  • An index seek (direct jump) is generally faster than an index scan (bounded sequential read).
  • A bitmap heap scan and an index-only scan are useful middle-ground and best-case variants.
  • Seeing a table scan on an indexed, low-selectivity column is often the optimizer working correctly.