A nightly reporting query that sorts and groups a large table ran in 40 seconds for months and now takes 25 minutes, with no code change and only steady data growth. Walk through how you would confirm that sort/aggregate memory is the cause and what you would change.
answer
- Cliff, not ramp → threshold crossed
- Executed plan: sort method + temp bytes; hash batches > 1
- Estimated vs actual rows = which story
- Fix order: fewer/narrower rows → index order → stats → memory last
- Scope the memory bump to the batch session
basics
~20 sGet the executed plan with actual row counts and per-operator memory. Look for a sort or hash aggregate that switched from in-memory to external/batched with large temp bytes, and for actual rows far above estimates. Then cut rows and columns feeding it, supply order via an index, refresh statistics, and only then raise the memory budget for that job.
solid answer
~1 min**Confirm, do not guess.** 1. Capture the plan *with actual counts and memory* (`EXPLAIN (ANALYZE, BUFFERS)`, `SET STATISTICS PROFILE`, a real execution plan — engine-specific but always available). A cost-only plan cannot show a spill. 2. Look for the tell: a sort reporting an external/merge method with temp bytes, or a hash aggregate reporting multiple batches/partitions with disk usage. Correlate with temp-file counters and temp-device I/O over the run window. 3. Compare estimated vs actual rows on that operator. If actual is orders of magnitude higher, the trigger is an estimate problem, not just growth. **Then fix, in this order.** - **Reduce input**: push filters earlier, drop unused wide columns so each spilled tuple is narrower, pre-aggregate. - **Remove the operator**: an index that supplies the grouping/ordering key turns a spilling sort-aggregate into a streaming one. - **Fix estimates**: refresh statistics; add multi-column statistics for correlated grouping keys so the planner stops under-sizing the hash table. - **Raise the budget last**, scoped to that batch session rather than server-wide, because the setting is per operator per query and this job may run alongside others. Finally, add monitoring on temp bytes so the next crossing is caught before it is a 25-minute job.
code
text · 7 lines-- months ago
GROUP AGGREGATE
-> SORT (key=account_id, method=quicksort, memory=61 MB)
-- now
GROUP AGGREGATE
-> SORT (key=account_id, method=external merge, temp_written=3.4 GB)go deeper
Know to look at the actual executed plan and check whether a sort or grouping step is using temporary disk files.
Name the specific plan markers (external sort method, multiple hash batches, temp bytes) and the obvious mitigations of filtering earlier and projecting fewer columns.
Deliver the full evidence chain, distinguish honest growth from estimation failure, and give a remediation order that treats a memory bump as the scoped last resort.
Talk about the operating model: headroom metrics rather than duration as the alert signal, memory admission policy across mixed OLTP and batch workloads, and whether the report should be pre-aggregated so the operator never sees the full history.
## Why the curve is a cliff The shape of the regression is the biggest clue. Steady data growth producing a 37× slowdown with no code change is the signature of a **threshold crossing**, not of linear growth. Two candidates dominate: a working set that no longer fits the buffer pool, and a sort or hash aggregate whose state no longer fits its memory budget so it began spilling to temporary files. Both are step functions; the second is the one this question is about, and it is the more common cause when the query contains ORDER BY, GROUP BY, or DISTINCT over a large set. ## Step 1 — get an executed plan, not an estimated one Every engine distinguishes the plan it *would* use from the plan it *did* run with measured row counts and resource usage. Only the executed form reports sort methods and spill sizes. Ask for it on the real data volume — reproducing on a scaled-down copy will hide the very threshold you are hunting. What you are reading for: - **Sort operators**: a method described as external merge / external sort, with a temp size in MB or GB. An in-memory sort reports a small memory figure instead. - **Hash aggregates / hash distinct**: a batch or partition count above one, plus disk usage. One batch means it stayed resident. - **Actual vs estimated rows** at each operator, especially the input to the sort/aggregate and the aggregate's own output (which is the group count). ## Step 2 — corroborate outside the plan Plan output can be sampled or truncated, so confirm with instance-level evidence over the job's window: temporary file creation count and bytes, temp tablespace / tempdb usage, and I/O on the temp device. A large spike aligned to the job is confirmation. If the temp device is shared with data files, the spill also degrades everything else running at the same time — which explains "the whole system feels slow at 2am" reports that accompany this. Rule out the alternatives while you are there: a plan *shape* change (different join or access method than before) points at statistics or parameter sensitivity rather than memory; a buffer-cache hit-ratio collapse with high physical reads points at working-set growth; lock waits point somewhere else entirely. If the plan shape is identical to the old one and only the sort method changed, you have your answer. ## Step 3 — classify the cause Two distinct stories produce the same spill: **(a) Honest growth.** Estimates match actuals; the data simply outgrew the budget. The fix is about the operator's input size or its budget. **(b) Estimation failure.** Actual rows or groups far exceed the estimate. The planner sized the plan for a small hash table and chose hash aggregation; reality needed far more. Correlated grouping columns and stale statistics are the usual culprits. Here, fixing the estimate can change the plan to something structurally better, not merely make the same plan fit. ## Step 4 — remediate in leverage order 1. **Shrink what reaches the operator.** Two independent levers: fewer rows (push predicates below the sort, aggregate earlier, filter partitions) and narrower rows (project only the columns actually needed — spilled tuples carry every column the plan propagates, so dropping a couple of wide text columns can halve temp bytes). This is usually the largest and safest win. 2. **Eliminate the operator.** An index on the grouping or ordering key gives ordered input, converting a hash aggregate that spills into a streaming grouped aggregate with negligible memory, or removing a sort entirely. For a reporting job that always groups the same way, this is often the permanent fix. Weigh the write cost of the extra index. 3. **Repair estimates.** Refresh statistics; where the grouping key spans correlated columns, create multi-column/extended statistics so the distinct-group estimate stops being the product of per-column guesses. 4. **Consider pre-aggregation.** A nightly report that repeatedly grinds the same base data is a candidate for an incrementally maintained summary table, turning an O(all history) job into O(yesterday). 5. **Raise the memory budget last, and scope it.** The budget is charged per operator per query, so a global increase multiplies across every concurrent session and is a classic route to OOM. Setting it for the batch role or that session only gives the report the memory it needs without exposing the OLTP workload. ## Step 5 — make it not recur Record the temp bytes for the job and alert on them. The operational lesson is that the metric to watch is not "query duration" — which stays flat right up to the cliff — but the headroom quantities: sort/hash working-set size versus budget, and estimated versus actual rows on stateful operators. Those degrade visibly for weeks before the duration does. ## What interviewers are listening for A disciplined evidence chain (executed plan → operator-level spill evidence → system counters), the discrimination between honest growth and estimation failure, and a remediation order that puts "raise the memory setting" *last* with an explicit note about per-operator-per-query multiplication under concurrency. Candidates who open with "increase work_mem" have skipped both the diagnosis and the blast-radius question.
- The report runs at 2am when the box is idle. Is raising the per-operation memory budget server-wide acceptable then?Still no — scope it. The budget is granted per operator instance per query, so a plan with several sorts and hashes multiplies it, and the daytime OLTP workload would inherit the same inflated setting at high concurrency. Set it for the batch role or inside that job's session, which gives the report its memory without changing the risk profile of everything else.
- How would you tell this apart from a regression caused by the buffer cache no longer holding the working set?Look at which counters moved. A buffer-cache problem shows as a fall in cache hit ratio and a rise in physical reads of data files, with the sort still reporting an in-memory method. A memory-grant problem shows as temp-file creation and temp-device I/O with the sort or aggregate reporting an external method, while data-file read patterns are unchanged. They can co-occur, but the temp-bytes metric is decisive for the spill.
saying these in an interview costs you the question
- Opening with "increase work_mem / the memory grant" before capturing an executed plan
- Reading a cost-only estimated plan and claiming to see a spill in it
- Reproducing on a small copy of the data, where the threshold is never crossed
- Ignoring tuple width — carrying unused wide columns into the sort inflates temp I/O
- Assuming a gradual slowdown; the cliff shape itself is the diagnostic clue