PostgreSQL EXPLAIN
Rows Removed by Filter in PostgreSQL EXPLAIN
Rows Removed by Filter is how many rows a plan step read and then threw away because they didn’t match a condition it couldn’t use an index for. A big number next to a small rows count means the step read far more than it returned.
Updated 9 October 2026
What it does
When a plan step has a Filter: line, PostgreSQL checks that condition against every row the step
produces and drops the ones that fail. EXPLAIN ANALYZE counts the dropped rows and prints them
underneath as Rows Removed by Filter.
The filter is the part of your WHERE clause that the step couldn’t use to find rows. A Seq Scan
has no way to find rows, so its whole condition is a filter. An Index Scan uses some conditions to
search the index (Index Cond:) and applies the rest as a filter to the rows the index returned.
Two related lines work the same way:
Rows Removed by Join Filter: rows a join produced and then rejected because a join condition that isn’t the join key failed.Rows Removed by Index Recheck: rows rechecked on a bitmap heap scan’s lossy pages; see lossy heap blocks.
When you see it
Only with EXPLAIN ANALYZE, and only on steps that have a Filter: (or Join Filter:) line and
removed at least one row. Plain EXPLAIN shows the Filter: line but not the count, because nothing
has run.
Reading its numbers
Put it next to the step’s actual rows:
Seq Scan on orders (cost=0.00..23094.00 rows=9867 width=34) (actual time=3.184..41.745 rows=10000.00 loops=1)
Filter: (status = 'cancelled'::text)
Rows Removed by Filter: 990000
The scan read 1,000,000 rows (10,000 kept + 990,000 removed) to return 1%. Every removed row still cost a page read and a comparison.
- It’s an average per loop. Like
rows, it’s divided byloops. In a parallel plan withloops=3,Rows Removed by Filter: 330000means about 990,000 in total. On the inner side of a nested loop, multiply by the loop count. See loops and actual time. - 0 isn’t printed. If the line is missing but
Filter:is there, every row passed. - Compare with Buffers. A high removed count with a high
Bufferscount on the same step is where the time goes.
When it’s a problem
When the removed count is large compared with the rows kept, on a step that takes a real share of the query’s time. Removing 19,000 rows from a 20,000-row table in 1 ms isn’t worth an index; removing 990,000 rows from a table you query 50 times a second is.
Fixes, most common first:
- Index the filtered column. If the filter is selective (it keeps a small share of rows), an
index lets the planner find matching rows instead of reading all of them. Build it on a busy table
with
CREATE INDEX CONCURRENTLY. - Extend the index the step already uses. If an
Index ScanshowsIndex Cond:on one column andFilter:on another, an index with both columns moves the filter into the index condition. Put columns compared with=first and the range column last. - A partial index. For a filter on a fixed value, such as
status = 'cancelled', an index with the sameWHEREclause (CREATE INDEX ON orders (created_at) WHERE status = 'cancelled') holds only those rows. - Make the condition indexable.
WHERE lower(email) = $1can’t use an index onemail; an index onlower(email)can. Casting the column (created_at::date = …) has the same effect: write the condition on the bare column (created_at >= … AND created_at < …).
If the filter keeps most of the table, the removed count is small anyway, and a sequential scan is the right plan.
Example
PostgreSQL 18.6, default settings, in a scratch schema:
CREATE SCHEMA seo_terms;
SET search_path = seo_terms;
CREATE TABLE orders (
id bigint PRIMARY KEY,
customer_id int NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL,
total numeric(10,2) NOT NULL
);
INSERT INTO orders
SELECT i,
1 + (i::bigint * 7919) % 20000,
CASE WHEN i % 100 = 0 THEN 'cancelled' WHEN i % 10 = 0 THEN 'pending' ELSE 'shipped' END,
timestamptz '2025-01-01' + i * interval '30 seconds',
((i::bigint * 37) % 50000) / 100.0
FROM generate_series(1, 1000000) AS i;
ANALYZE orders;
SET max_parallel_workers_per_gather = 0; -- one process, so the counts are totals
No index on status, so the scan reads the whole table:
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE status = 'cancelled';
Seq Scan on orders (cost=0.00..23094.00 rows=9867 width=34) (actual time=3.184..41.745 rows=10000.00 loops=1)
Filter: (status = 'cancelled'::text)
Rows Removed by Filter: 990000
Buffers: shared hit=10473 read=121
Planning Time: 0.049 ms
Execution Time: 42.055 ms
With the default two parallel workers, the same query splits the table three ways and the count is per process:
Gather (cost=1000.00..17789.03 rows=9867 width=34) (actual time=3.689..24.958 rows=10000.00 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=10367 read=227
-> Parallel Seq Scan on orders (cost=0.00..15802.33 rows=4111 width=34) (actual time=1.874..20.326 rows=3333.33 loops=3)
Filter: (status = 'cancelled'::text)
Rows Removed by Filter: 330000
Buffers: shared hit=10367 read=227
Now an index on created_at and a query for one day’s cancelled orders. The index finds the day; the
status is checked afterwards:
CREATE INDEX orders_created_at_idx ON orders (created_at);
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, total FROM orders
WHERE created_at >= '2025-03-01' AND created_at < '2025-03-02' AND status = 'cancelled';
Index Scan using orders_created_at_idx on orders (cost=0.42..137.64 rows=29 width=14) (actual time=0.042..0.294 rows=28.00 loops=1)
Index Cond: ((created_at >= '2025-03-01 00:00:00+00'::timestamp with time zone) AND (created_at < '2025-03-02 00:00:00+00'::timestamp with time zone))
Filter: (status = 'cancelled'::text)
Rows Removed by Filter: 2852
Index Searches: 1
Buffers: shared hit=36
Planning Time: 0.254 ms
Execution Time: 0.324 ms
28 rows kept, 2,852 fetched and thrown away. An index on both columns, equality column first, moves the status check into the index:
CREATE INDEX orders_status_created_at_idx ON orders (status, created_at);
Index Scan using orders_status_created_at_idx on orders (cost=0.42..79.52 rows=29 width=14) (actual time=0.026..0.047 rows=28.00 loops=1)
Index Cond: ((status = 'cancelled'::text) AND (created_at >= '2025-03-01 00:00:00+00'::timestamp with time zone) AND (created_at < '2025-03-02 00:00:00+00'::timestamp with time zone))
Index Searches: 1
Buffers: shared hit=24 read=3
Planning Time: 0.147 ms
Execution Time: 0.060 ms
No Filter: line and no removed rows: the index returned only the 28 rows wanted, and execution went
from 0.324 ms to 0.060 ms. PostgreSQL 14–17 print the same lines without Index Searches, and show
actual rows as whole numbers.
In Inlet
Inlet draws EXPLAIN ANALYZE as a tree and highlights the slowest step, which is usually the one with
the biggest removed count. You can also paste a plan into the free plan visualizer.