Bitmap Scans: How PostgreSQL Combines Indexes
A plain index scan is great for high selectivity (few rows) and a sequential scan is great for low selectivity (most rows). In between — say 1–15% of a table — both are suboptimal: the index scan jumps around the heap in random order, while the seq scan reads far too much. The bitmap scan fills this gap and is implemented in src/backend/executor/nodeBitmapHeapScan.c and the bitmap index scan nodes
Grounded in the source
Where this comes from in postgres/postgres
Each citation was checked against the REL_17_STABLE branch: real file path, symbol present, exact line number. Follow any link to read the code.
Primary symbol: BitmapHeapNext · line 69
Correction: cited as nodeBitmapHeapScan.c (capital S); real filename is nodeBitmapHeapscan.c
The one thing to understand first
A plain index scan is great for high selectivity (few rows) and a sequential scan is great for low selectivity (most rows). In between — say 1–15% of a table — both are suboptimal: the index scan jumps around the heap in random order, while the seq scan reads far too much. The bitmap scan fills this gap and is implemented in src/backend/executor/nodeBitmapHeapScan.c and the bitmap index scan nodes.
A bitmap scan trades index order for heap order: it collects all matching row locations first, then reads the heap once in physical block order. That single reordering is what turns thousands of random I/Os into mostly sequential ones — and it's why bitmaps can also intersect several indexes on the fly.
Two phases
A bitmap scan splits into a Bitmap Index Scan and a Bitmap Heap Scan:
- The bitmap index scan walks the index and, instead of immediately fetching heap rows, records the matching TIDs into an in-memory bitmap keyed by heap page.
- The bitmap heap scan then reads the heap in physical block order, visiting each needed page once and checking the flagged tuples.
Bitmap Heap Scan on orders
Recheck Cond: (status = 'pending')
-> Bitmap Index Scan on idx_orders_status
Index Cond: (status = 'pending')Why physical order matters
Random heap access (a plain index scan over many rows) can cost one random I/O per row. By sorting access into block order, the bitmap heap scan turns that into largely sequential I/O and visits each page only once even if many matching rows live on it. This is the core performance advantage for medium-selectivity predicates.
Combining multiple indexes
Bitmaps compose with boolean logic — the headline capability. For WHERE status = 'pending' AND region = 'EU' with separate indexes on each column, PostgreSQL can build two bitmaps and BitmapAnd them; for an OR it uses BitmapOr:
Bitmap Heap Scan on orders
-> BitmapAnd
-> Bitmap Index Scan on idx_status
-> Bitmap Index Scan on idx_regionThis means you do not always need a composite index for every predicate combination — several single-column indexes can be intersected on the fly. (A well-chosen composite index is still faster when the combination is hot.)
Lossy bitmaps
The bitmap has a memory budget (work_mem). If the exact per-tuple bitmap would exceed it, PostgreSQL degrades to a lossy bitmap that tracks whole pages rather than individual tuples. On a lossy page, the heap scan must recheck every tuple against the original condition — hence the Recheck Cond line, which does real work on lossy pages. You can spot pressure here:
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
-- Look for: "lossy=NNN" in the Bitmap Heap Scan,
-- meaning NNN page-level (rechecked) entries.Heavy lossiness means the bitmap outgrew work_mem; raising it restores exact (tuple-level) tracking and removes recheck overhead.
Layer 3 — Watch it happen on your own database
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE status = 'pending' AND region = 'EU';
-- Bitmap Heap Scan on orders (heap read in block order)
-- Recheck Cond: ... (real work only on lossy pages)
-- Heap Blocks: exact=1203 lossy=0 <- exact = good
-- -> BitmapAnd
-- -> Bitmap Index Scan on idx_status
-- -> Bitmap Index Scan on idx_regionTwo things to read here. First, BitmapAnd proves Postgres intersected two single-column indexes without a composite — you didn't need an index per predicate combination. Second, the Heap Blocks: exact=… lossy=… line: lossy > 0 means the bitmap outgrew work_mem and degraded to whole-page tracking, forcing a per-tuple Recheck. Raise work_mem and watch lossy drop back to zero.
Layer 4 — The levers this hands you
- **Raise
work_mem** to keep bitmaps exact (tuple-level) and avoid lossy rechecks on large result sets. - Keep statistics accurate so the planner switches between bitmap / plain index / seq scan at the right selectivity crossover.
- Promote hot AND-combinations to a composite index — an on-the-fly
BitmapAndis convenient, but a purpose-built composite index is faster when one combination dominates. - Remember bitmaps return no order — if a query needs sorted output, a plain index scan may win by avoiding a later sort.
Layer 5 — What an Oracle DBA should expect vs what they get
The name "bitmap" means something very different in each engine — this trips up Oracle DBAs constantly:
- Transient bitmaps, not bitmap indexes. Oracle's bitmap indexes are a persistent on-disk structure for low-cardinality columns (and they serialize concurrent DML). PostgreSQL builds its bitmaps in memory at query time from ordinary B-tree indexes, then throws them away — no DML locking concern.
- Index combination is automatic. What Oracle achieves with
BITMAP AND/BITMAP CONVERSIONover bitmap indexes, Postgres does ad-hoc viaBitmapAnd/BitmapOrover B-trees — no special index type required. - **
work_memgoverns accuracy.** The exact-vs-lossy degradation has no Oracle analogue; it's a direct consequence of the per-operation memory budget covered earlier. - No hint. You influence it through statistics and
work_mem, not anINDEX_COMBINEhint.
Key takeaway
A bitmap scan walks the index into an in-memory bitmap of row locations, then reads the heap once in physical block order — ideal for medium selectivity and for intersecting several single-column indexes with BitmapAnd/BitmapOr. Watch Heap Blocks: lossy in EXPLAIN (ANALYZE, BUFFERS): lossy pages mean the bitmap exceeded work_mem and now pays a recheck cost. Keep stats accurate, raise work_mem to stay exact, and build a composite index when one AND-combination is permanently hot.
Interview lens
How this shows up in interviews
What an interviewer is actually testing, the same mechanism seen from three roles, and the gap between a shallow answer and one that shows real depth.
What the interviewer is testing
Whether you understand the mid-selectivity gap and how a bitmap scan combines indexes and orders heap access.
Questions you should be able to answer
- ▸When does Postgres choose a bitmap scan over an index scan or a seq scan?
- ▸How can a bitmap scan use two indexes for one query?
- ▸What does a 'lossy' bitmap / heap recheck mean?
Database engineer
You read BitmapAnd/BitmapOr in plans and know work_mem decides whether the bitmap stays exact or goes lossy (per-page recheck).
Software engineer
You realize two separate single-column indexes can serve an AND query via a bitmap, so you don't always need a composite index.
Feature builder
You design filter combinations knowing bitmap AND/OR can merge them, which informs which indexes to create.
Shallow answer
'A bitmap scan is just another index scan.' Misses index combination and heap-ordering.
Answer that shows depth
For mid selectivity (~1-15%) a plain index scan thrashes the heap in random order while a seq scan reads far too much. A Bitmap Index Scan builds an in-memory bitmap of matching tuple locations; BitmapAnd/BitmapOr combine bitmaps from several indexes; then a Bitmap Heap Scan visits the heap in physical order (near-sequential I/O). If the bitmap exceeds work_mem it becomes lossy — tracking pages instead of tuples — so the heap scan rechecks the condition per row. This is exactly why two single-column indexes can jointly satisfy an AND without a composite index.
Pro lab notebook
The captured run behind every query
Every SQL query above is real and the source citations are verified. Pro unlocks the captured psql output — each query re-run against a real PostgreSQL 17.10 lab, with its verbatim result.
1 real lab transcript for this lesson
The explanation and the SQL above are free — so are the verified source citations. What Pro unlocks is the proof: every query in Bitmap Scans: How PostgreSQL Combines Indexes re-run against a real PostgreSQL 17.10 instance, with its verbatim psql output captured line for line.
What Pro unlocks here
- Every query in this lesson re-run against a real PostgreSQL 17.10 lab
- The verbatim psql output — page headers, row versions, plan nodes, catalog rows
- The corrected form of any query the source lesson got wrong, re-captured
- A copy-ready notebook you can replay yourself to confirm every claim
How this was verified
This explanation is anchored to PostgreSQL's source tree — the files linked above, on the REL_17_STABLE branch — and cross-checked against the official documentation for versions 15 through 18. Every SQL query shown was executed and its output captured in a throwaway PostgreSQL 17.10 lab; where a query needed correcting to run, that correction is noted inline.
Official documentation: §14.1 Using EXPLAIN (bitmap scans)