Sum versus max under deletes
Simulate a modelNeeds Python
Problem
Incremental view maintenance keeps an aggregate up to date by applying each change instead of recomputing. Whether that is cheap is an algebra question. SUM can absorb a deleted row by subtracting it, because addition has an inverse. MAX cannot: if you delete the current maximum, nothing in the old value tells you the second largest, so you must re-scan the group or keep every value (the whole multiset) in advance.
Implement an incremental SUM and an incremental MAX per group in 20 lines of Python. Feed both a stream of (group, value, +1/−1) changes. Count how many times each one has to re-scan the group. Then change MAX to keep a sorted multiset and compare memory.
The script uses 200,000 changes over 100 groups, 30% of them deletes of earlier values.
The question. How many rescans does an incremental MAX need under deletes, and how much state does each approach keep compared with SUM?
Predict
Expect SUM to need no group data, and MAX to need the whole multiset (memory proportional to input).
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
sum-versus-max-under-deletes.py # /// script # requires-python = ">=3.10" # dependencies = ["sortedcontainers"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """SUM versus MAX under deletes. An incremental SUM and an incremental MAX per group, fed the same stream of (group, value, +1/-1) changes. SUM needs nothing but its running total; MAX has no inverse, so deleting the current maximum forces a rescan of the group, or a stored multiset of every value. """ import argparse, json, random, tracemalloc from collections import defaultdict from sortedcontainers import SortedList p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--changes", type=int, default=200_000, help="Changes in the stream.") p.add_argument("--groups", type=int, default=100, help="Groups (for example cities).") p.add_argument("--delete-share", type=float, default=0.3, help="Share of changes that delete an existing value.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() random.seed(args.seed) # The stream: inserts of new values, and deletes of values inserted earlier live, stream = defaultdict(list), [] for _ in range(args.changes): g = random.randrange(args.groups) if live[g] and random.random() < args.delete_share: v = live[g].pop(random.randrange(len(live[g]))); stream.append((g, v, -1)) else: v = round(random.uniform(1, 1000), 2); live[g].append(v); stream.append((g, v, +1)) def run_sum(): sums = defaultdict(float) for g, v, sign in stream: sums[g] += sign * v # O(1) for insert and delete return sums def run_max_rescan(): """MAX with the group's values kept as a plain list; a delete of the max rescans the group.""" maxes, values, rescans, scanned = {}, defaultdict(list), 0, 0 for g, v, sign in stream: if sign > 0: values[g].append(v); maxes[g] = v if maxes.get(g) is None else max(maxes[g], v) else: values[g].remove(v) if v == maxes[g]: # the max was deleted: nothing in it says what is next rescans += 1; scanned += len(values[g]) maxes[g] = max(values[g]) if values[g] else None return maxes, rescans, scanned def run_max_sorted(): """MAX with a sorted multiset per group: no rescans, but every live value is stored.""" values = defaultdict(SortedList) for g, v, sign in stream: if sign > 0: values[g].add(v) else: values[g].remove(v) return {g: (s[-1] if s else None) for g, s in values.items()} def measured(fn): tracemalloc.start(); out = fn(); _, peak = tracemalloc.get_traced_memory(); tracemalloc.stop() return out, peak sums, sum_mem = measured(run_sum) (maxes, rescans, scanned), rescan_mem = measured(run_max_rescan) sorted_max, sorted_mem = measured(run_max_sorted) deletes = sum(1 for _, _, s in stream if s < 0) assert all(maxes[g] == sorted_max[g] for g in maxes) print(f"{len(stream):,} changes over {args.groups} groups, {deletes:,} of them deletes") print(f"SUM: 0 rescans, state = {args.groups} running totals, peak memory {sum_mem / 1e3:,.0f} KB") print(f"MAX, rescan on delete: {rescans:,} rescans reading {scanned:,} values, peak memory {rescan_mem / 1e6:,.1f} MB") print(f"MAX, sorted multiset: 0 rescans, every live value stored, peak memory {sorted_mem / 1e6:,.1f} MB") if args.json: with open(args.json, "w") as f: json.dump({"tables": { "approaches": [{"approach": "SUM", "rescans": 0, "values_read": 0, "peak_mb": sum_mem / 1e6}, {"approach": "MAX, rescan on delete", "rescans": rescans, "values_read": scanned, "peak_mb": rescan_mem / 1e6}, {"approach": "MAX, sorted multiset", "rescans": 0, "values_read": 0, "peak_mb": sorted_mem / 1e6}], "summary": [{"deletes": deletes, "rescans": rescans, "scanned": scanned, "sum_kb": sum_mem / 1e3, "rescan_mb": rescan_mem / 1e6, "sorted_mb": sorted_mem / 1e6}]}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run sum-versus-max-under-deletes.py uv run sum-versus-max-under-deletes.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --changes200,000 | Changes in the stream. |
| --groups100 | Groups (for example cities). |
| --delete-share0.3 | Share of changes that delete an existing value. |
| --seed0 | Random seed. |
Our run
Incremental aggregates under deletes
| Approach | Rescans | Values read by rescans | Peak memory (MB) |
|---|---|---|---|
| SUM | 0 | 0 | 0.009 |
| MAX, rescan on delete | 558 | 58.3K | 0.685 |
| MAX, sorted multiset | 0 | 0 | 0.723 |
200,000 changes over 100 groups, 60,298 of them deletes
SUM: 0 rescans, state = 100 running totals, peak memory 9 KB
MAX, rescan on delete: 558 rescans reading 58,347 values, peak memory 0.7 MB
MAX, sorted multiset: 0 rescans, every live value stored, peak memory 0.7 MBWhat we found
SUM absorbed all 60.3K deletes by subtraction, with no rescans and 9.06 KB of state: one running total per group. MAX needed 558 rescans, reading 58.3K values, every time a delete removed a group's current maximum, and to rescan at all it had to keep every value (0.685 MB). The sorted multiset removed the rescans but kept the same values (0.723 MB). Either way MAX holds state proportional to the input, as expected.
Read it
Incrementality requires an inverse. An operator absorbs changes cheaply only if its state forms a group (COUNT, SUM); MIN and MAX are only a semigroup, and DISTINCT or quantiles need the full multiset. Before promising an incremental view, check its aggregates against this table: the ones without an inverse decide the cost.
The practical middle ground is to keep a little extra: the top few values per group, rescanning only when deletes exhaust them, or recomputing those groups in a periodic batch.