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.

$ pip install sqlbuild
Read the docs View on GitHub

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

01 · Verify early

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
02 · Make lessons permanent

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.
03 · Move models safely

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

04 · Test properly

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
05 · Plan every change

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)
06 · Compare real data

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

07 · Refactor safely

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
08 · Tidy up safely

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).
DuckDB MotherDuck Snowflake BigQuery Databricks PostgreSQL SQL Server
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.