Skip to main content

Git Branches for Your Database: What-If Analysis in DuckDB Without Copying Tables

· 9 min read
Joachim Rosskopf
AnoFox Development Team

Every analyst has written this line. Nobody is proud of it.

CREATE TABLE sales_promo_v2_FINAL AS SELECT * FROM sales;

There is a better move. Branch the table instead:

CALL scenario_create('promo_q2');
ATTACH 'promo_q2' AS promo (TYPE scenario);

UPDATE promo.sales SET quantity = quantity * 1.25 WHERE product_id = 'product_1';

sales is untouched. promo.sales shows the what-if. Nothing was copied.

The Problem: What-If Analysis Has No Good Home

The request is always some flavour of the same thing:

"What does Q2 look like if we run the promo on product_1? Keep the current numbers too — the board wants both."

You need two versions of one table at the same time. SQL gives you three ways to do that, and all three are bad.

Copy the table. CREATE TABLE ... AS SELECT. Works instantly, and it's why every warehouse has a sales_v2, a sales_promo, and a sales_promo_final_v3 that nobody can delete because nobody remembers which report reads it. Storage doubles per scenario. When the base data refreshes, every copy silently goes stale.

Add a scenario column. Now sales has a scenario_id, and every query in your codebase needs a WHERE scenario_id = ... that someone will forget. Your primary key changes. Your dashboards change. A modelling question became a schema migration.

Do it in the application. Pull into pandas, mutate, push back. Now the truth lives in a notebook, and the answer isn't reproducible next quarter.

None of these are what you actually want. What you want is a branch: the original stays put, you make changes in an isolated world, you compare the two, and you either keep the changes or throw them away.

Version control solved this for code in 2005. It's oddly absent from analytics.

Branching, but for Tables

anofox_scenario is a DuckDB extension that adds exactly that. A scenario is a named branch over your existing tables:

INSTALL anofox_scenario FROM community;
LOAD anofox_scenario;

The mechanism is copy-on-write. Creating a scenario copies no rows at all — it writes a little metadata. When you modify a row inside the scenario, only that row is stored, in a per-scenario delta table. When you read, the extension merges the delta over the base on the fly.

So a scenario over a 100-million-row table where you changed 500 rows costs you 500 rows. Not 100 million.

The important consequence isn't storage though. It's that the base table is never written to. Not "by convention" — structurally. There is no code path from a scenario write to a base table.

Let's Actually Do It

We'll use the sales table from the anofox_forecast Getting Started guide — three products, 100 days of daily quantities. The setseed() call makes the random values reproducible, so the numbers below are the numbers you'll get.

SELECT setseed(0.42);

CREATE TABLE sales AS
SELECT
'product_' || (i % 3) AS product_id,
'2024-01-01'::DATE + (i * INTERVAL '1 day') AS date,
100.0 + (i % 7) * 10 + random() * 5 AS quantity
FROM generate_series(0, 99) AS t(i);

Here's what we start with:

SELECT product_id, count(*) AS days, round(avg(quantity),1) AS avg_qty
FROM sales GROUP BY 1 ORDER BY 1;
product_iddaysavg_qty
product_034132.7
product_133132.3
product_233131.5

Step 1: Create the branch

CALL scenario_create('promo_q2', 'product_1 promo from March',
key_columns := MAP {'sales': ['product_id','date']});

That key_columns argument deserves a word. To track a row change, the extension needs to know what identifies a row. Most tables tell it via a PRIMARY KEY. Ours was built with CREATE TABLE AS, so it has none — we declare the identity instead. Skip this on a keyless table and UPDATE still works, but you lose row-level diffing, because there's no way to say which row changed.

Step 2: Attach it

ATTACH 'promo_q2' AS promo (TYPE scenario);

This is the whole user interface. The scenario is now a catalog, like any attached database. Every base table appears inside it.

Step 3: Edit it with ordinary SQL

UPDATE promo.sales SET quantity = quantity * 1.25
WHERE product_id = 'product_1' AND date >= DATE '2024-03-01';

No special function. No _op column. No delta table to write by hand. INSERT, UPDATE, DELETE, TRUNCATE, MERGE INTO, ON CONFLICT, RETURNING — all of it works, because the scenario is a real catalog and DuckDB's planner treats it as one.

Step 4: Both worlds at once

SELECT 'base' AS world, round(avg(quantity),1) AS avg_qty
FROM sales WHERE product_id = 'product_1'
UNION ALL
SELECT 'promo', round(avg(quantity),1)
FROM promo.sales WHERE product_id = 'product_1';
worldavg_qty
base132.3
promo145.4

Both numbers, one query, one database file. That join across worlds is the thing copies can never give you cleanly.

Here is the whole sequence as a real session — real extension, real output, nothing staged:

Branching a DuckDB table and editing the branch with ordinary SQL

What Changed? Ask the Database

With copies, "what's different between these two tables?" is a FULL OUTER JOIN you write by hand, get subtly wrong, and rewrite for the next table. Here it's a function, because the extension already stored precisely the changed rows:

SELECT * FROM scenario_diff_summary('promo_q2');
table_namerows_addedrows_modifiedrows_removed
sales0130

Thirteen rows changed. Want them individually?

SELECT * FROM scenario_diff('promo_q2', 'sales') ORDER BY date DESC LIMIT 3;
product_iddatechange_typecolumn_nameold_valuenew_value
product_12024-04-07 00:00:00modifiedquantity163.926…204.908…
product_12024-04-04 00:00:00modifiedquantity133.511…166.889…
product_12024-04-01 00:00:00modifiedquantity101.066…126.332…

Column-level, with old and new values. And it streams through the engine, so you can filter and aggregate it like any table:

SELECT count(*) FROM scenario_diff('promo_q2','sales') WHERE change_type = 'modified';

You can also diff two scenarios against each other — scenario_diff('promo_q2', 'promo_aggressive', 'sales') — which is how you answer "how do our two proposals differ?" without materialising either.

Branches of Branches

A scenario can branch off another scenario, inheriting its changes:

CALL scenario_create('promo_aggressive', from_scenario := 'promo_q2');
SELECT name, mode, frozen, parent FROM scenario_list();
namemodefrozenparent
promo_q2deltafalseNULL
promo_aggressivedeltafalsepromo_q2

Baseline → moderate → aggressive, each one only storing its own increment. This is where the git analogy stops being a metaphor and starts being the actual data model.

Freezing: When a Number Must Stop Moving

Planning numbers get approved. Approved numbers must not drift.

CALL scenario_freeze('promo_q2');
UPDATE promo.sales SET quantity = 1;
Invalid Input Error: Cannot UPDATE: scenario 'promo_q2' is frozen.
Unfreeze it with CALL scenario_unfreeze('promo_q2')

Reads keep working; writes are refused. Combine mode := 'materialized' with a freeze and you get a genuine immutable snapshot — a full copy, pinned, unaffected by anything that happens to the base afterwards.

That last part matters more than it sounds, so let's be precise about it.

The One Decision You Have to Make

Scenarios come in three isolation tiers, and picking wrong is the one way to get a surprising answer:

ModeCreated withLater changes to the base…Cost
Delta (default)scenario_create('s')…show through, except on rows the scenario already changedMetadata only
Materializedmode := 'materialized'…are invisible. Fully isolatedCopies every base table
DuckLake snapshotbase := '<attached lake>'…are invisible. Reads pinned to creation timeNo copy

The default is a live overlay. That's usually what you want — a short-lived what-if against a base nobody is editing right now, and if a colleague fixes a data error you'd rather see the fix. But if the scenario is an approved plan you'll be audited on in six months, use materialized. Reproducibility beats freshness there.

Keeping the Changes

If the what-if wins, promote it. Look before you leap:

SELECT * FROM scenario_merge_preview('promo_q2');
table_namekeyactionconflict
salesproduct_1|2024-03-02 00:00:00updatefalse
salesproduct_1|2024-03-05 00:00:00updatefalse

Thirteen planned actions, no conflicts. Apply them:

SELECT * FROM scenario_merge('promo_q2');
tables_changedrows_appliedconflicts_resolved
1130
SELECT round(avg(quantity),1) AS base_after FROM sales WHERE product_id = 'product_1';
base_after
145.4

The base moved from 132.3 to 145.4, and the scenario ended up frozen with an empty delta — its changes now live in the base, so it becomes a record of what was applied rather than a thing you can keep editing.

If someone changed the base underneath you, on_conflict decides: abort (default) refuses, ours lets the scenario win, theirs keeps the base.

And if the what-if loses? CALL scenario_drop('promo_q2'); The branch disappears. The base never knew.

What It Won't Do

Worth knowing before you build on it:

  • No DDL inside a scenario. You can't ALTER TABLE in a branch. Schema changes go to the base, then scenario_refresh() picks up new tables. This is a deliberate design limit, not a gap.
  • Single writer. Host writes and scenario writes can't share one explicit transaction. It inherits DuckDB's concurrency model.
  • Keyless tables are second-class. Without a PRIMARY KEY or declared key_columns, you get UPDATE/DELETE with bag semantics but no scenario_diff.
  • Delta mode is an overlay, not a snapshot. See the table above. This is the one that surprises people.

The Part That Actually Changes Your Workflow

Everything so far treats a scenario as a place to put numbers. The interesting bit is that a scenario is a normal catalog — which means anything that takes a table name can point at one.

Including a forecaster:

SELECT * FROM ts_forecast_by('promo.sales', product_id, date, quantity, 'AutoETS', 7, '1d');

That's anofox_forecast generating a forecast of a world that doesn't exist, from data that was never copied. Same query as forecasting the real table — one word different.

Next post: Forecast the What-If, Not the Copy →


anofox_scenario is available in the DuckDB Community Extensions repository: INSTALL anofox_scenario FROM community;. Source, docs, and the full API reference are on GitHub.

🍪 Cookie Settings