Skip to content
About
Main content

Sum versus max under deletes

Simulate a modelNeeds Python

Problem

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 ) 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

Under deletes, how much state does an exact incremental MAX need?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    sum-versus-max-under-deletes.py88 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat 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

Incremental aggregates under deletes
ApproachRescansValues read by rescansPeak memory (MB)
SUM000.009
MAX, rescan on delete55858.3K0.685
MAX, sorted multiset000.723
Reference runApple M2 · 8 cores · 8 GB
Output · uv run sum-versus-max-under-deletes.py
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 MB
macOS 26.6.2 · Python 3.13.3 · sortedcontainers 2.4.0 · run 2026-10-20

What 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 , 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.