Incremental view maintenance as the endgame
The Zerodha analytics team has a SQL view that joins the last 24 hours of orders against the holdings table and groups by symbol — the "live PnL by ticker" tile that every retail trader stares at. On the warehouse it is a 90-second query and a ₹0.40 BigQuery slot bill, run every minute by a scheduled refresh, so the dashboard is up to 60 seconds stale and costs ₹17,000/month just to keep that one tile fresh. Every streaming system this curriculum has built — message logs, stream processors, Kappa, Beam, Materialize — has been climbing toward the same goal: write the view in SQL once, have the system update only the rows that changed, pay for work proportional to the change, not the size of the underlying tables. That goal has a name. It is called incremental view maintenance (IVM), and it is the endgame of unified batch and stream.
Incremental view maintenance is the algorithmic problem of keeping a SQL view's answer correct as the base tables change, doing work proportional to the change. Every modern streaming engine — Materialize, Flink SQL, RisingWave, ksqlDB, Snowflake dynamic tables, Databricks materialized views — is a different point on the IVM design space. The endgame for data engineering is when "streaming" becomes invisible: you write SQL, and the runtime decides what to recompute.
What "incremental" actually buys you
A traditional materialized view is a snapshot. You run REFRESH MATERIALIZED VIEW v and the engine recomputes the whole query — every join, every aggregation, every filter — from scratch. If the view is SELECT symbol, SUM(qty * price) FROM orders WHERE ts > now() - interval '24 hour' GROUP BY symbol over 200 million rows, refresh-from-scratch reads 200 million rows even if only 50,000 new orders arrived since the last refresh.
IVM flips this. Given the change to the inputs (50,000 new orders, maybe 100 deletes for cancellations) and the previous answer, the runtime computes the change to the output and applies it. If only 412 distinct symbols saw activity, only 412 rows in the view get updated. The cost is O(Δ) — proportional to the delta — not O(N).
Why this is the endgame and not just an optimisation: most production data is only a tiny fraction "fresh" at any moment. UPI processes 100M+ transactions/day, but at any given second the change rate is 1,000–10,000 rows. A 200M-row table has a delta-to-table ratio of 0.005% per second. IVM gives you a constant factor that matches your actual workload, not your historical data.
The catch — and it is a deep catch — is that "compute the change to the output from the change to the input" is easy for some operators (filter, projection, simple sums) and famously hard for others (joins on inserts and deletes, recursive CTEs, window functions, antijoins). The history of IVM is a history of widening the set of SQL operators for which the easy form is possible.
The delta algebra: how IVM views the world
Every IVM system, from 1980s research on relational deltas to today's Materialize, rests on the same idea: represent every change as a typed pair (row, multiplicity) where multiplicity is +1 for an insert and −1 for a delete. An update is a −1 of the old row plus a +1 of the new row. A "stream of changes" is a stream of these pairs. The base table at any time is the cumulative sum of its change stream.
The operators of relational algebra — σ (filter), π (project), ⋈ (join), γ (group-by-aggregate), ∪ (union) — each get a delta rule that says: "given the current state and an incoming delta, here is the delta to emit downstream."
For filter, the rule is trivial: δ(σ_p(R)) = σ_p(δR). The delta of a filtered relation is the filter applied to the delta. New rows that pass the predicate go through; new rows that fail are dropped; deletes that pass go through as −1s.
For join, it is harder but still local: δ(R ⋈ S) = (δR ⋈ S) ∪ (R ⋈ δS) ∪ (δR ⋈ δS). When R changes by δR, the change to R ⋈ S is δR joined against the current S, plus the symmetric term, plus a quadratic correction term. To compute this, the system must keep R and S materialised as indexes (arrangements in Differential Dataflow's vocabulary, state stores in Flink's vocabulary).
For group-by-aggregate, the rule depends on whether the aggregate is invertible. SUM and COUNT are: a delete row contributes −value and −1 and the running aggregate stays correct. MIN and MAX are not invertible without auxiliary state — if you delete the current minimum, you need to know the second-smallest value, which you cannot produce without rescanning.
Why this matrix matters for picking a streaming engine: ksqlDB, Flink SQL, Materialize, RisingWave, and Snowflake dynamic tables each support a different subset of these operators with full IVM. Materialize and DBSP-based RisingWave handle the largest set including recursion. Flink SQL handles inserts well but historically struggled with retractions on certain joins. ksqlDB is intentionally limited to the easy cases. The "what SQL works" question is exactly "which delta rules has the engine implemented."
A toy IVM engine in 60 lines of Python
To show that the algebra is implementable, here is a working IVM engine for a single SUM ... GROUP BY view over an inserts-and-deletes stream. It is the kernel of every real system.
from collections import defaultdict
from dataclasses import dataclass
from typing import Iterator
@dataclass(frozen=True)
class Change:
row: tuple # the row, e.g. ("RELIANCE", 100, 2480.50)
diff: int # +1 for insert, -1 for delete
class GroupBySumView:
"""Maintains SELECT key, SUM(value) FROM input GROUP BY key, incrementally."""
def __init__(self, key_idx: int, value_idx: int):
self.key_idx = key_idx
self.value_idx = value_idx
self.current = defaultdict(float) # group -> running sum
self.counts = defaultdict(int) # group -> row count (so we know when to drop a group)
def feed(self, deltas: list[Change]) -> Iterator[Change]:
"""Consume input deltas, yield deltas to the output view."""
# Stage 1: compute the change to each group's sum
group_delta_sum = defaultdict(float)
group_delta_cnt = defaultdict(int)
for d in deltas:
k = d.row[self.key_idx]
v = d.row[self.value_idx]
group_delta_sum[k] += d.diff * v
group_delta_cnt[k] += d.diff
# Stage 2: convert per-group changes into output deltas
for k, ds in group_delta_sum.items():
old_sum = self.current[k]
old_cnt = self.counts[k]
new_sum = old_sum + ds
new_cnt = old_cnt + group_delta_cnt[k]
if old_cnt > 0:
yield Change((k, old_sum), -1) # retract old answer
if new_cnt > 0:
yield Change((k, new_sum), +1) # emit new answer
if new_cnt == 0:
self.current.pop(k, None); self.counts.pop(k, None)
else:
self.current[k] = new_sum; self.counts[k] = new_cnt
# --- Demo: live PnL view over Zerodha-style order ticks ---
view = GroupBySumView(key_idx=0, value_idx=1)
batch1 = [
Change(("RELIANCE", 248050.0), +1), # buy 100 @ 2480.50
Change(("TCS", 388900.0), +1), # buy 100 @ 3889.00
Change(("RELIANCE", 124200.0), +1), # buy 50 @ 2484.00
]
print("Output of batch 1:")
for d in view.feed(batch1): print(" ", d)
batch2 = [
Change(("RELIANCE", 248050.0), -1), # cancel the first RELIANCE buy
Change(("INFY", 156500.0), +1), # new INFY buy
]
print("Output of batch 2:")
for d in view.feed(batch2): print(" ", d)
Output of batch 1:
Change(row=('RELIANCE', 248050.0), diff=1)
Change(row=('TCS', 388900.0), diff=1)
Change(row=('RELIANCE', 248050.0), diff=-1)
Change(row=('RELIANCE', 372250.0), diff=1)
Output of batch 2:
Change(row=('RELIANCE', 372250.0), diff=-1)
Change(row=('RELIANCE', 124200.0), diff=1)
Change(row=('INFY', 156500.0), diff=1)
A walkthrough of the key lines:
group_delta_sum[k] += d.diff * v— this is the entire IVM kernel for SUM. Multiplicity times value, accumulated per group. A delete (diff = -1) subtracts the value automatically. There is no special path for inserts vs deletes — they are the same code. Why this trick generalises: every linear aggregate (SUM, COUNT, AVG-as-SUM/COUNT) collapses inserts and deletes into the same arithmetic. Non-linear aggregates (MIN, MAX, MEDIAN) cannot, which is exactly why the IVM design space gets harder there.if old_cnt > 0: yield Change((k, old_sum), -1)— every output change is a retraction of the previous answer plus an emission of the new one. Downstream consumers see the view "change from RELIANCE=372250 to RELIANCE=124200" as two messages, not one. This is whatRETRACTmode looks like on the wire.if new_cnt == 0: self.current.pop(k, None)— when the last row in a group is deleted, the group disappears from the view. Without the count we could not distinguish "sum is genuinely zero" from "no rows", and the downstream view would show a stale(RELIANCE, 0.0)row forever.- The cost of
feed()isO(|deltas| + |groups touched|), neverO(|all rows ever seen|). This is the asymptotic property the warehouse refresh path lacks.
Real systems extend this skeleton along three axes: (1) more operators (joins, recursion, windows), (2) durable arrangements so the state survives restarts, and (3) the timestamp/multiplicity generalisation from Differential Dataflow that lets the system process out-of-order deltas correctly.
Why IVM is the unification of batch and stream
Re-read what the toy engine does. Given a stream of changes, it produces a stream of changes that a downstream consumer can apply to keep its own view in sync. There is no notion of "batch mode" vs "streaming mode" in the engine. If you feed the entire history of a table to it as one big batch of +1s, the output is the same as if you fed the history one row at a time.
This is the property the Dataflow Model (Beam, ch.73), Materialize (ch.76), and DBSP (the formal model behind RisingWave and Feldera) all aim at: a single execution engine that treats batch as a one-time delta and stream as an ongoing one. The query the user writes is the same. The answer the engine maintains is the same. The only difference is whether the input is finite.
This is why the chapter is called "the endgame". When IVM works for the full SQL surface area — joins, aggregations, window functions, recursion — there is no longer a meaningful distinction between "the batch warehouse query" and "the streaming view definition". You write SQL, and the engine decides what to recompute.
Where every modern system sits on the IVM map
The 2020s have produced roughly four design schools:
- DBSP / Differential Dataflow line. Materialize, RisingWave, Feldera. Based on the formal calculus of Mihalache, Budiu, McSherry et al. Supports the widest SQL surface — joins, recursion, window functions, EXCEPT, EXISTS — all incrementally. Pays the price in implementation complexity.
- Flink SQL line. Incrementality is operator-by-operator with retraction streams. Joins are supported but have memory implications (state growth) the user must reason about. Window functions on event time are first-class.
- Snowflake dynamic tables / Databricks materialized views line. Closer to "incremental refresh on a schedule" than continuous IVM. The engine looks at the query and decides whether each operator can be incrementalised; if not, it falls back to full refresh. Pragmatic, less ambitious.
- ksqlDB / Kafka Streams line. Limited SQL, mostly stateless transformations and simple aggregations on a single stream. Incrementality is implicit because the model is so restricted.
The trajectory is clear: every vendor is racing toward the DBSP frontier because that is the SQL surface area customers actually want. The bottleneck is engineering, not algorithms — the delta calculus is published.
Common confusions
- "IVM is the same as caching the query result." A cache invalidates and recomputes; IVM applies the delta to the cached answer. A cache for the Zerodha view would re-run the 90-second query on every input change. IVM updates only the rows whose group saw activity.
- "IVM is the same as streaming." Streaming is a delivery model — events flowing as they happen. IVM is an algorithm — given a delta, compute the delta to the answer. You can do IVM on a batch (one big delta) and you can do streaming without IVM (Flink without retractions, just emitting raw events).
- "If my SQL has a join, IVM costs O(N) anyway." Only if you don't keep indexes on the inputs. With indexes (Materialize calls them arrangements, Flink calls them state stores), join IVM costs
O(|δ| × match-factor), which is usually small. - "MIN and MAX cannot be incrementalised." They can, but the auxiliary state is a sorted multiset per group, not a single scalar. Materialize and RisingWave handle this; some engines fall back to full recompute on the affected group.
- "IVM is the same as materialized views in Postgres." Postgres materialized views are snapshot-only; you must
REFRESHthem. TheIVMextension and forks likepg_ivmadd real incremental maintenance, but it is not in vanilla Postgres. - "IVM is purely a streaming-database concern." Snowflake dynamic tables, Databricks materialized views, BigQuery materialized views, and Iceberg's incremental refresh tooling are all IVM at increasing levels of completeness. The endgame is happening in the warehouse too.
Going deeper
The DBSP calculus in one paragraph
DBSP (Database Stream Processor) by Budiu, McSherry, Tannen, et al. (2022) generalises Differential Dataflow into a typed calculus over Z-streams — streams of (record, multiplicity) pairs where multiplicity is an integer. Every relational operator is given a derivative operator in this calculus, and a chain rule lets the system compose incremental versions of arbitrary SQL queries automatically. The deep result is that for a wide class of SQL (including recursion via fixed-point operators), the chain rule produces an incremental query that is provably equivalent to running the original query on the cumulative input — and the cost is bounded by the change. The Feldera engine (open-source) is the reference implementation; RisingWave's planner consumes the same theory.
The retraction problem and why some engines emit "rolling" updates
When a SUM-over-GROUP-BY changes, the engine must emit a retraction of the old value and an emission of the new value. Downstream consumers must apply these in order — if the dashboard reads the topic at the wrong moment, it sees a (RELIANCE, 372250) retraction with no new value yet, and shows a flicker. Materialize's SUBSCRIBE and Flink's "changelog stream" mode both expose retraction explicitly. Some engines (ksqlDB, older Snowflake dynamic tables) hide retractions and emit only "the latest answer per key", which is friendlier for naive consumers but loses information about when things changed.
Cost: why IVM beats refresh only when changes are small
The crossover is when |δ| / |table| > some k. If you replace 30% of a table in one shot, IVM does as much work as full refresh — and pays the bookkeeping overhead on top. This is why batch backfills usually bypass IVM (you REFRESH the view fresh) and incremental updates use it. Flipkart's catalogue rebuild during Big Billion Days preparation is a classic case: 14× normal traffic for 4 hours, with full-table reprocessing during prep — they switch off IVM for the prep window, run a full warehouse refresh, then switch IVM back on.
Recursion: PageRank, transitive closure, shortest paths
The hard test for an IVM engine is recursive SQL — WITH RECURSIVE. PageRank is an iterative fixed-point computation: each round of the iteration is a SQL aggregation, the engine runs to convergence. Materialize and Feldera incrementalise this: when an edge is added to the graph, only the affected portion of the fixed point is recomputed. Most other engines reject recursive views entirely or full-refresh them on every input change. This is the operator that separates "streaming SQL" from "real IVM".
Where Indian companies are using this today
Razorpay's fraud team runs Materialize for the live merchant-risk score view (per ch.76). Zerodha's analytics team uses ClickHouse materialized views (a partial-IVM model — incrementally maintained but with limitations on join shape) for trade-tape aggregations. Cred's rewards team uses Snowflake dynamic tables for the daily-spend cohort view. Each picked the engine whose IVM coverage matched their query — a Razorpay-style 4-table join with windowing needs Materialize; a single-table SUM aggregation is fine on dynamic tables.
Where this leads next
- /wiki/wall-oltp-databases-are-a-source-you-dont-control — IVM is only useful if the deltas are correct, which means the source-of-truth (Postgres, MySQL) must emit a faithful change stream. The Wall opening Build 11 is exactly this problem.
- /wiki/logical-decoding-postgres-replication-slots-from-scratch — the next chapter, where the deltas come from in the first place.
- /wiki/iceberg-incremental-refresh — IVM applied to lakehouse table formats: how Iceberg snapshot diffs feed downstream materializations.
References
- DBSP: Automatic Incremental View Maintenance for Rich Query Languages (Budiu, McSherry, Tannen, Klymenko, et al., VLDB 2022) — the formal calculus, the chain rule, the recursion proof.
- Differential Dataflow (McSherry, Murray, Isard, Abadi, CIDR 2013) — the timestamped-multiset foundation Materialize and RisingWave both build on.
- Materialize architecture overview (Materialize docs) — concrete realisation of DBSP-style IVM.
- Apache Flink streaming SQL — dynamic tables and continuous queries — Flink's framing of the same problem.
- Snowflake dynamic tables (Snowflake docs) — the warehouse vendor's pragmatic IVM.
- pg_ivm — incremental view maintenance for Postgres — extension that brings real IVM to mainline Postgres.
- Feldera (open-source DBSP runtime) — the reference implementation if you want to read code.
- /wiki/materialize-and-differential-dataflow-the-database-view — the previous chapter; the system that motivated this calculus.