Forty materialized aggregates raised the warehouse bill instead of cutting it — how do you decide which to keep?
answer
- cost is automatic, return is not
- find the ones nobody actually reads
- refresh cadence versus read frequency
- consolidate onto the coarsest shared grain
- every aggregate needs an owner and an expiry
basics
~20 sBuild a ledger per aggregate: query cost avoided across its actual hits versus its refresh cost times refresh frequency, plus storage. Kill the zero-hit ones, slow the over-refreshed ones, and consolidate near-duplicates onto the coarsest grain several consumers can share.
solid answer
~50 sTreat each aggregate as an investment with a measurable return. **Cost** is refresh compute times refresh frequency plus storage; **return** is bytes or compute avoided per hit times the number of hits. Get hits from query history by target relation — if an aggregate has none, it is pure cost and goes today, and in a set of forty there are usually several. Next find the ones refreshed far more often than they are read: a fifteen-minute refresh serving a weekly report is the classic inversion, and relaxing cadence to the consumer's real staleness SLA fixes it without deleting anything. Then consolidate: several narrow aggregates can often be replaced by one at the coarsest grain that all of them can re-aggregate from, so you refresh once instead of five times. Finally, keep the survivors governed — an owner, a stated SLA and an expiry review — and remember that clustering the fact table or caching results may be the cheaper lever for some of them.
code
sql · 10 lines-- Ledger sketch: hits per aggregate over a full reporting cycle
SELECT scanned_relation AS aggregate_name,
count(*) AS hits_30d,
sum(bytes_scanned) AS bytes_read_30d,
max(query_start_time) AS last_hit
FROM query_history_by_relation
WHERE query_start_time >= current_date - 30
AND scanned_relation LIKE 'agg_%'
GROUP BY 1
ORDER BY hits_30d ASC;go deeper
Know that a pre-computed aggregate is not free: it costs compute every time it is rebuilt plus storage, and it only pays back if queries actually read it.
Be able to compare the two sides — refresh cost times frequency against savings times hit count — and explain why an aggregate refreshed far more often than it is read is a net loss.
Show the diagnostic path: pull hit counts from query history, find the dead and the over-refreshed, and consolidate overlapping grains onto one aggregate that several consumers can re-aggregate from.
Own the portfolio policy — ownership, staleness SLAs, expiry review, cost attributed to the consuming team — and be ready to argue when partitioning, caching or compute isolation is the better lever than another aggregate.
## Why forty aggregates cost more than none Every materialized aggregate has a fixed recurring cost — refresh compute multiplied by refresh frequency, plus storage — and a variable return that exists only when queries actually hit it. Teams add aggregates one at a time in response to individual complaints, each defensible in isolation, and nobody ever measures the portfolio. The failure is structural: cost accrues automatically while return depends on continued use, so an unmanaged set of aggregates drifts toward net loss. ## Build the ledger first Before deleting anything, produce one row per aggregate with: - **Refresh cost per run** and **runs per day** — the recurring spend. - **Storage footprint** — usually the small term, but not on wide or fine-grained aggregates. - **Hits**: how many queries actually read it, from query history grouped by the relation scanned. Where the engine rewrites transparently, this is the number to look for in the plan or history, not the number of queries someone believes should be hitting it. - **Saving per hit**: bytes or compute the hit avoided, estimated by comparing against the equivalent fact-table scan. Return is hits times saving per hit. Anything where recurring cost exceeds return is a candidate, and the ledger converts an argument about opinions into a list sorted by money. ## The four categories you will find **Zero-hit aggregates.** Built for a dashboard that was retired, or superseded by another aggregate, or never eligible for rewrite in the first place because a filter column was missing. Delete them. In a portfolio of forty there are almost always a handful, and they are the cheapest win available. **Over-refreshed aggregates.** Refresh cadence set by habit rather than by the consumer's requirement. An aggregate refreshed every fifteen minutes to serve a report opened weekly is paying ninety-six times a day for one read. The fix is not deletion but matching cadence to the stated staleness SLA — and the act of asking each consumer what staleness they actually tolerate usually recovers more money than any deletion, because the honest answer is very often much looser than the current setting. **Near-duplicates.** Five aggregates over the same fact table at overlapping grains, each refreshing independently and each re-scanning the same source. Replace them with one at the coarsest grain from which all five queries can be re-aggregated, provided the measures are additive. One refresh instead of five, one thing to keep correct. Where grains genuinely conflict, consider a cascade — refresh a fine aggregate from the fact table, then derive coarser ones from it — which trades a dependency chain and some latency for a much smaller total scan. **Genuinely load-bearing aggregates.** High hit counts, large per-hit savings, cadence matched to need. Leave them alone and protect them: they are the reason the pattern exists. ## Question whether an aggregate was the right lever Some entries on the list should never have been aggregates. Before rebuilding them, check: - Would **partitioning or clustering the fact table** on the dashboard's filter column prune to a similar cost with nothing to maintain? - Is the query repeated identically often enough that a **result cache** answers it for free, making the aggregate redundant? - Is the workload's real problem **concurrency or isolation** rather than scan volume, in which case separating compute would fix it and an aggregate will not? An aggregate is a standing maintenance obligation. It should win against these alternatives, not be the reflex. ## Governance so it does not recur The cleanup is worthless if the portfolio regrows the same way. The durable fixes are organisational: - **Every aggregate has an owner and a declared consumer.** No owner, no aggregate. - **Every aggregate has a stated staleness SLA**, and cadence is derived from it rather than chosen. - **Expiry by default**: a quarterly review deletes anything with no hits since the last review, and the cost report goes to the owning team, not to a central platform budget where nobody feels it. - **A shared catalogue** so the next person finds the existing coarse aggregate instead of adding the forty-first. ## What to say in the interview Lead with measurement, not intuition: hits and refresh cost per aggregate. Then the sequence — delete the dead, re-cadence the over-refreshed, consolidate the overlapping, protect the load-bearing — and close with governance, because the portfolio grew to forty for reasons that will still be there next quarter.
- How would you determine whether an aggregate is being hit at all?Group query history by the relation actually scanned, over a window long enough to cover weekly and monthly reports, and count queries per aggregate. Where the engine rewrites transparently, the executed plan rather than the submitted SQL names the aggregate, so the history view that records the scanned objects is the source of truth. Zero hits over a full cycle is grounds for deletion, not for investigation.
- When is a cascade of aggregates worth the dependency chain it creates?When several coarse aggregates all derive from the same fact table and each refresh re-scans it. Refreshing one fine aggregate from the facts and deriving the coarser ones from it turns N large scans into one plus several small ones. You pay with ordering constraints, added end-to-end latency and a failure in the chain affecting everything downstream, so it pays when the base scan dominates and the grains genuinely nest.
- What would make you rebuild the fact table's physical layout instead of adding another aggregate?When the queries filter consistently on one or two columns and the fact table is not partitioned or clustered on them. Pruning gets much of an aggregate's benefit with no refresh obligation, no staleness and no correctness surface. Aggregates earn their keep when the win comes from collapsing rows rather than skipping them — a heavy GROUP BY over a wide time range that pruning cannot reduce.
- How do you set refresh cadence without guessing?Ask each consumer for the staleness they can actually tolerate and derive cadence from the tightest one, rather than picking an interval by habit. Record the SLA next to the aggregate so the choice is auditable. Most reporting consumers tolerate far more staleness than their aggregate is configured for, and simply writing the number down usually cuts refresh spend more than any deletion does.
saying these in an interview costs you the question
- Judges aggregates by size or age rather than by hits and refresh cost
- Refreshes every aggregate on the same cadence regardless of consumer need
- Adds another aggregate rather than consolidating overlapping ones
- Never checks whether clustering the fact table would do the same job
- Deletes without measuring, then rebuilds under pressure a month later