PostgreSQL EXPLAIN
Unique in PostgreSQL EXPLAIN
Unique removes duplicate rows from input that’s already sorted, by comparing each row with the one before. It implements DISTINCT and DISTINCT ON when the planner sorts (or reads an index) instead of hashing. It’s cheap; the sort or scan beneath it often isn’t.
Updated 9 October 2026
What it does
Unique reads rows that arrive sorted on the columns that must be distinct. It passes a row on only
if it differs from the previous one; duplicates are next to each other in sorted input, so one
comparison per row is enough. It keeps a single row in memory and streams its output.
It’s used for:
SELECT DISTINCT, when the planner sorts rather than hashes.SELECT DISTINCT ON (a) … ORDER BY a, b: keeps the first row of each group of equala. With theORDER BY, that’s “the row with the smallest or largestbpera”, a common way to pick the latest row per customer.UNION(withoutALL), on sorted input, to remove duplicates across branches.
The alternative for DISTINCT and UNION is a HashAggregate, which needs no
sorted input but holds every distinct value in memory. DISTINCT ON always uses sorted input and
Unique.
When the planner picks it
- The input is already in the right order, typically from an index on the distinct columns.
- The query also has
ORDER BYon the same columns, so one sort serves both. DISTINCT ON, always.- Many distinct values, where a hash table would be large.
Since PostgreSQL 15, SELECT DISTINCT can also be split across parallel workers.
Reading its numbers
Unique (cost=0.42..243.99 rows=9243 width=4) (actual time=0.009..0.857 rows=500.00 loops=1)
Buffers: shared hit=13
-> Index Only Scan using orders_customer_id_idx on orders (… rows=10000.00 loops=1)
Unique has no fields of its own beyond the standard ones:
rows: distinct rows kept. Compare with the child’s rows: here 10,000 in, 500 out.- The estimate (
rows=9243) comes from the column’sn_distinctstatistics and is often rough, as here. actual time: mostly its child’s. The comparisons themselves are cheap.
When it’s a problem
- Reading everything to find a few distinct values.
SELECT DISTINCT customer_id FROM orderswalks a million index entries to return 50,000 values. If there’s a table that already lists the values (here,customers), ask it instead:SELECT id FROM customers c WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id). In the example: 84 ms → 35 ms. With very few distinct values and no such table, a recursive query that jumps from one value to the next through the index (a “loose index scan”) is the usual workaround. - A big Sort under it.
Sort Method: external mergebeneath a Unique means the sort spilled. Raisework_memfor the query, or provide the order with an index on the distinct columns. DISTINCThiding a join problem. ADISTINCTadded to remove duplicates created by a join often means the join should be anEXISTS. The duplicates cost time to create and more to remove.DISTINCT ONwithout a supporting index sorts all candidate rows. An index on(customer_id, created_at DESC)gives it its order directly.
Example
PostgreSQL 18.6, default settings:
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;
Which of the first 500 customers have orders:
EXPLAIN (ANALYZE, BUFFERS)
SELECT DISTINCT customer_id FROM orders WHERE customer_id <= 500;
Unique (cost=0.42..243.99 rows=9243 width=4) (actual time=0.009..0.857 rows=500.00 loops=1)
Buffers: shared hit=13
-> Index Only Scan using orders_customer_id_idx on orders (cost=0.42..218.54 rows=10178 width=4) (actual time=0.008..0.460 rows=10000.00 loops=1)
Index Cond: (customer_id <= 500)
Heap Fetches: 0
Index Searches: 1
Buffers: shared hit=13
Planning Time: 0.063 ms
Execution Time: 0.881 ms
The index returns customer ids in order, so Unique only has to skip repeats.
The latest order of each of those customers, with DISTINCT ON:
EXPLAIN (ANALYZE, BUFFERS)
SELECT DISTINCT ON (customer_id) customer_id, id, total, created_at
FROM orders WHERE customer_id <= 500
ORDER BY customer_id, created_at DESC;
Unique (cost=9693.16..9744.05 rows=9243 width=26) (actual time=23.991..27.158 rows=500.00 loops=1)
Buffers: shared hit=8346
-> Sort (cost=9693.16..9718.61 rows=10178 width=26) (actual time=23.988..25.080 rows=10000.00 loops=1)
Sort Key: customer_id, created_at DESC
Sort Method: quicksort Memory: 853kB
Buffers: shared hit=8346
-> Bitmap Heap Scan on orders (cost=119.30..9015.65 rows=10178 width=26) (actual time=2.289..17.084 rows=10000.00 loops=1)
Recheck Cond: (customer_id <= 500)
Heap Blocks: exact=8334
Buffers: shared hit=8346
-> Bitmap Index Scan on orders_customer_id_idx (cost=0.00..116.76 rows=10178 width=0) (actual time=0.832..0.833 rows=10000.00 loops=1)
Index Cond: (customer_id <= 500)
Index Searches: 1
Buffers: shared hit=12
Planning Time: 0.114 ms
Execution Time: 27.282 ms
Here the index can’t provide created_at DESC within each customer, so the 10,000 candidate orders are
fetched and sorted before Unique keeps the first per customer.
All customers with orders, two ways:
EXPLAIN (ANALYZE, BUFFERS) SELECT DISTINCT customer_id FROM orders;
Unique (cost=0.42..21300.42 rows=49565 width=4) (actual time=0.012..82.111 rows=50000.00 loops=1)
Buffers: shared hit=947
-> Index Only Scan using orders_customer_id_idx on orders (cost=0.42..18800.42 rows=1000000 width=4) (actual time=0.011..45.350 rows=1000000.00 loops=1)
Heap Fetches: 0
Index Searches: 1
Buffers: shared hit=947
Planning Time: 0.043 ms
Execution Time: 83.994 ms
EXPLAIN (ANALYZE, BUFFERS)
SELECT c.id FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
Gather (cost=1000.72..21286.30 rows=49565 width=4) (actual time=0.233..33.661 rows=50000.00 loops=1)
Workers Planned: 1
Workers Launched: 1
Buffers: shared hit=150006 read=137
-> Nested Loop Semi Join (cost=0.71..15329.80 rows=29156 width=4) (actual time=0.071..28.825 rows=25000.00 loops=2)
Buffers: shared hit=150006 read=137
-> Parallel Index Only Scan using customers_pkey on customers c (cost=0.29..1100.41 rows=29412 width=4) (actual time=0.047..4.023 rows=25000.00 loops=2)
Heap Fetches: 0
Index Searches: 1
Buffers: shared hit=5 read=135
-> Index Only Scan using orders_customer_id_idx on orders o (cost=0.42..0.85 rows=20 width=4) (actual time=0.001..0.001 rows=1.00 loops=50000)
Index Cond: (customer_id = c.id)
Heap Fetches: 0
Index Searches: 50000
Buffers: shared hit=150001 read=2
Planning:
Buffers: shared hit=43 read=7
Planning Time: 0.446 ms
Execution Time: 35.293 ms
The semi join stops at the first order of each customer (rows=1.00 per loop) instead of reading all
twenty. It touches more pages (one short index descent per customer) but does far less work per
customer, and runs in parallel.
In Inlet
Inlet draws EXPLAIN ANALYZE as a tree and highlights the slowest step and badly misestimated row
counts.