DEV Community

Philip McClarence
Philip McClarence

Posted on

Bitmap Heap Scan in Postgres: When It’s Smart and When It’s a Symptom

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:

  1. Heap Blocks: exact only

    Usually fine. The bitmap stayed precise.

  2. Heap Blocks: lossy plus many rechecks

    Often a work_mem problem. Postgres ran out of memory for the bitmap and degraded to page-level tracking.

  3. Large Rows Removed by Filter or Rows 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 Scan often wins.
  • Medium number of scattered rows: Bitmap Heap Scan often wins.
  • Huge part of the table: Seq Scan often 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);
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Postgres scans the status index and records heap tuple locations for rows where status = 'active'.

Bitmap Index Scan on app_events_created_at_idx
Enter fullscreen mode Exit fullscreen mode

It does the same for the timestamp condition.

BitmapAnd
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now Postgres visits the heap pages indicated by the bitmap and fetches the actual rows.

Recheck Cond: ((status = 'active'::text) AND ...)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

The key lines are:

Rows Removed by Index Recheck: 18496
Heap Blocks: exact=2311 lossy=5513
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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 Scan is good when few rows match.
  • A Bitmap Heap Scan is good when many scattered rows match, but not most of the table.
  • A Seq Scan is 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
Enter fullscreen mode Exit fullscreen mode

After updating stats:

ANALYZE app_events;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

not usually:

(created_at, status)
Enter fullscreen mode Exit fullscreen mode

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)))
Enter fullscreen mode Exit fullscreen mode

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 =
Enter fullscreen mode Exit fullscreen mode

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.