Five minutes, one real diagnostic, no fluff. A GROUP BY on a high-cardinality key builds an in-memory hash table — one entry per distinct key. On a big enough table that hash table outgrows max_memory_usage and the query dies with MEMORY_LIMIT_EXCEEDED (code 241) instead of finishing slowly. Today: the safety valve, and the cheaper options.
The safety valve
max_bytes_before_external_group_by sets a per-query memory threshold. Cross it and ClickHouse writes the partial aggregation state to a temp file on disk, frees the memory, and keeps going — merging the spilled chunks at the end.
SET max_bytes_before_external_group_by = 10000000000; -- 10 GB, e.g. half of max_memory_usage
Same pattern covers two siblings — too low causes needless disk I/O, too high risks OOM:
| Setting | Applies to | Guidance |
|---|---|---|
max_bytes_before_external_group_by |
GROUP BY |
Session-level; common start is half of max_memory_usage. 0 disables spilling. |
max_bytes_before_external_sort |
ORDER BY |
Same ratio guidance. |
max_bytes_before_external_join |
Hash joins | Same ratio, applied to the join’s build side. |
Spilling is strictly slower than staying in RAM — treat it as a safety valve, not a substitute for a correct max_memory_usage.
Try these first (cheaper than spilling)
- Pre-aggregate. If the same aggregation runs repeatedly, a materialized view pre-aggregates on insert so each query scans far fewer rows. See projections vs materialized views.
- Two-level aggregation.
group_by_two_level_threshold/group_by_two_level_threshold_bytessplit the hash table into buckets that merge in parallel across threads — lower peak memory without touching disk. - Check the scan first. A
GROUP BYreading too many rows because of a missing skip index or partition problem always uses more memory than it should — see the skip indices guide. - Raise
max_memory_usageif the cluster genuinely has headroom — RAM is usually faster than spilling to disk.
Diagnose OOM-killed queries
SELECT
event_time, query_id, user, exception_code,
formatReadableSize(memory_usage) AS memory_usage,
normalizeQuery(query) AS normalized_query
FROM system.query_log
WHERE event_time > now() - INTERVAL 1 HOUR
AND type IN ('ExceptionWhileProcessing', 'ExceptionBeforeStart')
AND (exception_code = 241 OR exception LIKE '%MEMORY_LIMIT_EXCEEDED%')
ORDER BY event_time DESC
LIMIT 50;
Confirm a spill actually happened
SELECT
query_id,
ProfileEvents['ExternalAggregationWrittenRows'] AS spilled_rows,
ProfileEvents['ExternalAggregationCompressedBytes'] AS spilled_bytes
FROM system.query_log
WHERE query_id = 'your-query-id'
AND type = 'QueryFinish';
Nonzero spilled_rows means it spilled; zero means it finished in memory and the threshold wasn’t the bottleneck.
How chmonitor surfaces this
The Health page’s OOM-Killed Queries check runs the diagnostic query above continuously and alerts when the rate crosses a threshold, with read_rows, memory_usage, and the query text linked from Expensive Queries. The AI agent’s query-optimization skill watches for exactly this pattern — high memory_usage on an aggregation step.
Related
- Docs: External GROUP BY guide — full spill reference
- Docs: Query optimization pillar
- Live demo: dash.chmonitor.dev/metrics