dbt-plan

Static analysis tool that warns about risky DDL changes before dbt run. Like terraform plan for dbt.

pip install dbt-plan

See a result in 2 minutes

1Install
One package, one dependency (sqlglot). Nothing else to configure.
python -m pip install dbt-plan
2Try the sample
The committed before/after SQL needs no dbt installation or warehouse credentials.
git clone --depth 1 https://github.com/PresentJay/dbt-plan
cd dbt-plan
bash examples/sample-project/run-example.sh
3Use it in your project
dbt-plan run compiles twice and therefore uses the same credentials as your normal dbt compile.
dbt-plan run

Already have compiled artifacts? Run dbt-plan check directly. It reads local files and does not connect to a warehouse. Add it to CI when you're ready.

What you'll see

$ dbt-plan check dbt-plan -- 4 model(s) changed dialect: snowflake (default; adapter: unknown) baseline: unknown revision, unknown snapshot time DESTRUCTIVE int_order_enriched (incremental, sync_all_columns) ADD COLUMN billing_method ADD COLUMN shipping_city DROP COLUMN billing_info DROP COLUMN shipping_info Downstream: dim_customers, fct_daily_sales (2 model(s)) >> BROKEN_REF fct_daily_sales: reads dropped column(s): shipping_info SAFE dim_customers (table) CREATE OR REPLACE TABLE SAFE dim_publishers (table) CREATE OR REPLACE TABLE SAFE fct_daily_sales (incremental, append_new_columns) ADD COLUMN total_sales Causal explanations (before policy) - DESTRUCTIVE cascade.broken_ref: source model.sample.int_order_enriched; affected model.sample.fct_daily_sales reads dropped column(s): shipping_info Source change: added billing_method, shipping_city; removed billing_info, shipping_info Affected/evidence columns: (not recorded); evidence: legacy_cascade / unknown / provenance_unavailable; raw risk: broken_ref; waiver eligible: false Review: provenance_unavailable Source config: materialized=incremental; on_schema_change=None Affected config: materialized=incremental; on_schema_change=None Step: model.sample.int_order_enriched -> model.sample.fct_daily_sales; exact direct compiled read; root attribution not established; removed shipping_info Config: materialized=incremental; on_schema_change=None - DESTRUCTIVE ddl.drop_column: source model.sample.int_order_enriched; affected model.sample.int_order_enriched DROP COLUMN; column: billing_info Source change: added billing_method, shipping_city; removed billing_info, shipping_info Affected/evidence columns: billing_info, billing_method, customer_id, order_date, order_id, revenue, shipping_city, shipping_info; 1 columns omitted; evidence: compiled_sql / exact / column_diff_checked; raw risk: destructive; waiver eligible: true Compiled SQL (not a source/Jinja line): target/compiled/sample/models/int_order_enriched.sql Source config: materialized=incremental; on_schema_change=None Affected config: materialized=incremental; on_schema_change=None - DESTRUCTIVE ddl.add_column: source model.sample.int_order_enriched; affected model.sample.int_order_enriched ADD COLUMN; column: billing_method Source change: added billing_method, shipping_city; removed billing_info, shipping_info Affected/evidence columns: billing_info, billing_method, customer_id, order_date, order_id, revenue, shipping_city, shipping_info; 1 columns omitted; evidence: compiled_sql / exact / column_diff_checked; raw risk: destructive; waiver eligible: true Compiled SQL (not a source/Jinja line): target/compiled/sample/models/int_order_enriched.sql Source config: materialized=incremental; on_schema_change=None Affected config: materialized=incremental; on_schema_change=None - DESTRUCTIVE ddl.add_column: source model.sample.int_order_enriched; affected model.sample.int_order_enriched ADD COLUMN; column: shipping_city Source change: added billing_method, shipping_city; removed billing_info, shipping_info Affected/evidence columns: billing_info, billing_method, customer_id, order_date, order_id, revenue, shipping_city, shipping_info; 1 columns omitted; evidence: compiled_sql / exact / column_diff_checked; raw risk: destructive; waiver eligible: true Compiled SQL (not a source/Jinja line): target/compiled/sample/models/int_order_enriched.sql Source config: materialized=incremental; on_schema_change=None Affected config: materialized=incremental; on_schema_change=None - DESTRUCTIVE ddl.drop_column: source model.sample.int_order_enriched; affected model.sample.int_order_enriched DROP COLUMN; column: shipping_info Source change: added billing_method, shipping_city; removed billing_info, shipping_info Affected/evidence columns: billing_info, billing_method, customer_id, order_date, order_id, revenue, shipping_city, shipping_info; 1 columns omitted; evidence: compiled_sql / exact / column_diff_checked; raw risk: destructive; waiver eligible: true Compiled SQL (not a source/Jinja line): target/compiled/sample/models/int_order_enriched.sql Source config: materialized=incremental; on_schema_change=None Affected config: materialized=incremental; on_schema_change=None - WARNING input.refusal: source model.sample.dim_customers; affected model.sample.dim_customers Unresolved compiled SQL read from int_order_enriched while checking int_order_enriched; affected columns: billing_info, shipping_info Source change: not recorded Affected/evidence columns: (not recorded); evidence: input / unknown / read_unresolved; raw risk: warning; waiver eligible: false Review: read_unresolved Source config: materialized=table; on_schema_change=None Affected config: materialized=table; on_schema_change=None - SAFE ddl.replace_table: source model.sample.dim_customers; affected model.sample.dim_customers CREATE OR REPLACE TABLE Source change: not recorded Affected/evidence columns: country, created_at, customer_id, customer_tier; evidence: compiled_sql / exact / column_diff_checked; raw risk: safe; waiver eligible: true Compiled SQL (not a source/Jinja line): target/compiled/sample/models/dim_customers.sql Source config: materialized=table; on_schema_change=None Affected config: materialized=table; on_schema_change=None - SAFE ddl.replace_table: source model.sample.dim_publishers; affected model.sample.dim_publishers CREATE OR REPLACE TABLE Source change: not recorded Affected/evidence columns: end_date, publisher_id, publisher_name, start_date; evidence: compiled_sql / exact / column_diff_checked; raw risk: safe; waiver eligible: true Compiled SQL (not a source/Jinja line): target/compiled/sample/models/dim_publishers.sql Source config: materialized=table; on_schema_change=None Affected config: materialized=table; on_schema_change=None - SAFE ddl.add_column: source model.sample.fct_daily_sales; affected model.sample.fct_daily_sales ADD COLUMN; column: total_sales Source change: added total_sales; removed (not recorded) Affected/evidence columns: order_count, order_date, shipping_info, store_id, total_sales, unique_customers; evidence: compiled_sql / exact / column_diff_checked; raw risk: safe; waiver eligible: true Compiled SQL (not a source/Jinja line): target/compiled/sample/models/fct_daily_sales.sql Source config: materialized=incremental; on_schema_change=None Affected config: materialized=incremental; on_schema_change=None Observation: model.sample.int_order_enriched -> model.sample.dim_customers; unknown read check; not a column-flow edge; columns (not recorded); read_unresolved Explanation detail omitted: 0 associations; 0 graph edges not displayed. Full relevant graph and findings: JSON. External consumer coverage: unknown. Exposures are declared consumers only. dbt-plan: 4 checked, 3 safe, 0 warning, 1 destructive, 1 cascade risk(s)

What it checks

Column changes
Detects ADD/DROP from compiled SQL diffs. Flags sync_all_columns drops as DESTRUCTIVE.
Cascade analysis
Finds downstream models broken by a dropped column: the ones that name it, the ones that select * and lose it without their own file changing, and the unit tests whose fixtures pin it down.
Config changes
Detects materialization or on_schema_change policy changes between snapshots.
CI-ready
Exit codes, JSON output, GitHub markdown. Execution-error migration guide. One-line summary for grep.
Multi-dialect
Snowflake, BigQuery, Postgres, Redshift, DuckDB, and more via --dialect.
Zero runtime deps
Only sqlglot. It analyzes compiled SQL on disk, so dbt compile is the step that talks to your warehouse.
Safe by design
Parse failures, SELECT *, and ambiguous columns always warn. False safe is never OK.
200+ tests
92% coverage. Every materialization, every edge case, every output format tested.

CI integration

1Generate workflow
One command creates .github/workflows/dbt-plan.yml with snapshot, check, and merge gate.
dbt-plan ci-setup
2Push and done
Every PR that touches models/ will be checked. Destructive changes block merge (exit 1).
git add .github/workflows/dbt-plan.yml
git push

All commands

CommandWhat it does
dbt-plan runOne command: compile baseline, compile current, check. Handles git stash automatically.
dbt-plan checkAnalyze compiled SQL changes and warn about risks. Supports --format text/github/json, --select.
dbt-plan snapshotSave current compiled SQL + manifest as baseline for later comparison.
dbt-plan ci-setupGenerate GitHub Actions workflow that runs dbt-plan on every PR.
dbt-plan initGenerate .dbt-plan.yml config + add .dbt-plan/ to .gitignore.
dbt-plan statsAnalyze project: materialization distribution, how many models' columns it can read, and which materializations it has no rule for.

Configuration

Create .dbt-plan.yml in your project root, or use environment variables.
# .dbt-plan.yml

# Skip known-safe models
ignore_models: [scratch_model, staging_temp]

# SQL dialect (default: snowflake)
dialect: bigquery

# Custom compile command (for uv, poetry, etc.)
compile_command: uv run dbt compile

# Exit code for warnings (default: 2, set 0 to pass CI)
warning_exit_code: 0
All settings also work as environment variables (for CI):
DBT_PLAN_DIALECT=bigquery
DBT_PLAN_COMPILE_COMMAND="uv run dbt compile"
DBT_PLAN_IGNORE_MODELS="scratch_model,staging_temp"
DBT_PLAN_FORMAT=json
DBT_PLAN_WARNING_EXIT_CODE=0
Priority: CLI flags > environment variables > .dbt-plan.yml > defaults

Design philosophy

PrincipleMeaning
False safe = neverIf we can't determine safety, we warn. Never silently pass.
False warning = OKOver-warning is fine. Use ignore_models to suppress.
No runtime depsOnly sqlglot, and dbt-plan reads files rather than querying a warehouse.
CI-firstExit codes, structured output, one-line summary.