InletDownload

PostgreSQL EXPLAIN

Parallel Seq Scan in PostgreSQL EXPLAIN

A Parallel Seq Scan is a sequential scan split between several processes: each one reads a share of the table’s pages and filters them. It always sits under a Gather or Gather Merge, and its rows and times are averages per process.

Updated 9 October 2026

What it does

Parallel Seq Scan reads a table the way a Seq Scan does, but several processes share the work. The table’s pages are divided into ranges; each process (the leader, which is your session, plus the parallel workers) claims a range, reads and filters it, then claims the next. No page is read twice.

The rows each process finds go up through the rest of the parallel part of the plan and are collected by a Gather or Gather Merge node in the leader. Anything above that node runs in the leader only.

Parallelism is often paired with a two-stage aggregate: each process counts its share (Partial Aggregate) and the leader adds the partial results together (Finalize Aggregate). That’s how count(*) over a big table gets faster.

When the planner picks it

  • The table is bigger than min_parallel_table_scan_size (8 MB by default). Smaller tables get a plain Seq Scan.
  • max_parallel_workers_per_gather is above zero (default 2). The planned number of workers grows with the table’s size, up to that limit. ALTER TABLE <table> SET (parallel_workers = <n>) overrides the planner’s choice for one table.
  • The estimated saving beats the overhead: starting workers (parallel_setup_cost, default 1000) and passing each row to the leader (parallel_tuple_cost, default 0.1). Queries that return many rows to the leader gain little, so filters and aggregates benefit most.
  • Nothing in the query is parallel-unsafe. Functions marked PARALLEL UNSAFE, and queries that write data (other than CREATE TABLE … AS and a few similar commands), run without parallel workers.

Reading its numbers

->  Parallel Seq Scan on orders  (cost=0.00..13542.33 rows=11903 width=0) (actual time=0.202..32.861 rows=10000.00 loops=3)
      Filter: (status = 'refunded'::text)
      Rows Removed by Filter: 323333
      Buffers: shared hit=6740 read=1594 written=129
  • loops=3: the node ran in three processes: the leader and two workers.
  • rows=10000.00: an average per process. The total is rows × loops: 30,000 refunded orders. The estimate (rows=11903) is per process too.
  • Rows Removed by Filter: 323333: also an average per process; about 970,000 rows were rejected in all. See Rows Removed by Filter.
  • actual time: per process, averaged, so it’s roughly the wall-clock time of the parallel part, not the sum of everyone’s CPU time. See loops and actual time.
  • Buffers: totals for all processes. Here 6,740 + 1,594 = 8,334 pages, the whole table, read once.
  • Workers Planned and Workers Launched appear on the Gather above, not on the scan. If fewer workers launch than planned, the leader and the workers that did start do all the work.

EXPLAIN (ANALYZE, VERBOSE) adds a line per worker, which shows how evenly the work was shared:

Worker 0:  actual time=0.120..51.083 rows=13239.00 loops=1
  Buffers: shared hit=3118 read=560 written=43
Worker 1:  actual time=0.021..20.030 rows=2361.00 loops=1
  Buffers: shared hit=656

Worker 0 found 13,239 rows and Worker 1 only 2,361, so the leader found the other 14,400. Uneven shares are normal: workers start a little after the leader, and the leader also has to collect rows.

When it’s a problem

  • It’s scanning a big table to find a few rows. Three processes reading 1,000,000 rows is still 1,000,000 rows. An index on the filtered column is usually the real fix (see Seq Scan). Parallelism only divides the time.
  • Workers didn’t launch. Workers Launched: 0 (or fewer than planned) means the server had no worker processes free: max_parallel_workers (default 8) and max_worker_processes (default 8) are shared by every session. The query still finishes, in the leader alone, but takes as long as a plain scan or longer. On a busy server, lower max_parallel_workers_per_gather for heavy reporting sessions rather than letting every query compete for workers.
  • It returns lots of rows. Every row passes through the Gather to the leader. For SELECT * over a large share of a table, the leader becomes the bottleneck and the parallel plan can be slower than a serial one.
  • It uses more of the machine. A parallel query uses several CPU cores and its share of I/O. That’s the point for one big report; it’s a cost when many such queries run at once.

To compare with a serial plan, run SET max_parallel_workers_per_gather = 0; in your session and EXPLAIN ANALYZE again.

Example

PostgreSQL 18.6, default settings (2 workers per Gather):

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;

CREATE TABLE orders (
  id          bigint PRIMARY KEY,
  customer_id int NOT NULL REFERENCES customers (id),
  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 customers, orders;

orders is 1,000,000 rows (65 MB). Counting refunded orders:

EXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM orders WHERE status = 'refunded';
Finalize Aggregate  (cost=14572.31..14572.32 rows=1 width=8) (actual time=40.765..42.755 rows=1.00 loops=1)
  Buffers: shared hit=6740 read=1594 written=129
  ->  Gather  (cost=14572.09..14572.30 rows=2 width=8) (actual time=38.761..42.748 rows=3.00 loops=1)
        Workers Planned: 2
        Workers Launched: 2
        Buffers: shared hit=6740 read=1594 written=129
        ->  Partial Aggregate  (cost=13572.09..13572.10 rows=1 width=8) (actual time=33.416..33.417 rows=1.00 loops=3)
              Buffers: shared hit=6740 read=1594 written=129
              ->  Parallel Seq Scan on orders  (cost=0.00..13542.33 rows=11903 width=0) (actual time=0.202..32.861 rows=10000.00 loops=3)
                    Filter: (status = 'refunded'::text)
                    Rows Removed by Filter: 323333
                    Buffers: shared hit=6740 read=1594 written=129
Planning Time: 0.070 ms
Execution Time: 42.790 ms

Read it bottom up: three processes each scanned about a third of the table and kept about 10,000 rows; each counted its own rows (Partial Aggregate, one row per process); the Gather collected three partial counts (rows=3.00); Finalize Aggregate added them into one.

With the session limited to no workers (SET max_parallel_workers = 0), the plan is the same but the leader does everything alone, and the scan shows the full table in one loop:

->  Gather  (cost=14656.24..14656.45 rows=2 width=8) (actual time=50.154..50.206 rows=1.00 loops=1)
      Workers Planned: 2
      Workers Launched: 0
      …
            ->  Parallel Seq Scan on orders  (cost=0.00..13625.33 rows=12361 width=0) (actual time=0.009..48.756 rows=30000.00 loops=1)
                  Filter: (status = 'refunded'::text)
                  Rows Removed by Filter: 970000

PostgreSQL 14–17 print the same plan with whole-number rows (rows=10000 loops=3) and, without the BUFFERS option, no buffer lines.

In Inlet

Inlet draws EXPLAIN ANALYZE as a tree and highlights the slowest step and badly misestimated row counts. With your own Anthropic API key, Ask Claude (⌘L) can explain a plan like this one; it sends the plan and schema, never rows.

Related

Sources