Every output on this page was produced by running the tool, not written by hand.
dbt-plan does not tell you your numbers changed. It cannot: it never queries anything. If you need to know that yesterday’s revenue moved, you want a data diff, and the tools below do that properly.
| Tool | Answers | Needs a warehouse |
|---|---|---|
| Recce | did the values change — row, profile, top-k diffs | yes |
| SQLMesh plan | column-level impact, blue-green deploys | yes |
| dbt Fusion / VS Code compare | how your edit affects data in your account | yes |
| data-diff, dbt-audit-helper | row-level comparison between two relations | yes |
| dbt-plan | will this DDL drop a column, and what breaks downstream | no |
The question dbt-plan answers is narrower. In exchange it costs nothing, takes under a second, and runs in places the others cannot run at all. Use it as the cheap gate in front of the expensive one: dbt-plan on every pull request, a data diff when it flags something worth looking at.
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)
Exit code 1. The interesting line is the last one under int_order_enriched:
fct_daily_sales still references a column that is about to disappear. Nothing
about that is visible in the diff of either model on its own.
Reproduce it with bash examples/sample-project/run-example.sh.
This is the one that has no cheap answer anywhere else, and the reason to run dbt-plan
before dbt run rather than only in CI.
A macro is edited. No model file changes:
{% macro audit_cols() %}
- current_timestamp() AS dbt_loaded_at,
- 'v1' AS pipeline_version
+ current_timestamp() AS dbt_loaded_at
{% endmacro %}
The pull request diff is two lines in one file. Nothing tells you which models call it,
and nothing tells you that one of them is incremental with on_schema_change:
sync_all_columns, where a vanished column means dbt issues DROP COLUMN against a table
that has data in it.
Compile and ask:
$ dbt compile && dbt-plan check
dbt-plan -- 1 model(s) changed
DESTRUCTIVE fct_orders (incremental, sync_all_columns)
DROP COLUMN pipeline_version
dbt-plan: 1 checked, 0 safe, 0 warning, 1 destructive
Not a single model file was touched, and the column is named.
This matters more when a coding agent is making the edit. A person changing a shared macro usually pauses to wonder where it is used. An agent changes it and moves on, so the check has to be in the loop rather than in someone’s head.
A contributor forks your dbt project and opens a pull request. Warehouse credentials are not available to that workflow, and should not be — that is the whole point of the restriction.
Every warehouse-connected tool is unavailable here. Whether dbt-plan is depends on which engine compiles the project, and the difference is not cosmetic.
On dbt Core this does not work. dbt-plan itself never connects, but producing
its input does: dbt compile connects, and a fork pull request has no secrets to
connect with. dbt-plan ci-setup generates a workflow that says so plainly rather
than failing later with a driver error.
On the Fusion engine it does. Fusion compiles without a warehouse connection. Verified against a Snowflake profile pointing at an account that does not exist:
dbt-fusion 2.0.0-preview.218
Finished 'compile' successfully for target 'dev' [3.1s]
Processed: 3 models | 3 total | 3 success
Three models compiled, manifest.json written, nothing dialled out. So this runs
with no credentials at all:
on: pull_request # not pull_request_target — no secrets, deliberately
jobs:
plan:
runs-on: ubuntu-latest
permissions:
contents: read # no secrets needed, so none are granted
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- run: pip install dbt-plan
- run: |
git checkout $
dbt compile && dbt-plan snapshot
git checkout $
dbt compile
dbt-plan check --format github >> $GITHUB_STEP_SUMMARY
- run: dbt-plan check # exit 1 blocks the merge
A macro that queries the warehouse — run_query, get_column_values,
adapter.get_columns_in_relation — needs a connection even under Fusion, and the
models using it fail to compile:
[error] [DbConnectionFailed (dbt1300)]: [Snowflake] 261004 (08004): failed to auth
--> models/marts/introspective.sql:2:22
Summary: 4 total | 3 success | 1 error
Only those models fail; the rest still compile. dbt-plan does not quietly analyse what is left — it names what is missing:
DESTRUCTIVE fct_orders (incremental, sync_all_columns)
DROP COLUMN store_id
WARNING: The compile is incomplete -- 1 model(s) in the manifest have no compiled
SQL: introspective
So on a fork pull request you get a real verdict on everything that compiled, and
an explicit statement about everything that did not. That is the same boundary
ci-setup already names when it explains least privilege: dbt compile reads no
tables unless your macros introspect.
Required checks have to be fast and they have to be reliable, or people start asking for merge overrides.
The CLI benchmark measures 50, 200, and 1,000 models with unchanged, one-changed, and all-changed workloads. It records every subprocess sample and checks the findings, so a fast but empty analysis fails the benchmark. Compilation is excluded and must be measured separately in your own project.
The analysis reads local artifacts and makes no warehouse queries. Compilation may still connect through your adapter or macros.
dbt-plan run --against main # compare with where this branch left main
dbt-plan run # compare with your last commit
The default baseline is your last commit, so anything you have already committed on the branch is not in the comparison. That is the right answer while you are editing and the wrong one before you push, which is why the command says which baseline it used:
[dbt-plan run] Baseline: your last commit (a1b2c3d). Anything already committed on
this branch is not compared -- pass --against main for that.
--against compares with the branch point, not the tip of main, so nothing
other people merged while your branch was open is reported as yours.
Either way it stashes uncommitted work to build the baseline and restores it
afterwards, and --against puts HEAD back before the stash is popped. If anything
fails in between, both restores still run and tell you where your changes are.
Reviewers frequently lack access to the warehouse the author used. The GitHub output is designed to make the risk legible to someone in that position:
🔴 **DESTRUCTIVE** `int_order_enriched` (incremental, sync_all_columns)
- `DROP COLUMN` shipping_info
- Downstream: dim_customers, fct_daily_sales (2 model(s))
- 🔴 **BROKEN_REF** `fct_daily_sales`: reads dropped column(s): shipping_info
Everything else on this page uses a project written to show the tool working. This section is jaffle_shop at HEAD, five models, unmodified except for the change named in each row. dbt 1.11.7, dbt-plan 0.13.0.
| change | output | exit | what it said |
|---|---|---|---|
rename first_name in stg_customers |
6 lines | 1 | DESTRUCTIVE + BROKEN_REF customers |
drop status from stg_orders |
7 lines | 1 | DESTRUCTIVE + BROKEN_REF orders + DATA_TEST_FAILURE |
| a macro drops a column, no model file touched | 7 lines | 1 | DESTRUCTIVE + BROKEN_REF orders + DATA_TEST_FAILURE |
add a where clause, no column change |
5 lines | 0 | SAFE |
| reformat a model | 5 lines | 0 | SAFE |
| touch a mart, no column change | 4 lines | 0 | SAFE |
| nothing changed | 1 line | 0 | no model changes detected |
No false positives and no misses. Both destructive predictions were checked against
dbt build, which failed exactly where they said it would:
Binder Error: Values list "customers" does not have a column named "first_name"
ERROR accepted_values_stg_orders_status__placed__shipped__completed__return_pending__returned
Text and GitHub output shorten that generated name to
accepted_values_stg_orders_status__...ng__returned, keeping both ends; JSON
keeps the exact name for automation. If distinct names would shorten to the same
label, the human-readable formats keep them in full.
dbt-plan check took 0.12-0.17s. dbt compile on the same project took 2.46s,
so the check is about 5% of a compile you were already paying for.
Two things this does not tell you. jaffle_shop has no incremental models, so every
model-level verdict was SAFE and every finding came from cascade – the DDL rules
table got no exercise here at all. And with five models, nothing came close to the
point where a long cascade list becomes hard to read.
Worth knowing before you put it in front of your team.
It can still over-report downstream breakage, but only when it has to. Cascade detection resolves the reference: it parses the downstream model against a schema built from the project’s own compiled SQL, and reports a break only when the model names the dropped column and that name resolves to the changed relation. A mention in a comment, in a string literal, or on another table’s column of the same name is no longer a finding.
When that cannot be resolved — the SQL will not parse, or a column cannot be attributed to a relation — it falls back to the old text search, which is wider. The direction of the error is deliberate: a refusal has to widen what gets reported, never narrow it, so an unparseable downstream model is still over-reported rather than passed over.
It reports a warning when it cannot be sure. SELECT * without manifest
column definitions, a duplicate column name from a join, an unrecognised
on_schema_change — all produce REVIEW REQUIRED rather than a verdict. That is
the tool working as intended; see design notes.
It says nothing about data. A change can be perfectly safe by DDL and still be catastrophically wrong. dbt-plan will pass it.
It reads target/, so a failed compile is a stale answer. If dbt compile
fails, the compiled SQL is whatever was there before and the diff comes out empty.
dbt-plan now says so rather than reporting a clean run:
WARNING: target/ may be out of date -- models/staging/stg_orders.sql is not verified as current compilation input. Recompile, or this report describes code you no longer have.
When the source checkout is available, dbt-plan also compares model and macro
content with the manifest and uses available compilation evidence to identify
stale or incomplete artifacts. Timestamp checks remain a fallback when stronger
evidence is unavailable. An artifact-only checkout cannot validate source edits
or deletions it was not given. Chain the commands (dbt compile && dbt-plan check)
or use dbt-plan run, which compiles and stops on a failure. See
analysis limits for the input boundary.
For a genuine false positive you have accepted, --acknowledge keeps it in the
report while letting the build through; ignore_models hides it entirely.
Details in the configuration reference.