InletDownload

PostgreSQL EXPLAIN

Heap Blocks: lossy in PostgreSQL EXPLAIN

A lossy heap block is a table page the bitmap remembered only as “something on this page matches”, because the bitmap ran out of work_mem. PostgreSQL then rechecks every row on that page, and Rows Removed by Index Recheck counts the ones that didn’t match.

Updated 9 October 2026

What it does

A bitmap scan works in two steps. The Bitmap Index Scan collects the locations of matching rows into a bitmap in memory; the Bitmap Heap Scan then reads the table pages in physical order and returns those rows.

The bitmap normally records each matching row exactly: page 812, rows 3 and 41. That’s an exact block. When the bitmap would grow past work_mem (4 MB by default), PostgreSQL shrinks it by replacing the row-level detail for some pages with a single “some row on this page matches” bit. Those are lossy blocks. For a lossy block, the heap scan has to read every row on the page and test it against the Recheck Cond: again. Rows that fail are counted in Rows Removed by Index Recheck.

The line looks like this:

Heap Blocks: exact=586 lossy=6729

When you see it

On every Bitmap Heap Scan in EXPLAIN ANALYZE. exact= alone is normal. lossy= appears when the bitmap ran out of memory, or when the index can only point at pages in the first place: a BRIN index stores page ranges, not rows, so a bitmap built from it is always lossy.

Reading its numbers

  • exact: pages where only the matching rows were checked.
  • lossy: pages where every row was read and rechecked.
  • exact + lossy: the table pages the step visited. That number doesn’t change when blocks become lossy; the extra work is CPU, rechecking rows that were never going to match.
  • Rows Removed by Index Recheck: rows on lossy pages that failed the recheck. Compare with the step’s rows.
  • Recheck Cond: is always printed on a Bitmap Heap Scan, even when nothing was rechecked; the Rows Removed by Index Recheck line is what tells you it happened.

When it’s a problem

When lossy is a large share of the blocks and Rows Removed by Index Recheck dwarfs rows. The planner knows about it too: it raises its cost estimate when it expects the bitmap to go lossy, so it may switch to a sequential scan instead.

Fixes:

  1. Give the query more work_mem. It’s per operation, so raise it for the session or the one query rather than server-wide:
    SET work_mem = '32MB';          -- this session
    SET LOCAL work_mem = '32MB';    -- inside a transaction, this transaction only
    ALTER ROLE reporting SET work_mem = '32MB';
    
    The bitmap’s size grows with the number of table pages that hold a match. In the example below, 7,315 pages fitted in the default 4 MB but not in 64 kB.
  2. Match fewer rows. A more selective condition, or a multicolumn index that applies more of the WHERE clause in the index, makes the bitmap smaller.
  3. For BRIN, lossy blocks are expected. If Rows Removed by Index Recheck is large, the table isn’t well ordered by the indexed column, or pages_per_range is too large for the query.

Example

PostgreSQL 18.6, the 1,000,000-row orders table from Rows Removed by Filter with an index on customer_id. Each customer’s 50 orders are spread across the table, so 200 customers’ orders touch most pages:

SET search_path = seo_terms;
CREATE INDEX orders_customer_id_idx ON orders (customer_id);
SET max_parallel_workers_per_gather = 0;

EXPLAIN (ANALYZE, BUFFERS)
SELECT sum(total) FROM orders WHERE customer_id BETWEEN 1000 AND 1199;

With the default work_mem (4 MB), every block is exact:

Aggregate  (cost=11177.68..11177.69 rows=1 width=32) (actual time=7.874..7.875 rows=1.00 loops=1)
  Buffers: shared hit=7327
  ->  Bitmap Heap Scan on orders  (cost=139.42..11152.56 rows=10048 width=6) (actual time=1.365..7.189 rows=10000.00 loops=1)
        Recheck Cond: ((customer_id >= 1000) AND (customer_id <= 1199))
        Heap Blocks: exact=7315
        Buffers: shared hit=7327
        ->  Bitmap Index Scan on orders_customer_id_idx  (cost=0.00..136.91 rows=10048 width=0) (actual time=0.693..0.693 rows=10000.00 loops=1)
              Index Cond: ((customer_id >= 1000) AND (customer_id <= 1199))
              Index Searches: 1
              Buffers: shared hit=12
Planning Time: 0.064 ms
Execution Time: 7.955 ms

With SET work_mem = '64kB' (the minimum), the same query, same pages, all already in cache:

Aggregate  (cost=24911.49..24911.50 rows=1 width=32) (actual time=40.708..40.710 rows=1.00 loops=1)
  Buffers: shared hit=7327
  ->  Bitmap Heap Scan on orders  (cost=139.42..24886.36 rows=10048 width=6) (actual time=0.541..39.873 rows=10000.00 loops=1)
        Recheck Cond: ((customer_id >= 1000) AND (customer_id <= 1199))
        Rows Removed by Index Recheck: 798066
        Heap Blocks: exact=586 lossy=6729
        Buffers: shared hit=7327
        ->  Bitmap Index Scan on orders_customer_id_idx  (cost=0.00..136.91 rows=10048 width=0) (actual time=0.489..0.489 rows=10000.00 loops=1)
              Index Cond: ((customer_id >= 1000) AND (customer_id <= 1199))
              Index Searches: 1
              Buffers: shared hit=12
Planning Time: 0.059 ms
Execution Time: 40.733 ms

The page count is identical (7,327 buffers), but 6,729 pages went lossy and 798,066 rows were rechecked to find the same 10,000. Execution went from 8 ms to 41 ms. Note the planner’s estimate also rose, from 11,152 to 24,886, because it predicted the lossy pages from work_mem.

A BRIN index is lossy by design. On a 200,000-row table written in clicked_at order, with sequential scans turned off to force the index:

CREATE TABLE clicks (id bigint, page_id int, clicked_at timestamptz);
INSERT INTO clicks
SELECT i, i % 5000, timestamptz '2026-01-01' + i * interval '1 second'
FROM generate_series(1, 200000) AS i;
CREATE INDEX clicks_clicked_at_brin ON clicks USING brin (clicked_at);
ANALYZE clicks;
SET enable_seqscan = off;

EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM clicks WHERE clicked_at >= '2026-01-02' AND clicked_at < '2026-01-03';
->  Bitmap Heap Scan on clicks  (cost=33.90..2807.90 rows=86974 width=0) (actual time=0.305..6.588 rows=86400.00 loops=1)
      Recheck Cond: ((clicked_at >= '2026-01-02 00:00:00+00'::timestamp with time zone) AND (clicked_at < '2026-01-03 00:00:00+00'::timestamp with time zone))
      Rows Removed by Index Recheck: 14080
      Heap Blocks: lossy=640
      Buffers: shared hit=645

No exact blocks at all; 14,080 rows from the edges of the matching page ranges were rechecked and dropped. That’s a small price next to the 86,400 rows returned.

In Inlet

Inlet draws EXPLAIN ANALYZE as a tree and highlights the slowest step. You can also paste a plan into the free plan visualizer.

Related

Sources