How it works
A SQL-to-SQL compiler for incremental computation.
OpenIVM does not replace your engine. It reads your view definition and emits the SQL that keeps it up to date, so the same approach carries from DuckDB to Spark and beyond.
IVM in one equation.
Given a table T and a view V = Q(T), find a function f so that f(ΔT) = ΔV: compute the change to the view from the change to the table alone, instead of re-running Q.
OpenIVM follows DBSP, which gives such an f for arbitrary relational queries using two operators: differentiation D (ΔT = T′ − T) and integration I (T + ΔT = T′). Each operator is replaced by its incremental form, and the plan runs over deltas.
Selection and projection are their own incremental form; a join becomes three joins.
Changes add up: Rnew = Rold + ΔR. For regional_totals (region → total):
{ US → 100, EU → 200 }{ US → +50, JP → +300, EU → −200 }{ US → 150, JP → 300 }EU drops out: its only sale was deleted, so its row count reaches zero.
…and every algebraic step is plain SQL
UNION ALL+ grouped weight sumsJOINweights multiplyMERGE/ INSERT, DELETEEvery change is a weighted row.
A delta table has the same columns as its source, plus a signed weight. An INSERT writes +1, a DELETE writes −1, and an UPDATE writes both (old row −1, new row +1), inside the same transaction as the DML. Operators then work on these weights algebraically: aggregates sum weight × value, joins multiply weights.
Several views can share one delta table; each keeps its own cursor and reads only what it has not yet processed. Over DuckLake, there is no delta table at all: changes are derived from table snapshots, and the pre-refresh state is read through time travel.
SELECT * FROM openivm_delta_sales;
| region | product | amount | multiplicity |
|---|---|---|---|
| US | Bolt | 50 | +1 |
| JP | Gear | 300 | +1 |
| EU | Gadget | 200 | −1 |
After two inserts and one delete. Timestamps omitted. Captured from the DuckDB extension.
A real engine underneath.
Most IVM compilers are detached prototypes that talk to a database through drivers. OpenIVM lives inside one: DuckDB parses, binds and plans the view, so the compiler gets schema validation, cardinality estimates and a cost model for free, and physical choices such as indexes or what to materialize stay tunable.
- Normalize. Intercept
CREATE MATERIALIZED VIEW, take the bound logical plan, and rewrite equivalent shapes into forms the delta rules handle. - Model. Build a delta model: each operator's rule kind, the state it needs, and its lineage, a mapping from an affected output key, group or window partition back to the source columns that can change it.
- Rewrite. Turn source scans into deltas and propagate them operator by operator, restricting any recomputation to the exact domain lineage says was touched.
- Emit. LPTS (Logical Plan to SQL) turns the rewritten plan into a dialect-independent AST, flattens it into an ordered CTE program, and renders it for the target engine. OpenIVM decides what to compute; LPTS decides how to say it.
What a refresh actually runs.
One view definition becomes several SQL statements: compute ΔV from ΔT, fold ΔV into V, publish the rows that changed, then clear the consumed deltas. Below is what OpenIVM compiles for regional_totals, captured with SET openivm_files_path. Internal tables are named after a key derived from the view's catalog, schema and name, shown as <key>; catalog prefixes and literal timestamps are elided. It is ordinary SQL, which is what makes the approach portable.
A grouped aggregate over one table. This is all you write.
CREATE MATERIALIZED VIEW regional_totals AS
SELECT region, SUM(amount) AS total, COUNT(*) AS cnt
FROM sales
GROUP BY region;The view is stored in openivm_data_<key> with a hidden non-NULL count, so a group that loses all its values returns NULL rather than 0. Readers see openivm_visible_<key>, the published result, through a plain view. Changes to sales are captured in openivm_delta_sales.
CREATE TABLE openivm_data_<key> AS
SELECT region, sum(amount) AS total, count_star() AS cnt,
count(amount) AS openivm_nonnull_sum_count_1
FROM sales
GROUP BY region;
CREATE TABLE openivm_visible_<key> AS
SELECT * EXCLUDE (openivm_nonnull_sum_count_1), CAST(0 AS BIGINT) AS openivm_ordinal
FROM openivm_data_<key>;
CREATE VIEW regional_totals AS
SELECT * EXCLUDE (openivm_ordinal) FROM openivm_visible_<key>;
CREATE UNIQUE INDEX openivm_data_<key>openivm_index ON openivm_data_<key>(region);On refresh, only rows newer than the view's cursor are read. Duplicate deltas are consolidated first (a +1 and a −1 for the same tuple cancel), then the aggregate is applied to the delta itself, keeping the multiplicity as a group key.
WITH
t0_scan (t0_region, t0_amount, t0_openivm_multiplicity) AS (
SELECT region, amount, openivm_multiplicity
FROM openivm_delta_sales
WHERE openivm_timestamp >= $last_update
),
t1_aggregate (t8_region, t8_amount, t9_openivm_multiplicity) AS (
SELECT t0_region, t0_amount, sum(t0_openivm_multiplicity)
FROM t0_scan
GROUP BY t0_region, t0_amount
),
t2_block (t2_region, total, cnt, openivm_nonnull_sum_count_1, t1_openivm_multiplicity) AS (
SELECT t8_region, sum(t8_amount), count_star(), count(t8_amount),
CAST(t9_openivm_multiplicity AS INTEGER)
FROM t1_aggregate
WHERE t9_openivm_multiplicity != 0
GROUP BY t8_region, CAST(t9_openivm_multiplicity AS INTEGER)
)
INSERT INTO openivm_delta_<key>
(region, total, cnt, openivm_nonnull_sum_count_1, openivm_multiplicity)
SELECT * FROM t2_block;The view's delta is folded with SUM(multiplicity × value) and merged on the group key; groups whose count drops to zero are deleted.
WITH refresh_cte AS (
SELECT region,
sum(openivm_multiplicity * total) AS total,
sum(openivm_multiplicity * cnt) AS cnt,
sum(openivm_multiplicity * openivm_nonnull_sum_count_1) AS openivm_nonnull_sum_count_1
FROM openivm_delta_<key>
WHERE openivm_timestamp > $last_update
GROUP BY region
)
MERGE INTO openivm_data_<key> v USING refresh_cte d
ON v.region IS NOT DISTINCT FROM d.region
WHEN MATCHED THEN UPDATE SET
total = CASE WHEN COALESCE(v.openivm_nonnull_sum_count_1 + d.openivm_nonnull_sum_count_1,
v.openivm_nonnull_sum_count_1, d.openivm_nonnull_sum_count_1) = 0
THEN NULL
ELSE COALESCE(v.total + d.total, v.total, d.total) END,
cnt = COALESCE(v.cnt + d.cnt, v.cnt, d.cnt),
openivm_nonnull_sum_count_1 = COALESCE(v.openivm_nonnull_sum_count_1 + d.openivm_nonnull_sum_count_1,
v.openivm_nonnull_sum_count_1, d.openivm_nonnull_sum_count_1)
WHEN NOT MATCHED THEN INSERT (region, total, cnt, openivm_nonnull_sum_count_1)
VALUES (d.region, d.total, d.cnt, d.openivm_nonnull_sum_count_1);
DELETE FROM openivm_data_<key> WHERE COALESCE(cnt, 0) = 0;Only the affected groups are compared with the published table. Their signed difference replaces the old visible rows and is recorded as a delta, which is what downstream views consume.
CREATE TEMP TABLE openivm_publish_changes_<key> AS
SELECT region, total, cnt, openivm_ordinal, SUM(openivm_publish_weight) AS openivm_publish_weight
FROM (
SELECT *, -1 AS openivm_publish_weight -- old visible rows of affected groups
FROM openivm_visible_<key> v
WHERE EXISTS (SELECT 1 FROM openivm_delta_<key> d WHERE v.region IS NOT DISTINCT FROM d.region)
UNION ALL
SELECT *, +1 FROM openivm_publish_next_<key> -- their new rows
) signed_rows
GROUP BY region, total, cnt, openivm_ordinal
HAVING SUM(openivm_publish_weight) <> 0;
-- then: append the signed changes to openivm_delta_openivm_visible_<key>,
-- delete the old rows from openivm_visible_<key> and insert the new onesConsumed deltas are removed once every dependent view has moved past them, and the cursor advances to MAX(timestamp) + 1µs of what this refresh actually saw, not now(), so a concurrent insert is never skipped or applied twice.
DELETE FROM openivm_delta_<key>;
DELETE FROM openivm_delta_sales
WHERE openivm_timestamp < (SELECT MIN(last_update) FROM openivm_delta_tables
WHERE table_name = 'openivm_delta_sales');
UPDATE openivm_delta_tables
SET last_update = COALESCE((SELECT MAX(openivm_timestamp) + INTERVAL '1 microsecond'
FROM openivm_delta_sales),
make_timestamp(epoch_us(now()))),
last_refresh_ts = make_timestamp(epoch_us(now()))
WHERE view_name = '<key>' AND table_name = 'openivm_delta_sales';Maintain views across engines.
Modern pipelines span several systems: transactions in one, analytics in another. Because the maintenance steps are SQL, rendered by LPTS in each engine's dialect, they can be shipped to wherever the data lives instead of copying the data to wherever the IVM engine lives.
A view that only does the work that changed.
The view below is SELECT region, SUM(amount), COUNT(*) FROM sales GROUP BY region. Changes collect as signed deltas; a refresh folds them in. Insert then delete the same row and the deltas cancel out before any work is done.
sales base table
| region | amount |
|---|
Δ sales pending deltas
| ± | region | amount |
|---|
No pending changes.
regional_totals materialized view
| region | total | cnt |
|---|
Go deeper.
The full internals live in the repository docs, next to the code they describe.
Delta rules
How each operator is classified (linear, product, stateful, non-linear) and what state it needs.
Read →Operator coverage
Joins, aggregates, DISTINCT, semi/anti, windows, top-k: the delta rule and refresh strategy for each.
Read →Optimizations
Empty-delta skipping, delta consolidation, insert-only fast paths, FK-aware pruning.
Read →Refresh and pipelines
Refresh strategies, scheduled refresh, cascades and pipeline refresh.
Read →Limitations
What falls back to full refresh today, and why.
Read →The paper
OpenIVM: a SQL-to-SQL Compiler for Incremental Computations. SIGMOD Companion 2024.
Read →