InletDownload

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:

  • BitmapOr merges the bitmaps of several Bitmap Index Scans, for a = 1 OR b = 2 or x IN (…) across indexes.
  • BitmapAnd keeps only locations present in all of them, for a = 1 AND b = 2 when 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.
  • OR conditions on different indexed columns, through BitmapOr.
  • 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, jsonb containment, 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 an UPDATE of a customer’s 20 orders had been rolled back, the Bitmap Index Scan found rows=40.00 while the heap scan above returned 20; after VACUUM removed 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 / BitmapAnd always show rows=0.00 (or rows=0 before 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 (Filter and Rows Removed by Filter on 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 BitmapAnd of 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.
  • rows far 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.

Related

Sources