Sort & Hash Aggregates
Sorts and hash aggregates need RAM up to work_mem per plan node per operation. When memory is insufficient, PostgreSQL 18.4 spills to disk and runtime jumps.
Search across all documentation pages
Sorts and hash aggregates need RAM up to work_mem per plan node per operation. When memory is insufficient, PostgreSQL 18.4 spills to disk and runtime jumps.
Quick-reference recipe card - copy-paste ready.
EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id, count(*), sum(total)
FROM orders
GROUP BY customer_id
ORDER BY sum(total) DESC
LIMIT 20;When to reach for this: Aggregation or ORDER BY queries show high I/O or external merge / Disk in the plan.
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL,
total numeric(12,2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO orders (customer_id, total)
SELECT (random() * 50000)::bigint, (random() * 1000)::numeric(12,2)
FROM generate_series(1, 1000000);
ANALYZE orders;
SET work_mem = '1MB';
EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id, sum(total) AS revenue
FROM orders
GROUP BY customer_id
ORDER BY revenue DESC;
RESET work_mem;Look for HashAggregate vs GroupAggregate, and Sort Method: external merge Disk when work_mem is tiny.
What this demonstrates:
work_mem is exceededwork_memGROUP BY columns. One pass if no ordering required.Sort or index scan that emits ordered rows.work_mem; external merge to disk when larger.work_mem (not shared across nodes in one query).| Node | Meaning |
|---|---|
HashAggregate | In-memory grouping via hash table |
GroupAggregate | Groups presorted input |
Sort Method: quicksort Memory | Sort fit in work_mem |
Sort Method: external merge Disk | Sort spilled |
-- Reduce sort work: match index order
CREATE INDEX orders_customer_total_idx ON orders (customer_id, total);
EXPLAIN (ANALYZE)
SELECT customer_id, sum(total)
FROM orders
GROUP BY customer_id;work_mem globally - Ten concurrent queries each using 256MB can OOM the host. Fix: Set per-role work_mem; keep global modest.SELECT DISTINCT on wide rows sorts everything. Fix: DISTINCT ON with supporting index, or rewrite.HashAggregate spill - Rare but possible on huge distinct counts. Fix: Increase work_mem for batch role or pre-aggregate.| Alternative | Use When | Don't Use When |
|---|---|---|
| Incremental materialized view | Repeated heavy aggregates | Need real-time totals |
| Partial GROUP BY in app | Tiny result cardinality | Large distinct groups |
| BRIN + time filter | Rolling window on time series | Arbitrary dimension groups |
Start from EXPLAIN ANALYZE spill indicators. Increase in small steps per workload role, not globally.
Hash does not need sorted input. Group uses sorted input and can pipeline from index scans.
Disk sorts add latency and CPU even on fast storage. Memory fits are dramatically cheaper.
Only with optimizations like sorted group aggregate early stop in specific plans. Do not assume.
Yes when scan order matches ORDER BY or GROUP BY leading keys.
Large scans may use Partial Aggregate plus Gather. Each worker has its own work_mem budget.
Hash Join shows batches; HashAggregate spill is less common but check temp file usage in EXPLAIN ANALYZE.
maintenance_work_mem controls index builds and VACUUM, not query sorts.
Cap runaway sorts per role to protect shared storage on ad hoc SQL pools.
work_mem tuning in OLTP vs OLAP section for role-specific defaults.
Stack versions: This page was written for PostgreSQL 18.4 (stable 18, maintenance 17), pgvector 0.8+, PgBouncer 1.x, Patroni 3.x, and PostGIS 3.5+.
Reviewed by Chris St. John·Last updated Jul 18, 2026