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’srows.Recheck Cond:is always printed on a Bitmap Heap Scan, even when nothing was rechecked; theRows Removed by Index Recheckline 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:
- Give the query more
work_mem. It’s per operation, so raise it for the session or the one query rather than server-wide:
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.SET work_mem = '32MB'; -- this session SET LOCAL work_mem = '32MB'; -- inside a transaction, this transaction only ALTER ROLE reporting SET work_mem = '32MB'; - Match fewer rows. A more selective condition, or a multicolumn index that applies more of the
WHEREclause in the index, makes the bitmap smaller. - For BRIN, lossy blocks are expected. If
Rows Removed by Index Recheckis large, the table isn’t well ordered by the indexed column, orpages_per_rangeis 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.