PostgreSQL Deep Dive: Seq Scan vs Index Scan vs Bitmap Scan — When Postgres Chooses Each
You created an index. You ran a query. The plan says “Seq Scan”. You think the planner is broken.
It probably isn’t.
One of the most common mistakes in PostgreSQL performance tuning is assuming that index scans are always faster than sequential scans. They aren’t. Today we’re going deep into the three table access methods — Sequential Scan, Index Scan, and Bitmap Scan — and the cost model the planner uses to choose between them. By the end, you’ll know exactly why Postgres chose that seq scan, and when you actually need to intervene.
The Three Scan Types
PostgreSQL has three ways to read rows from a table (ignoring specialized ones like TID scans):
| Scan Type | How It Works | Best For |
|---|---|---|
| Sequential Scan | Read every page of the table top to bottom, check each row against the WHERE clause | Large portions of the table, or no useful index |
| Index Scan | Walk the B-tree to find matching entries, fetch each row directly from the table | Very selective queries (few matching rows) |
| Bitmap Scan | Build a bitmap of matching row locations from the index, then fetch rows sorted by physical location | Moderate selectivity (more than a few rows, less than most of the table) |
Let’s look at each one in detail.
Sequential Scan: The Unfairly Maligned Workhorse
A sequential scan reads every page in the table (in order, from block 0 to the last block), and for each row, evaluates the WHERE conditions. Rows that pass are returned.
Seq Scan on orders (cost=0.00..1545.00 rows=100000 width=100)
Filter: (status = 'shipped')
Cost Model
cost = (pages × seq_page_cost) + (rows × cpu_tuple_cost) + (filter_evaluations × cpu_operator_cost)
With defaults (seq_page_cost = 1.0, cpu_tuple_cost = 0.01, cpu_operator_cost = 0.0025):
- Reading 345 pages:
345 × 1.0 = 345.00 - Processing 10,000 rows:
10,000 × 0.01 = 100.00 - Evaluating one filter on 10,000 rows:
10,000 × 0.0025 = 25.00 - Total: ~470.00
Why Seq Scan Is Often the Right Choice
Sequential I/O is fundamentally faster than random I/O. On spinning disks, sequential reads are 10-50× faster than random reads. On SSDs, the gap is smaller but still significant because:
-
OS read-ahead: The kernel detects sequential access patterns and prefetches pages. A seq scan often runs at near memory speed because the pages are already in the OS cache by the time Postgres needs them.
-
No index overhead: An index scan has to read the index pages and the table pages. A seq scan only reads table pages.
-
Locality: A seq scan reads pages in order. Every page read is useful. An index scan might need page 5, then page 342, then page 12, then page 891 — jumping around wastes I/O.
The Crossover Point
The planner compares the cost of a seq scan against the cost of an index scan. For a simple equality filter on a column with an index:
- If the query matches ~5-10% of the table or less, the index scan usually wins.
- If the query matches ~10-25% of the table, the bitmap scan usually wins.
- If the query matches more than ~25% of the table, the seq scan usually wins.
These percentages are rough guidelines. The actual crossover depends on random_page_cost, data distribution, whether pages are cached, and row width.
The key insight: An index scan on 50% of the table means visiting half the rows and reading index pages and doing it in random order. That’s almost always slower than just reading the whole table sequentially.
When Seq Scan Is the Wrong Choice
Seq scan is wrong when:
-
The table is huge and the query is selective — you’re scanning a 100M-row table to find 5 rows. The index should win here. Check if statistics are stale (
ANALYZEthe table). -
random_page_costis too high — the planner overestimates the cost of index scans. Common on SSDs. Fix:
-- In postgresql.conf
random_page_cost = 1.1 -- for SSD/NVMe
effective_cache_sizeis too low — the planner thinks pages won’t be cached, making index scans look expensive. Fix:
-- Should be ~75% of available RAM (shared_buffers + OS cache)
effective_cache_size = '12GB' -- example for a 16GB server
Index Scan: Precise But Expensive
An index scan traverses the B-tree from root to leaf, following the index structure to find matching entries. For each matching entry, it fetches the corresponding heap (table) tuple directly.
Index Scan using idx_orders_status on orders (cost=0.29..8.31 rows=1 width=100)
Index Cond: (status = 'shipped')
Cost Model
cost = (index_pages × random_page_cost) + (index_entries × cpu_index_tuple_cost)
+ (heap_pages × random_page_cost) + (heap_rows × cpu_tuple_cost)
Every heap page access is charged at random_page_cost (default 4.0) because index scans access pages in index order, not physical order. Two consecutive index entries might point to pages at opposite ends of the table.
Index-Only Scan (PostgreSQL 9.2+)
If all the columns you need are in the index itself, PostgreSQL can skip the heap fetch entirely:
Index Only Scan using idx_orders_status on orders (cost=0.29..4.31 rows=1 width=10)
Index Cond: (status = 'shipped')
Heap Fetches: 0
This is dramatically faster — you read only index pages, no table pages at all. Heap Fetches: 0 means every tuple’s visibility was confirmed from the Visibility Map (VM) without visiting the table.
Requirements for index-only scan:
- All columns in the query must be in the index (either as key columns or
INCLUDEcolumns) - The Visibility Map must be up to date (recently vacuumed)
-- Covering index: key column + included columns
CREATE INDEX idx_orders_covering ON orders (status) INCLUDE (id, created_at);
-- Now this query can use an index-only scan
SELECT id, status, created_at FROM orders WHERE status = 'shipped';
The VM gotcha: If the table hasn’t been recently vacuumed, the Visibility Map is stale and the planner has to fall back to heap fetches to check visibility. You’ll see high Heap Fetches counts even with an index-only scan. Vacuum more frequently or tune autovacuum for this table.
When Index Scan Goes Wrong
-
Fetching too many rows in random order — the planner thinks the query matches 5 rows but it actually matches 50,000. The index scan does 50,000 random page fetches instead of one sequential pass. This is the classic “estimated vs actual rows” problem from yesterday’s post.
-
Correlated subqueries forcing repeated index scans — a nested loop join runs the inner index scan once per outer row:
Nested Loop (cost=...)
-> Seq Scan on customers (actual rows=10000)
-> Index Scan on orders (loops=10000) -- 10,000 index traversals!
- Index bloat making the index itself slow — a bloated index has more levels to traverse and more pages to read. REINDEX to fix.
Bitmap Scan: The Goldilocks Zone
The bitmap scan is a two-phase hybrid between index scans and sequential scans:
Phase 1 — Bitmap Index Scan: Walk the index and build a bitmap of matching row locations (TIDs). Each bit in the bitmap represents one page (lossy) or one row (exact).
Phase 2 — Bitmap Heap Scan: Sort the bitmap by page number (so table access is semi-sequential), then fetch the matching pages and check visibility.
Bitmap Heap Scan on orders (cost=25.07..280.11 rows=5000 width=100)
Recheck Cond: (status = 'shipped')
-> Bitmap Index Scan on idx_orders_status (cost=0.00..23.82 rows=5000 width=0)
Index Cond: (status = 'shipped')
Why “Recheck Cond”?
The bitmap might be in lossy mode — where each bit represents an entire 8KB page rather than a specific row. In lossy mode, the heap scan has to recheck the index condition against every row on each marked page, because some rows on that page might not actually match.
You’ll see this in EXPLAIN output:
Bitmap Heap Scan on orders
Recheck Cond: (status = 'shipped')
Heap Blocks: exact=500 ← exact mode: every row location stored
-- OR --
Heap Blocks: lossy=500 ← lossy mode: only page locations stored
Lossy mode kicks in when the bitmap exceeds work_mem. The fix is simple:
SET work_mem = '32MB'; -- for this session
When Bitmap Scan Beats Both Alternatives
Bitmap scan wins in the middle ground:
- Too many rows for index scan — random I/O from 5,000 index fetches is expensive.
- Too few rows for seq scan — scanning the entire table to find 5,000 out of 100,000 rows wastes I/O.
- Bitmap scan sorts by page number — so the 5,000 fetches become semi-sequential. Pages that happen to be near each other get read together.
Multi-Condition Bitmaps: AND and OR
Bitmap scans can combine multiple indexes using BitmapAnd and BitmapOr:
-- Two separate indexes
CREATE INDEX idx_orders_status ON orders (status);
CREATE INDEX idx_orders_date ON orders (created_at);
-- Query using both
EXPLAIN SELECT * FROM orders
WHERE status = 'shipped' AND created_at > '2026-01-01';
Bitmap Heap Scan on orders
Recheck Cond: (status = 'shipped' AND created_at > '2026-01-01')
-> BitmapAnd
-> Bitmap Index Scan on idx_orders_status
Index Cond: (status = 'shipped')
-> Bitmap Index Scan on idx_orders_date
Index Cond: (created_at > '2026-01-01')
The planner:
- Builds bitmap A from
idx_orders_status - Builds bitmap B from
idx_orders_date - ANDs them together (only pages matching both conditions)
- Scans the (much smaller) result bitmap
This is why PostgreSQL doesn’t need composite indexes for every query pattern — bitmap combining handles multi-column WHERE clauses efficiently.
For OR conditions:
SELECT * FROM orders WHERE status = 'shipped' OR status = 'returned';
Bitmap Heap Scan on orders
Recheck Cond: (status = 'shipped' OR status = 'returned')
-> BitmapOr
-> Bitmap Index Scan on idx_orders_status
Index Cond: (status = 'shipped')
-> Bitmap Index Scan on idx_orders_status
Index Cond: (status = 'returned')
The bitmaps are OR’d together — any page matching either condition is included.
How the Planner Decides: A Cost Comparison
Let’s trace the planner’s decision for a concrete example:
-- Table: orders, 1,000,000 rows, ~100,000 pages (~800MB)
-- Index: idx_orders_status on orders(status)
-- Distribution: status = 'shipped' → 300,000 rows (30%)
-- status = 'pending' → 5,000 rows (0.5%)
Query 1: Selective (0.5% of rows)
SELECT * FROM orders WHERE status = 'pending';
| Method | Cost Calculation | Total |
|---|---|---|
| Seq Scan | 100K pages × 1.0 + 1M rows × 0.01 + 1M × 0.0025 | ~127,500 |
| Index Scan | ~20 index pages × 4.0 + 5K entries × 0.005 + ~5K heap pages × 4.0 + 5K × 0.01 | ~20,125 |
| Bitmap Scan | ~20 index pages × 4.0 + 5K × 0.005 + ~5K heap pages × 4.0 | ~20,100 |
Winner: Index Scan (or Bitmap Scan — very close). The planner might choose either.
Query 2: Moderate Selectivity (30% of rows)
SELECT * FROM orders WHERE status = 'shipped';
| Method | Cost Calculation | Total |
|---|---|---|
| Seq Scan | 100K × 1.0 + 1M × 0.01 + 1M × 0.0025 | ~127,500 |
| Index Scan | ~20 × 4.0 + 300K × 0.005 + ~200K heap pages × 4.0 + 300K × 0.01 | ~803,080 |
| Bitmap Scan | ~20 × 4.0 + 300K × 0.005 + ~80K heap pages × 4.0 + 300K × 0.01 | ~321,500 |
Winner: Seq Scan. The index scan costs 6× more because 300K random page fetches is brutal. The bitmap scan is better than index scan but still more expensive than sequential.
Query 3: SSD Changes Everything
Same query as #2, but random_page_cost = 1.0 (SSD):
| Method | Cost Calculation | Total |
|---|---|---|
| Seq Scan | 100K × 1.0 + 1M × 0.01 + 1M × 0.0025 | ~127,500 |
| Bitmap Scan | ~20 × 1.0 + 300K × 0.005 + ~80K × 1.0 + 300K × 0.01 | ~113,520 |
Winner: Bitmap Scan. With cheap random I/O, the bitmap scan becomes competitive even for 30% selectivity. On SSDs, the planner uses indexes more aggressively — which is usually correct.
Diagnostic Queries
Check What the Planner Thinks About Your Data
-- Row count and page count estimates
SELECT relname, reltuples, relpages
FROM pg_class
WHERE relname = 'orders';
-- Column statistics that drive selectivity estimates
SELECT attname, n_distinct, null_frac,
array_length(most_common_vals, 1) AS mcv_count,
array_length(histogram_bounds, 1) AS histogram_buckets
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'status';
Force Each Scan Type for Comparison
-- Compare actual performance of each scan type
-- (use ANALYZE to get real times)
-- Force seq scan
SET enable_indexscan = off;
SET enable_bitmapscan = off;
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE status = 'pending';
-- Force index scan
SET enable_seqscan = off;
SET enable_bitmapscan = off;
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE status = 'pending';
-- Force bitmap scan
SET enable_seqscan = off;
SET enable_indexscan = off;
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE status = 'pending';
-- Reset
RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan;
Check Index Usage Stats
SELECT
schemaname,
relname AS table_name,
indexrelname AS index_name,
idx_scan AS times_used,
idx_tup_read AS tuples_read_from_index,
idx_tup_fetch AS tuples_fetched_from_heap,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0 -- indexes never used
ORDER BY pg_relation_size(indexrelid) DESC;
Unused indexes waste disk space, slow down writes (every INSERT/UPDATE/DELETE touches every index), and add vacuum overhead. Drop them.
Common Mistakes
1. “The Planner Isn’t Using My Index” (But It Shouldn’t)
-- You created this index
CREATE INDEX idx_orders_total ON orders (total_amount);
-- Then you run this query
SELECT * FROM orders WHERE total_amount > 100;
-- And it returns 80% of the table
-- The planner chooses seq scan — correctly!
-- An index scan would be slower for 80% selectivity
2. “The Planner Isn’t Using My Index” (But It Should)
-- Function on the indexed column defeats the index
SELECT * FROM orders WHERE lower(status) = 'shipped';
-- Seq Scan! lower() is a black box to the planner
-- Fix: expression index
CREATE INDEX idx_orders_lower_status ON orders (lower(status));
-- Or: don't wrap the column in a function
SELECT * FROM orders WHERE status = 'SHIPPED'; -- if your data is uppercase
3. “I Added an Index and the Query Got Slower”
This happens when:
- The planner’s row estimate is wrong (thinks query matches 100 rows, actually matches 100,000)
- The index scan does 100K random page fetches instead of one sequential pass
- The fix is NOT to drop the index — it’s to fix the statistics
-- Check the estimate
EXPLAIN (ANALYZE) SELECT * FROM orders WHERE customer_id = 42;
-- If rows=10 but actual rows=100000, fix statistics
ALTER TABLE orders ALTER COLUMN customer_id SET STATISTICS 500;
ANALYZE orders;
4. “My Multi-Column WHERE Needs a Composite Index”
Not necessarily. If you have separate indexes on both columns, the planner can use a BitmapAnd:
-- These two separate indexes
CREATE INDEX idx_a ON orders (status);
CREATE INDEX idx_b ON orders (created_at);
-- Can be combined for this query
SELECT * FROM orders WHERE status = 'shipped' AND created_at > '2026-01-01';
-- A composite index is better IF both columns are always queried together
-- But separate indexes are more flexible for different query patterns
Key Takeaways
- Sequential scans are not the enemy — for queries touching more than ~25% of a table, seq scan is usually the fastest option. The planner knows this.
- Index scans are best for highly selective queries — fetching a few rows. They become pathological when the selectivity estimate is wrong and the planner thinks it will fetch 10 rows but actually fetches 10,000.
- Bitmap scans are the middle ground — they convert random I/O into semi-sequential I/O by sorting the bitmap by page number. They’re the right choice for moderate selectivity.
random_page_costis the single biggest knob — on SSDs, set it to 1.0-1.1. The default 4.0 makes the planner avoid index scans it should be using.- Bitmap combining (
BitmapAnd/BitmapOr) means separate single-column indexes can handle multi-column WHERE clauses — you don’t always need composite indexes. - Index-only scans require recent vacuums — without a current Visibility Map, every index-only scan falls back to heap fetches.
- If the planner chooses “wrong”, first check row estimates (
EXPLAIN ANALYZE), then check statistics, then checkrandom_page_costandeffective_cache_size. Only after those should you considerenable_flags.
What’s Next
Tomorrow we’ll continue the query planning series with Nested Loop vs Hash Join vs Merge Join — Planner Heuristics. Now that you understand how rows get found, we’ll look at how they get combined — and why the planner’s join choice is usually right even when it looks wrong.