I do not panic when I see Bitmap Heap Scan in a Postgres plan anymore.
For years I treated it like a yellow warning light: not obviously broken, but suspicious. Then I spent time reading what the bitmap nodes actually do, and I realized many of my “fixes” were either useless or were really composite-index problems in disguise.
A Bitmap Heap Scan usually means:
Postgres found enough matching rows that plain index lookups would be too random, but not so many that a sequential scan clearly wins. So it batches heap access by page.
That is often exactly the right plan.
TL;DR
A Bitmap Heap Scan is Postgres batching row lookups from one or more indexes. It builds a bitmap of heap locations, visits heap pages in physical order, and avoids bouncing around the table one index entry at a time.
Before treating it as a problem, check three things:
Heap Blocks: exactonly
Usually fine. The bitmap stayed precise.Heap Blocks: lossyplus many rechecks
Often awork_memproblem. Postgres ran out of memory for the bitmap and degraded to page-level tracking.Large
Rows Removed by FilterorRows Removed by Index Recheck
The query may be fetching too much heap data. Your index may not match the real filter shape.
Bitmap scans live in the middle:
- Very few rows: plain
Index Scanoften wins. - Medium number of scattered rows:
Bitmap Heap Scanoften wins. - Huge part of the table:
Seq Scanoften wins.
The plan that scares people
Suppose we have an events table:
CREATE TABLE app_events (
id bigint generated always as identity primary key,
account_id bigint not null,
status text not null,
event_type text not null,
created_at timestamptz not null,
payload jsonb not null
);
CREATE INDEX app_events_status_idx
ON app_events (status);
CREATE INDEX app_events_created_at_idx
ON app_events (created_at);
And a dashboard query:
SELECT id, account_id, status, created_at, payload
FROM app_events
WHERE status = 'active'
AND created_at > now() - interval '7 days';
On a table with a few million rows, EXPLAIN (ANALYZE, BUFFERS) might show this:
Bitmap Heap Scan on public.app_events (cost=1269.36..48291.44 rows=54620 width=137)
(actual time=18.742..94.583 rows=51243 loops=1)
Recheck Cond: ((status = 'active'::text) AND (created_at > (now() - '7 days'::interval)))
Heap Blocks: exact=7824
Buffers: shared hit=6012 read=2198
-> BitmapAnd (cost=1269.36..1269.36 rows=54620 width=0)
(actual time=17.205..17.207 rows=0 loops=1)
Buffers: shared hit=181 read=205
-> Bitmap Index Scan on app_events_status_idx (cost=0.00..425.10 rows=421000 width=0)
(actual time=7.134..7.134 rows=421876 loops=1)
Index Cond: (status = 'active'::text)
Buffers: shared hit=96 read=24
-> Bitmap Index Scan on app_events_created_at_idx (cost=0.00..816.70 rows=91300 width=0)
(actual time=9.497..9.497 rows=88912 loops=1)
Index Cond: (created_at > (now() - '7 days'::interval))
Buffers: shared hit=85 read=181
Planning Time: 0.624 ms
Execution Time: 97.812 ms
This is not Postgres failing to use an index. It is using two indexes.
Line by line:
Bitmap Index Scan on app_events_status_idx
Postgres scans the status index and records heap tuple locations for rows where status = 'active'.
Bitmap Index Scan on app_events_created_at_idx
It does the same for the timestamp condition.
BitmapAnd
Because both predicates must be true, Postgres intersects the two bitmaps.
For an OR query, you may see BitmapOr instead.
Bitmap Heap Scan on public.app_events
Now Postgres visits the heap pages indicated by the bitmap and fetches the actual rows.
Recheck Cond: ((status = 'active'::text) AND ...)
The heap scan rechecks the condition against table rows. This is especially important when the bitmap becomes lossy, but you will commonly see a Recheck Cond on bitmap heap scans even when all heap blocks are exact.
Heap Blocks: exact=7824
This is a good sign. The bitmap stayed precise. Postgres knew which tuple offsets to inspect inside those heap pages.
Buffers: shared hit=6012 read=2198
This tells you how much data came from shared buffers and how much had to be read. The plan shape is only half the story; if the query is doing a lot of physical reads on a busy system, that matters.
Nothing here screams “bad index.” This is the planner doing what bitmap scans are for.
What actually happens under the hood
A plain Index Scan walks an index and visits heap rows as it finds matching index entries.
That is excellent when only a few rows match.
But when tens of thousands of rows match and the heap rows are scattered, a plain index scan can turn into random heap access: jump to page 812, then page 12944, then page 91, then back near page 812. If many matching rows are interleaved across the table, that can be expensive.
A bitmap scan separates the work into two phases.
First, one or more Bitmap Index Scan nodes read indexes and build an in-memory bitmap of heap tuple locations. Internally, this structure is page-oriented:
- For exact pages, Postgres tracks tuple offsets within the heap page.
- For lossy pages, Postgres tracks only that the page may contain matches.
Then the Bitmap Heap Scan reads the heap pages in block order. It can visit each heap page once, pull the relevant rows, and avoid repeated random trips to the same page.
That batching is the whole reason this plan exists.
Bitmap heap scans are common for application queries such as:
- recent events,
- active users,
- open tickets,
- unpaid invoices,
- unprocessed jobs,
- dashboard filters over medium-sized result sets.
They are not inherently bad. They are a cost-based compromise between index scans and sequential scans.
Heap Blocks: exact versus lossy
This is one of the first lines to check:
Heap Blocks: exact=7824
Exact means the bitmap had enough memory to track tuple offsets precisely.
Lossy means Postgres ran out of memory for the detailed bitmap and degraded some pages to page-level tracking. Instead of knowing “these three tuples on this page match,” it only knows “this page may contain matches.”
A lossy plan looks like this:
Bitmap Heap Scan on public.app_events (cost=9321.41..118004.76 rows=318440 width=137)
(actual time=64.892..481.116 rows=287906 loops=1)
Recheck Cond: ((status = 'active'::text) AND (created_at > (now() - '30 days'::interval)))
Rows Removed by Index Recheck: 18496
Heap Blocks: exact=2311 lossy=5513
Buffers: shared hit=8420 read=6231
-> BitmapAnd (cost=9321.41..9321.41 rows=318440 width=0)
-> Bitmap Index Scan on app_events_status_idx
Index Cond: (status = 'active'::text)
-> Bitmap Index Scan on app_events_created_at_idx
Index Cond: (created_at > (now() - '30 days'::interval))
The key lines are:
Rows Removed by Index Recheck: 18496
Heap Blocks: exact=2311 lossy=5513
Those extra rows had to be tested because Postgres no longer knew exactly which tuples on the lossy pages matched.
That is often a memory sizing issue, not an index issue.
If this query is important and repeatedly goes lossy, consider raising work_mem for the session, role, or workload:
SET work_mem = '64MB';
Do not blindly raise work_mem globally without doing the math. It is per operation, and a single query can use multiple memory-consuming nodes, sometimes multiplied by parallel workers.
But if a dashboard or reporting role is constantly producing lossy bitmaps, targeted work_mem tuning can make a large difference.
Why the planner picked bitmap instead of index scan or seq scan
Postgres is comparing costs.
Roughly:
- A plain
Index Scanis good when few rows match. - A
Bitmap Heap Scanis good when many scattered rows match, but not most of the table. - A
Seq Scanis good when reading the table straight through is cheaper than chasing index pointers.
The planner’s decision depends heavily on row estimates and cost settings, including:
- table statistics,
- column statistics,
- correlation between index order and heap order,
-
random_page_cost, -
seq_page_cost, -
effective_cache_size, - available memory,
- whether the needed pages are likely cached.
A typical progression looks like this.
With stale stats, the planner may wildly overestimate the result size and choose a sequential scan:
Seq Scan on app_events (cost=0.00..312044.00 rows=2400000 width=137)
(actual rows=51243 loops=1)
Filter: ((status = 'active'::text) AND (created_at > (now() - '7 days'::interval)))
Rows Removed by Filter: 4748757
After updating stats:
ANALYZE app_events;
The planner may see the true selectivity and choose a bitmap heap scan:
Bitmap Heap Scan on app_events (cost=1269.36..48291.44 rows=54620 width=137)
(actual rows=51243 loops=1)
Heap Blocks: exact=7824
If the predicate becomes much more selective:
SELECT id, account_id, status, created_at, payload
FROM app_events
WHERE status = 'active'
AND created_at > now() - interval '1 hour';
Then a plain index scan may win:
Index Scan using app_events_created_at_idx on app_events
(cost=0.43..612.11 rows=340 width=137)
(actual rows=338 loops=1)
Index Cond: (created_at > (now() - '1 hour'::interval))
Filter: (status = 'active'::text)
The important point: the plan shape follows the estimated cost. Bitmap Heap Scan is not a moral judgment. It is Postgres saying, “Batching heap access looks cheaper here.”
When a composite index is better than bitmaping two indexes
Bitmap scans are one of the few ways Postgres can combine multiple single-column indexes for one query. That does not mean two single-column indexes are as good as one well-designed composite index.
For this query:
SELECT id, account_id, status, created_at, payload
FROM app_events
WHERE status = 'active'
AND created_at > now() - interval '7 days';
these two indexes are usable:
CREATE INDEX app_events_status_idx
ON app_events (status);
CREATE INDEX app_events_created_at_idx
ON app_events (created_at);
But Postgres may have to scan a large part of each index, build two bitmaps, intersect them, and then visit the heap.
A composite index that matches the filter shape is often better:
CREATE INDEX CONCURRENTLY app_events_status_created_at_idx
ON app_events (status, created_at);
For an equality condition plus a range condition, the usual rule is:
Put equality columns first, then range columns.
Here, that means:
(status, created_at)
not usually:
(created_at, status)
With (status, created_at), Postgres can jump to the status = 'active' portion of the index and then scan the timestamp range inside that subset.
With (created_at, status), the leading condition is a range. Postgres can scan recent rows, but the status condition is less useful for narrowing the index range. It may still be applied, but the scan often covers a wider slice.
A better plan might become:
Bitmap Heap Scan on app_events (cost=1044.22..30112.91 rows=51200 width=137)
(actual time=10.318..61.774 rows=51243 loops=1)
Recheck Cond: ((status = 'active'::text) AND (created_at > (now() - '7 days'::interval)))
Heap Blocks: exact=7824
-> Bitmap Index Scan on app_events_status_created_at_idx
Index Cond: ((status = 'active'::text) AND (created_at > (now() - '7 days'::interval)))
Or, if the result is selective enough:
text
Index Scan using app_events_status_created_at_idx on app_events
(cost=0.43..5210.88 rows=51200 width=137)
(actual time=0.071..49.302 rows=51243 loops=1)
Index Cond: ((status =
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.