PostgreSQL EXPLAIN
Seq Scan in PostgreSQL EXPLAIN
A Seq Scan reads every row of a table in the order it’s stored and keeps the rows that match. It’s the right plan for small tables and for queries that return a large share of the rows; on a big table that returns a handful, it usually means a missing index.
Updated 9 October 2026
What it does
Seq Scan (sequential scan) reads a table from its first page to its last and checks every row against
the query’s conditions, shown in the plan as Filter. Rows that pass go up to the next node; the rest
are counted in Rows Removed by Filter. No index is involved.
Each 8 kB page is read once, in the order it sits on disk. That makes a sequential scan the cheapest way to read a large part of a table: the operating system can read ahead, and there’s no index to walk first. It’s also always possible, so the planner builds a sequential scan plan for every table and keeps it unless something cheaper beats it.
When the planner picks it
- No index matches the condition. That includes a column wrapped in a function or cast:
lower(email) = '…'can’t use a plain index onemail. - The condition matches a large share of the rows. Fetching 5% of a table through an index means
jumping between pages all over it; reading the whole table in order is often cheaper. There’s no fixed
cut-off: it depends on the table, how the rows are laid out, and settings like
random_page_cost. - The table is small. A few pages are cheaper to read whole than through an index.
- The query needs every row anyway, such as an export or an aggregate over the whole table.
On tables bigger than min_parallel_table_scan_size (8 MB by default), you’ll often see a
Parallel Seq Scan under a Gather instead.
Reading its numbers
Seq Scan on customers (cost=0.00..993.00 rows=2488 width=29) (actual time=0.016..5.996 rows=2500.00 loops=1)
Filter: (country = 'NZ'::text)
Rows Removed by Filter: 47500
Buffers: shared hit=368
Filter: the conditions checked against each row.Rows Removed by Filter: rows read and rejected. Here 50,000 rows were read to return 2,500. Whenloopsis more than 1, this is an average per loop, likerows. See Rows Removed by Filter.rows=2488againstrows=2500.00: the planner’s estimate and what really came out. Close, so the statistics are fine. PostgreSQL 18 prints actual rows with two decimals; 17 and earlier print whole numbers.Buffers: shared hit=368: all 368 of the table’s pages were already in PostgreSQL’s shared buffers.read=would count pages fetched from the operating system or disk. See Buffers: shared hit and read. On 18,EXPLAIN ANALYZEincludes buffers without being asked; on 14–17 addBUFFERS.cost=0.00..993.00: a startup cost of zero (the first row can come out at once) and a total that grows with the number of pages and rows. See cost and rows estimates.
“Disabled” sequential scans
If you switch sequential scans off to test something (SET enable_seqscan = off) and the table has no
other way in, PostgreSQL still uses one. Version 18 says so plainly:
Seq Scan on products (cost=0.00..1976.00 rows=1 width=23)
Disabled: true
Filter: (name = 'Product 7'::text)
PostgreSQL 14–17 add 10,000,000,000 to the cost instead. On 17 here, that inflated cost was enough to switch on JIT compilation for a one-row lookup:
Seq Scan on products (cost=10000000000.00..10000001976.00 rows=1 width=23)
Filter: (name = 'Product 7'::text)
JIT:
Functions: 2
Options: Inlining true, Optimization true, Expressions true, Deforming true
When it’s a problem
Look for three things together: a big table, few rows returned, and Rows Removed by Filter close to
the table’s size. Then:
- Add an index on the filtered column. In the example below, an index turns a 3.4 ms scan of
50,000 rows into a 0.03 ms lookup. On a production table use
CREATE INDEX CONCURRENTLYso writes aren’t blocked while it builds: see CREATE INDEX CONCURRENTLY. The trade-off: every index slows inserts and updates a little and takes disk space, so index the queries you actually run, not every column. - Index the expression the query uses. For
WHERE lower(email) = $1, createCREATE INDEX ON users (lower(email)), or change the query to compare the plain column. - Refresh statistics. If the estimated rows are far from the actual rows, run
ANALYZE <table>. After a bulk load, autovacuum may not have analysed the table yet, and a bad estimate can make an index look more expensive than it is. - Ask for fewer rows. If the query really needs most of the table, the sequential scan is already
the fastest way to get it. The fix is a narrower
WHERE, pagination, or a summary table.
Don’t leave enable_seqscan = off set to force indexes: it’s a test switch for the whole session, not a
hint for one query.
Example
PostgreSQL 18.6 with default settings, in a scratch schema:
CREATE SCHEMA seo_explain;
SET search_path = seo_explain;
CREATE TABLE customers (
id int PRIMARY KEY,
name text NOT NULL,
country text NOT NULL,
created_at timestamptz NOT NULL
);
INSERT INTO customers
SELECT i,
'Customer ' || i,
(ARRAY['GB','US','DE','FR','NL','IE','ES','IT','SE','NO',
'DK','FI','PL','PT','BE','AT','CH','CA','AU','NZ'])[1 + (i * 7) % 20],
timestamptz '2023-01-01' + i * interval '20 minutes'
FROM generate_series(1, 50000) AS i;
VACUUM ANALYZE customers;
Customers in New Zealand are 5% of the table. A sequential scan is the right plan, and the estimate is close:
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM customers WHERE country = 'NZ';
Seq Scan on customers (cost=0.00..993.00 rows=2488 width=29) (actual time=0.016..5.996 rows=2500.00 loops=1)
Filter: (country = 'NZ'::text)
Rows Removed by Filter: 47500
Buffers: shared hit=368
Planning Time: 0.073 ms
Execution Time: 6.156 ms
Looking up one customer by name, with no index on name, reads the same 368 pages to keep one row:
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM customers WHERE name = 'Customer 4242';
Seq Scan on customers (cost=0.00..993.00 rows=1 width=29) (actual time=0.457..3.350 rows=1.00 loops=1)
Filter: (name = 'Customer 4242'::text)
Rows Removed by Filter: 49999
Buffers: shared read=368
Planning Time: 0.055 ms
Execution Time: 3.369 ms
With an index, it reads three pages:
CREATE INDEX customers_name_idx ON customers (name);
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM customers WHERE name = 'Customer 4242';
Index Scan using customers_name_idx on customers (cost=0.29..8.31 rows=1 width=29) (actual time=0.018..0.019 rows=1.00 loops=1)
Index Cond: (name = 'Customer 4242'::text)
Index Searches: 1
Buffers: shared hit=3
Planning Time: 0.053 ms
Execution Time: 0.032 ms
On a table of 50,000 rows the difference is a few milliseconds. On a table of 50 million it’s seconds per query, which is when a Seq Scan in a plan deserves a second look.
In Inlet
Inlet draws EXPLAIN and EXPLAIN ANALYZE as a tree and highlights the slowest step and badly
misestimated row counts, so a scan that dominates the query is easy to spot. You can also paste a plan
into the free plan visualizer.