Free and open source · Apache-2.0
The refactorable warehouse
Verify early, test properly, and refactor safely.
Change your warehouse as often as your code. SQLBuild brings compile-time checks, tests and diffs to your SQL, so change is safe.
Why warehouses resist change
Valid isn't the same as correct
SQL can run cleanly and still return the wrong number.
Lessons get forgotten
Hard-won rules live in review comments, until someone misses one.
Code review can't see the data
A pull request shows what the SQL changed, not what the numbers did.
Names are tied to data
Rename an incremental model and you rebuild its history from scratch.
One refactor, start to finish
Rewrite a few models, rename an incremental rollup and move a model between folders. Each step catches something the one before it can't.
Before anything runs
Catch errors before anything runs
Every column and type is checked offline against your declared contracts. A missing column fails in seconds, pointing at the line.
sqb compile error[B002]: Unknown column 'qty' in table 'stg_orders' (context: SELECT) model: fact_orders --> models/marts/fact_orders.sql:16:5 | 16 | o.qty, | ^ = help: review the SQL expression, input types, and authoritative schema error[B217]: Incompatible comparison between timestamp and integer model: fact_orders --> models/marts/fact_orders.sql:27:7 | 27 | WHERE o.ordered_at > 5 | ^ = help: review the SQL expression, input types, and authoritative schema ✗ Project compiled 12 models, 1 seed, 2 functions, 4 errors, 0 warnings
Turn review comments into compile errors
A few lines of Python turn a review comment into a compile error, for people and agents alike. Here a mart reads a raw source directly.
# rules/layers.py @rule( code="XSQBRARCH001", message="Marts must read sources through staging", remediation="Reference a staging model with __ref() instead.", ) def marts_use_staging(*, model: Model, ctx: RuleContext) -> list[Finding]: layer = ctx.project.tree.relative_parts(path=model.path, under="models")[0] sql = ctx.sql.for_model(model).authored.source if layer != "marts" or "__source(" not in sql: return [] line = sql[: sql.index("__source(")].count("\n") + 1 return [ctx.finding(subject=model, line=line)] sqb compile error[XSQBRARCH001]: Marts must read sources through staging --> models/marts/daily_revenue.sql:24:1 | 24 | INNER JOIN __source("raw__payments") p ON o.order_id = p.order_id AND p.status = 'success' | ^ = help: Reference a staging model with __ref() instead.
Reorganise without breaking anything
Declarations live next to the models that use them, and the compiler enforces who can see them. sqb scope shows what a move would break.
models/marts/ ├── _sqlbuild/ │ ├── _enums/ # "_" = visible only to models directly in marts/ │ │ └── payment_status.sql │ └── _macros/ │ ├── currency.py # cents_to_dollars │ └── datetime.py # timestamp_trunc └── daily_revenue.sql # uses payment_status and cents_to_dollars sqb scope model:daily_revenue --as-path models/intermediate/daily_revenue.sql Move preview Resource: model:daily_revenue Destination: models/intermediate/daily_revenue.sql Lost (3) ├─ ● enum:payment_status models/marts/_sqlbuild/_enums/payment_status.sql:1:1 ├─ ● macro:cents_to_dollars models/marts/_sqlbuild/_macros/currency.py:4:1 └─ ○ macro:timestamp_trunc models/marts/_sqlbuild/_macros/datetime.py:4:1 Invalidated usages (2) - enum:payment_status - macro:cents_to_dollars
Before production changes
Test the logic, not just the columns
Tests mock the sources and check the result across every model in between. A dropped filter fails, so the table is left alone.
-- tests/unit/test_daily_revenue_chain.sql (trimmed) TEST(); WITH __source__raw__payments AS ( SELECT 10 AS id, 1 AS order_id, 2850 AS amount_cents, 'success' AS status UNION ALL SELECT 11 AS id, 2 AS order_id, 850 AS amount_cents, 'failed' AS status ), __expected__daily_revenue AS ( SELECT CAST('2026-04-01' AS DATE) AS revenue_date, 1 AS order_count, 2850 AS total_revenue_cents ), __assert__daily_revenue_is_non_negative AS ( SELECT * FROM __ref("daily_revenue") WHERE total_revenue_cents < 0 ) SELECT 1 sqb build --select daily_revenue test test_daily_revenue_chain FAIL ├── expect expected daily_revenue FAIL unexpected=1; missing=1 │ differing column total_revenue_cents: actual=3700, expected=2850 └── error [T003] test 'test_daily_revenue_chain' failed ✗ Completed with errors PASS=0 FAIL=1 SKIP=1
Know what will rebuild, and why
sqb plan shows why each model will run and how much it will rebuild. You choose per model how far a change replays: forward only, a set window like 14 days, or the full history.
sqb plan --select daily_order_rollup+ Query changed (1) └── daily_order_rollup rebuild last 14d, add column ├── policy replay_on_change=bounded-14d ├── schema diff: + avg_order_cents (added) └── query diff: @@ -2,6 +2,7 @@ SUM(waffles_ordered) AS waffles_ordered, - SUM(revenue_cents) AS revenue_cents + SUM(revenue_cents) AS revenue_cents, + SUM(revenue_cents) / NULLIF(SUM(orders_placed), 0) AS avg_order_cents FROM __ref("hourly_order_activity") Upstream changed (1) └── hourly_activity_with_daily_context rebuild last 14d └── cause daily_order_rollup (query changed)
See what the tests missed
Tests only cover the cases someone wrote down. Here a tidy-up passed its build, but a customer disappeared and two lost an order.
sqb diff prod:dev --select dim_customers --full Rows prod count dev count 5 (-20.00%) 4 ┏━┯━┯━┯━┯━┓ ╭──┬──────────┬─────────╮ ┃-│-│-│-│-┃ │ 1│ prod only│ (20.00%)│ ┠─┼─┼─┼─┼─┨╌╌╌┏━┯━┯━┯━┯━┓├──┼──────────┼─────────┤╌╮ ┃ │ │ │ │ ┃ = ┃ │ │ │ │ ┃│ 2│ equal │ (50.00%)│ │ ┠─┼─┼─┼─┼─┨╌╌╌┠─┼─┼─┼─┼─┨├──┼──────────┼─────────┤╌├╴ 4 joined ┃ │ │ │ │ ┃ ≠ ┃ │ │ │ │ ┃│ 2│ unequal │ (50.00%)│ │ ┗━┷━┷━┷━┷━┛╌╌╌┗━┷━┷━┷━┷━┛├──┼──────────┼─────────┤╌╯ │ 0│ dev only │ (0.00%)│ ╰──┴──────────┴─────────╯ Changed Columns ┌─────────────────┬──────────────┬──────────────┐ │ lifetime_orders │ mismatches=2 │ match=60.00% │ └─────────────────┴──────────────┴──────────────┘ lifetime_orders - customer_id=2 | 2 -> 1 - customer_id=3 | 2 -> 1 prod only - customer_id=5
When production changes
Rename models without rebuilding history
A renamed incremental model keeps its table instead of starting again. If the logic changed too, declare migrate_from.
sqb plan Migrations (1) └── daily_order_rollup migrate dev.daily_activity_rollup -> dev.daily_order_rollup ├── compatibility compatible ├── transfer physical copy, promote by staged rename └── discovery automatic
Archive old tables before deleting them
The janitor renames tables your project no longer builds, and only drops them once the archive expires, 14 days later by default.
sqb janitor Janitor preview prod relations to archive 1 archives to delete 0 archive retention 14 days Relations to archive prod.weekly_revenue -> prod._sqb_archive__20260925t155417z__weekly_revenue …
Real output from SQLBuild's example project, trimmed to the relevant lines. During a migration the old table is never modified, archives can be renamed back until they expire, and every move is recorded. How migrations work · How the janitor works
Built for safe change
The rest of what it takes to change a warehouse with confidence.
Audits that block bad data
Failing audits stop bad data before it reaches production, for full builds and incremental batches alike.
Data contracts
Declared columns and types, checked at compile time and again before the table replaces production.
Column lineage
See where every column comes from and which models use it, worked out at compile time.
End-to-end scenarios
Run the real model graph against test data, then replay it locally on DuckDB in CI.
Python macros
Logic lives in plain, testable Python functions, not Jinja blocks in your SQL.
Python in the same graph
Loaders, tasks, assets and checks run in the same build as your SQL.
Start simple
SQLBuild runs as a normal build tool. Its state lives in append-only tables in your warehouse, next to your data.
- No external state service, manifest files or account.
- Try it locally on DuckDB, with no warehouse credentials.
- Free and open source, with no paid tier.
- Virtual environments for isolated previews and rollback, when you need them (alpha).
pip install sqlbuild sqb playground waffle-shop cd waffle-shop sqb build ✓ Completed successfully PASS=95 FAIL=0 sqb test
Change your warehouse like you change code
The docs cover models, testing, diffs, migrations and every command.