PostgreSQL EXPLAIN
Bitmap Index Scan in PostgreSQL EXPLAIN
A Bitmap Index Scan reads an index and marks the table locations of every match in an in-memory bitmap. It returns no rows itself: the Bitmap Heap Scan above it uses the bitmap to read those table pages in order.
Updated 9 October 2026
What it does
A bitmap scan happens in two steps, shown as two nodes. The lower one, Bitmap Index Scan, searches
an index for the entries matching its Index Cond and records where each matching row lives (its page
and position in the table) in a bitmap held in memory. It doesn’t touch the table.
The node above, Bitmap Heap Scan, then reads the marked table pages in physical order, each page once. That’s the point of the split: an ordinary Index Scan visits the table in index order, which can mean jumping back and forth and reading the same page several times.
Because a bitmap is a set of locations, PostgreSQL can combine several of them before touching the table:
BitmapOrmerges the bitmaps of several Bitmap Index Scans, fora = 1 OR b = 2orx IN (…)across indexes.BitmapAndkeeps only locations present in all of them, fora = 1 AND b = 2when there’s a separate index on each column.
When the planner picks it
- Too many rows for an Index Scan, too few for a Seq Scan. Thousands of rows scattered over the table are the typical case.
ORconditions on different indexed columns, throughBitmapOr.- Two single-column indexes that together narrow things down, through
BitmapAnd. A multicolumn index is usually faster, but the bitmap lets you combine indexes you already have. - Index types that can’t return rows in order, such as GIN (full-text search,
jsonbcontainment, arrays): they are always used through a bitmap scan.
Reading its numbers
-> Bitmap Index Scan on orders_customer_id_idx (cost=0.00..58.38 rows=4196 width=0) (actual time=0.331..0.331 rows=4000.00 loops=1)
Index Cond: ((customer_id >= 1000) AND (customer_id <= 1199))
Index Searches: 1
Buffers: shared hit=7
Index Cond: the conditions used to search the index.rows: the number of index entries found and added to the bitmap, not rows returned to you. It counts entries that point at dead row versions too. In one of our runs, after anUPDATEof a customer’s 20 orders had been rolled back, the Bitmap Index Scan foundrows=40.00while the heap scan above returned 20; afterVACUUMremoved the dead entries, it found 20.width=0: it produces no columns, only locations.Index Searches(PostgreSQL 18): how many times it descended the index.Buffers: index pages only. Table pages are counted on the Bitmap Heap Scan.BitmapOr/BitmapAndalways showrows=0.00(orrows=0before 18). That’s a known limitation of how they’re measured, not an empty result; look at the scans beneath them.
The bitmap itself lives in work_mem. If it doesn’t fit, PostgreSQL stops remembering individual rows
for some pages and keeps only the page. That shows up on the Bitmap Heap Scan as
Heap Blocks: lossy=…; see lossy heap blocks.
When it’s a problem
The Bitmap Index Scan itself is rarely slow: in these examples it takes well under a millisecond. When a bitmap scan is slow, the time is almost always in the Bitmap Heap Scan above. Things to check here:
- The index matches far more entries than you need, and the heap scan filters most of them away
(
FilterandRows Removed by Filteron the Bitmap Heap Scan). A multicolumn index that includes the filtered column narrows the bitmap. Build it with CREATE INDEX CONCURRENTLY on a live table. - A
BitmapAndof two weak indexes. If each index alone matches half the table, combining them still reads two large indexes. One index on both columns is cheaper to read and to maintain than two. rowsfar above what the heap scan returns on a heavily updated table: dead index entries. Check that autovacuum keeps up with the table.
Example
PostgreSQL 18.6, default settings:
CREATE SCHEMA seo_explain;
SET search_path = seo_explain;
CREATE TABLE orders (
id bigint PRIMARY KEY,
customer_id int NOT NULL,
status text NOT NULL,
total numeric(10,2) NOT NULL,
created_at timestamptz NOT NULL
);
INSERT INTO orders
SELECT i,
1 + (i::bigint * 7919) % 50000,
CASE WHEN i % 100 < 90 THEN 'shipped'
WHEN i % 100 < 97 THEN 'pending'
ELSE 'refunded' END,
round(((i * 37) % 100000) / 100.0, 2),
timestamptz '2024-01-01' + i * interval '1 minute'
FROM generate_series(1, 1000000) AS i;
CREATE INDEX orders_customer_id_idx ON orders (customer_id);
CREATE INDEX orders_created_at_idx ON orders (created_at);
VACUUM ANALYZE orders;
(Our test table also had a foreign key to customers; it doesn’t change these plans.)
Each customer has 20 orders spread through the table. The orders of 200 customers:
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE customer_id BETWEEN 1000 AND 1199;
Bitmap Heap Scan on orders (cost=59.43..7154.02 rows=4196 width=34) (actual time=0.730..5.213 rows=4000.00 loops=1)
Recheck Cond: ((customer_id >= 1000) AND (customer_id <= 1199))
Heap Blocks: exact=3879
Buffers: shared hit=3886
-> Bitmap Index Scan on orders_customer_id_idx (cost=0.00..58.38 rows=4196 width=0) (actual time=0.331..0.331 rows=4000.00 loops=1)
Index Cond: ((customer_id >= 1000) AND (customer_id <= 1199))
Index Searches: 1
Buffers: shared hit=7
Planning Time: 0.070 ms
Execution Time: 5.462 ms
Seven index pages produced a bitmap of 4,000 row locations in 0.3 ms; reading the 3,879 table pages they point to took the rest.
Two conditions on two different indexes, combined with BitmapOr:
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE customer_id = 4242 OR created_at < '2024-01-01 06:00';
Bitmap Heap Scan on orders (cost=11.88..1266.74 rows=379 width=34) (actual time=0.166..0.267 rows=379.00 loops=1)
Recheck Cond: ((customer_id = 4242) OR (created_at < '2024-01-01 06:00:00+00'::timestamp with time zone))
Heap Blocks: exact=23
Buffers: shared hit=26 read=3
-> BitmapOr (cost=11.88..11.88 rows=379 width=0) (actual time=0.047..0.047 rows=0.00 loops=1)
Buffers: shared hit=5 read=1
-> Bitmap Index Scan on orders_customer_id_idx (cost=0.00..4.58 rows=20 width=0) (actual time=0.039..0.039 rows=20.00 loops=1)
Index Cond: (customer_id = 4242)
Index Searches: 1
Buffers: shared hit=2 read=1
-> Bitmap Index Scan on orders_created_at_idx (cost=0.00..7.12 rows=359 width=0) (actual time=0.007..0.008 rows=359.00 loops=1)
Index Cond: (created_at < '2024-01-01 06:00:00+00'::timestamp with time zone)
Index Searches: 1
Buffers: shared hit=3
Planning:
Buffers: shared hit=12 read=1
Planning Time: 0.123 ms
Execution Time: 0.290 ms
20 + 359 locations, merged, 23 table pages read. Note BitmapOr … rows=0.00 even though it passed 379
locations up.
In Inlet
Inlet draws EXPLAIN ANALYZE as a tree and highlights the slowest step and badly misestimated row
counts.