Diagnosing Hash Join Batch Spills #
A hash join builds a table from one input and probes it with the other. While that table fits in memory the join is close to optimal. When it does not, the executor partitions both inputs into batches, writes most of them to temporary files, and processes them one pair at a time — turning a single pass into many, with a full write and read of the spilled data in between.
The plan reports this precisely, in three fields on the Hash node. This page shows how to read them and how to trace a spill back to its actual cause. The operator itself is covered in hash join mechanics.
The Fields That Describe a Spill #
Every Hash node under EXPLAIN ANALYZE reports:
Buckets— the hash table’s bucket count, sized from the estimated row count.Batches— how many partitions the join finally used.1means everything stayed in memory.Original Hash Batches— how many it planned for. Present only when the executor had to increase the count mid-build.Memory Usage— peak kilobytes held by the resident portion of the build side.
The relationship between Batches and Original Hash Batches is the diagnosis:
| Batches | Original | Meaning |
|---|---|---|
| 1 | — | No spill; the build side fit comfortably |
| 8 | 8 | Planned spill: the planner knew it would not fit |
| 32 | 4 | Estimate collapse: the build side was far bigger than expected |
The third row is the expensive one. Increasing the batch count mid-build means re-partitioning rows that were already hashed, which is pure re-work on top of the temporary I/O.
Annotated Evidence #
An estimate collapse looks like this:
Hash Join (cost=48210.11..891402.10 rows=4120334 width=96)
(actual time=18220.4..39810.1 rows=4120334 loops=1)
Hash Cond: (e.session_id = s.session_id)
-> Seq Scan on events e (actual rows=48000000 loops=1)
-> Hash (cost=28104.00..28104.00 rows=890000 width=48)
(actual time=18102.6..18102.6 rows=12000000 loops=1)
Buckets: 65536 (originally 1048576)
Batches: 32 (originally 4) -- re-partitioned mid-build
Memory Usage: 65536kB
-> Seq Scan on sessions s (actual rows=12000000 loops=1)
Buffers: shared read=182400, temp read=214880 written=214880
Estimated 890,000 build rows against 12,000,000 actual — a 13× underestimate. The executor started with 4 batches, discovered the table would not fit, and escalated to 32, re-hashing what it had already built. The temp read/written pair confirms roughly 1.7 GB of temporary traffic in each direction.
A planned spill is far less dramatic:
-> Hash (actual rows=12000000 loops=1)
Buckets: 65536 Batches: 16 Memory Usage: 65536kB
No originally clauses. The planner knew the build side was large, chose 16 batches up front, and partitioned cleanly. This is a resource situation, not an estimation bug.
Step-by-Step Resolution #
-
Read the three fields in order:
Batches, anyoriginallyvalue, then the estimated-versus-actual rows on theHashnode’s child. -
If the estimate collapsed, repair the statistics rather than the memory:
ANALYZE sessions; -- correlated predicates on the build side benefit from extended statistics CREATE STATISTICS sessions_tenant_type (ndistinct, dependencies) ON tenant_id, session_type FROM sessions; ANALYZE sessions; -
Shrink the build side. The hash table holds only the columns the join needs, so removing wide columns from the target list and pushing filters below the
Hashnode both reduce it — see filter pushdown mechanics. -
Check which side is being hashed. PostgreSQL hashes the side it estimates to be smaller; a bad estimate can invert that. When the estimate is corrected, the roles often swap on their own and the spill disappears.
-
If the estimate was right, size the allowance for the statement:
BEGIN; SET LOCAL work_mem = '512MB'; -- hash nodes get 2× by default SET LOCAL hash_mem_multiplier = 2.0; EXPLAIN (ANALYZE, BUFFERS) SELECT … ; COMMIT; -
Consider a different strategy for genuinely huge inputs. A merge join over indexed inputs has predictable memory behavior where a hash join does not.
-
Confirm the fix:
Batches: 1, nooriginallyclause, and notempfigures in the buffers line.
Before and After #
-- BEFORE: build side estimated 890k, actually 12M
Hash Buckets: 65536 (originally 1048576) Batches: 32 (originally 4)
temp read=214880 written=214880
Execution Time: 39810.1 ms
-- AFTER: ANALYZE + extended statistics; planner hashes the correct side
Hash Buckets: 2097152 Batches: 1 Memory Usage: 412880kB
(no temp figures)
Execution Time: 8402.6 ms
Batches returning to 1 and the temp counters vanishing is the complete signature of a resolved spill. Execution time is the consequence.
Common Pitfalls #
Raising work_mem when the estimate collapsed. With a 13× underestimate you would need 13× the memory you predicted, on every concurrent backend. Diagnostic signal: an originally clause on Batches. Fix: repair the statistics first.
Reading Memory Usage as the requirement. It reports the resident portion after partitioning, not the size the whole table needed. Diagnostic signal: a modest figure beside large temp written. Fix: size from the actual build-side row count and width.
Forgetting hash_mem_multiplier. Hash nodes get work_mem × hash_mem_multiplier, so sorts and hashes cross their thresholds at different points. Diagnostic signal: sorts clean while hashes still batch. Fix: raise the multiplier.
Ignoring which side is hashed. Hashing the 48-million-row side instead of the 12-million-row side guarantees a spill. Diagnostic signal: the Hash node sitting over the larger relation. Fix: correct the estimates so the planner picks the smaller build side.
Treating a spill as harmless because the query still finishes. Temporary I/O is the part of a plan’s cost that degrades worst under concurrency: twenty backends each writing a gigabyte of batch files contend for the same disk and the same temp_file_limit. Diagnostic signal: a query whose isolated timing looks acceptable but whose latency triples at peak. Fix: eliminate the spill rather than tolerating it, and monitor pg_stat_database.temp_bytes as a workload-level indicator.
Letting a skewed join key defeat partitioning. Batching assumes the hash distributes rows evenly. When one key value accounts for a large share of the build side, its partition stays oversized no matter how many batches the executor creates, and the re-partitioning loop can repeat several times. Diagnostic signal: a high Batches count with Peak Memory Usage still at the allowance ceiling. Fix: check the most-common-value list in pg_stats for the join column, and consider filtering or handling the dominant value separately.
Forgetting that parallel workers each build their own table. In a parallel plan that is not a Parallel Hash Join, every worker builds a private copy of the hash table, so total memory is the allowance multiplied by the participants. Diagnostic signal: memory pressure that scales with Workers Launched. Fix: budget accordingly, or use the shared-hash variant where available.
Frequently Asked Questions #
What is the difference between Batches and Original Hash Batches?
Original Hash Batches is what the executor planned for; Batches is what it used. A larger final figure means the batch count was increased mid-build, forcing already-hashed rows to be re-partitioned — the most expensive spill variant.
Which side does Memory Usage refer to?
The build side, under the Hash node. The probe side streams through and is never held in memory, which is why getting the smaller relation onto the build side is often the cheapest available fix.
Can a hash join spill with a correct estimate?
Yes. If the build side genuinely exceeds work_mem × hash_mem_multiplier, the planner spills deliberately and says so by choosing a batch count up front. That is a resource decision, and the fix is a larger allowance, a narrower build side, or a different join method.
Related #
- Hash Join Mechanics — parent guide: build and probe phases in detail
- When Does the Optimizer Choose a Hash Join — sibling: the cost comparison that selects it
- Execution Plan Fundamentals — section overview: join strategies and their trade-offs
- Tuning work_mem for Hash Aggregates — sizing the same allowance across a workload