Skip to main content

dbt-plan

Static analysis tool that warns about risky DDL changes before dbt run.

Like terraform plan for dbt, and used the same way: you run it before the thing that changes your warehouse, not only in CI afterwards.

Runs on compiled SQL. It reads files and nothing else, so it works with any warehouse — Snowflake, BigQuery, Redshift, Postgres, DuckDB — through one code path.

What It Looks Like

$ dbt-plan check

dbt-plan -- 2 model(s) changed

DESTRUCTIVE  int_order_enriched (incremental, sync_all_columns)
  DROP COLUMN  shipping_info
  DROP COLUMN  billing_info
  ADD COLUMN   shipping_city
  Downstream: dim_customers, fct_orders (2 model(s))
  >> BROKEN_REF  fct_orders: references dropped column(s): shipping_info

SAFE  dim_customers (table)
  CREATE OR REPLACE TABLE

dbt-plan: 2 checked, 1 safe, 0 warning, 1 destructive, 1 cascade risk(s)

What It Does

dbt-plan analyzes compiled SQL diffs to catch dangerous schema changes at PR time:

  • Column changes: detects ADD/DROP COLUMN from SQL diff
  • Risk assessment: judges safety based on materialization x on_schema_change rules
  • 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. Names the exposures whose owners need telling
  • Contracts: reports a change an enforced contract will reject, in either direction
  • Config changes: detects materialization or on_schema_change policy changes
  • Type changes: compares explicit CAST types between revisions
  • SELECT * resolution: reads the columns from the CTEs of the same statement, and follows a ref() into the referenced model's compiled SQL

It does NOT execute anything, connect to any warehouse, or simulate dbt run. It reads files, compares them, and warns you.

Quick Start

pip install dbt-plan
dbt-plan run               # compile baseline → compile current → check

dbt-plan run does the whole thing in one command, and needs whatever credentials your dbt compile normally needs.

The loop it is built for

Once you have a baseline, the inner loop is a single sub-second command. Edit a model or a macro, recompile, and see what dbt run would do — before running it:

dbt-plan snapshot          # once, on the revision you are changing from
                           # ... edit models, edit macros ...
dbt compile && dbt-plan check

Measured on a project of 3 models, median of 3 runs:

step time
dbt compile (Fusion) 1.8 – 3.8 s
dbt-plan check 0.11 s
dbt-plan snapshot 0.10 s

200 models, every one of them changed: 0.48 s. The compile is the cost, and you were compiling anyway — dbt-plan itself is fast enough to sit in the edit loop rather than at the end of it.

Working with a coding agent

An agent editing models cannot eyeball a diff and hesitate. Give it the check and the reasons behind it:

dbt-plan agent-setup       # writes dbt-plan guidance into your AGENTS.md
dbt-plan check --format json

The guidance leads with what an agent most often gets wrong: adding a model to ignore_models, or downgrading on_schema_change from sync_all_columns to ignore, silences a real finding without making the change safe.

Or give it the check as an MCP tool:

pip install 'dbt-plan[mcp]'
dbt-plan-mcp                # stdio MCP server exposing `plan` and `snapshot`

plan returns the verdict, the per-model operations, and — separately — a refusals list naming everything dbt-plan declined to judge. That separation is the point: a person reading "safe" may still glance at the diff, an agent reading it proceeds, so a non-empty refusals must never be collapsed into the verdict.

The server is a separate package from the analysis core. The core is offline and synchronous by design and tests/test_invariants.py fails the build on an asyncio or network import anywhere inside it; an MCP server is both, so keeping them apart is what keeps that guarantee provable.

Registry entry — the line below is how the MCP registry verifies that whoever publishes the entry also owns this PyPI package, so it has to stay in the README that ships:

mcp-name: io.github.PresentJay/dbt-plan

More commands

dbt-plan init              # Generate .dbt-plan.yml config + update .gitignore
dbt-plan stats             # Analyze project readiness
dbt-plan ci-setup          # Generate GitHub Actions workflow
dbt-plan check --format github   # GitHub markdown output
dbt-plan check --format json     # JSON for CI pipelines
dbt-plan check --select fct_orders    # one model
dbt-plan check --select fct_orders+   # it and everything downstream

Scope

dbt-plan is a static analysis warning tool, not a runtime simulator.

In scope Out of scope
Column ADD/DROP detection from compiled SQL dbt run simulation
materialization × on_schema_change risk rules Warehouse connection
Cascade: broken refs, build failures, inherited column loss seed / source change detection
Config change detection (materialization, osc) pre_hook / post_hook DDL analysis
Unit test fixtures and exposure owners downstream seed / source fixtures dbt-plan cannot read
Enforced-contract violations, by column name Contract data_type comparison
Explicit CAST type changes Type changes on uncast columns
SELECT * resolved through CTEs and ref() SELECT * over a source or a raw table
CI exit codes + structured output full_refresh mode judgment

Design principle: false warnings are OK, false safe is never OK.

When to use it

dbt-plan answers a narrower question than the warehouse-connected tools (Recce, SQLMesh, data-diff) and costs nothing to run, so it works as the cheap gate in front of them — and on the Fusion engine, which compiles without a warehouse connection, that includes fork pull requests where they cannot run at all. See use cases for the comparison, real timings, and what it gets wrong.

Deliberately Not Planned

Ideas that look useful but contradict what this tool is:

Idea Why not
INFORMATION_SCHEMA query Requires a warehouse connection. dbt-plan reads files and nothing else, which is what lets it run wherever its input exists — including a fork's pull request, once the project compiles on Fusion.
Type changes on columns with no explicit CAST The type is whatever the warehouse assigned, so seeing a change would mean asking it. Columns that are cast explicitly on both sides are compared — see below.

DDL Prediction Rules

Materialization on_schema_change Predicted DDL Safety
table any CREATE OR REPLACE TABLE SAFE
view any CREATE OR REPLACE VIEW SAFE
ephemeral any (no physical object) SAFE
snapshot any REVIEW REQUIRED WARNING
incremental ignore no DDL SAFE
incremental fail build failure WARNING
incremental append_new_columns ADD COLUMN only SAFE
incremental sync_all_columns ADD + DROP COLUMN DESTRUCTIVE if columns removed
any (model removed) MODEL REMOVED DESTRUCTIVE
any (unknown osc) UNKNOWN on_schema_change WARNING
materialized_view / custom (not set by you) UNKNOWN materialization WARNING
materialized_view / custom (you set one) follows the incremental rules per osc
any (contract: {enforced: true}) CONTRACT VIOLATION WARNING

"Not set by you" means the author wrote no on_schema_change, in the model or in dbt_project.yml. dbt resolves one for every model regardless, so the resolved value asserts nothing; an explicit setting is a claim about how that materialization behaves and is honoured. dbt-plan reads unrendered_config to tell them apart.

An enforced contract inverts the rules above: dbt requires every column to be declared, so a column added to the SQL fails the build just as a removed one does. Names only — dbt compares its declared data_type against the warehouse, which dbt-plan does not read.

CI Integration (GitHub Actions)

name: dbt-plan
on:
  pull_request:
    paths: ['models/**', 'macros/**', 'dbt_project.yml']

jobs:
  plan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    env:
      # Whatever your profiles.yml reads. `dbt compile` connects; dbt-plan does not.
      SNOWFLAKE_ACCOUNT: ${{ secrets.SNOWFLAKE_ACCOUNT }}
      SNOWFLAKE_USER: ${{ secrets.SNOWFLAKE_USER }}
      SNOWFLAKE_PRIVATE_KEY: ${{ secrets.SNOWFLAKE_PRIVATE_KEY }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0          # the base revision has to be in the clone
          persist-credentials: false
      - uses: actions/setup-python@v5
        with: { python-version: '3.12' }
      - run: pip install uv && uv sync

      - uses: PresentJay/dbt-plan@v1

Keep the pull_request trigger. Never switch it to pull_request_target — dbt compile runs Jinja and macros written in the pull request, so that would hand your warehouse credentials to code from any fork.

Input Default
compile-command dbt compile Runs twice, once per revision.
base-ref the PR base The revision to compare against.
project-dir . dbt project directory.
dialect the manifest's adapter_type, else snowflake sqlglot dialect for parsing compiled SQL. Set this only to override what your project already says.
version latest Pin a dbt-plan release.
fail-on destructive Or warning, or never.
summary true Write the report to the job step summary.

Outputs verdict (safe / destructive / warning), exit-code, and report (path to the JSON report), so a later step can comment on the PR or open a ticket.

For a workflow you own outright rather than a wrapped action, dbt-plan ci-setup generates one with the credential wiring and least-privilege notes inline. Details in docs/ci-integration.md.

How It Works

flowchart TD
    A[dbt-plan snapshot] --> B[Save compiled SQL + manifest.json]

    C[dbt-plan check] --> D[diff_compiled_dirs]
    D --> E[base compiled SQL]
    D --> F[current compiled SQL]
    E --> G[extract_columns]
    F --> H[extract_columns]
    G --> I[base columns]
    H --> J[current columns]
    I --> K[column diff]
    J --> K
    K --> L[predict_ddl + manifest config]
    L --> M{Safety?}
    M -->|SAFE| N[exit 0]
    M -->|WARNING| O[exit 2]
    M -->|DESTRUCTIVE| P[exit 1 — block merge]
    L --> Q[find_downstream]
    Q --> R[format_text / format_github]

Contributing

See CONTRIBUTING.md for development setup, TDD workflow, and coding rules.

Architecture

src/dbt_plan/
├── columns.py      # SQLGlot column extraction (multi-dialect)
├── config.py       # .dbt-plan.yml + env var configuration
├── predictor.py    # DDL risk assessment rules + cascade analysis
├── manifest.py     # manifest.json parsing + downstream BFS
├── diff.py         # compiled SQL directory comparison
├── formatter.py    # text / GitHub markdown / JSON output
└── cli.py          # CLI: snapshot, check, init, stats, run, ci-setup

How to Contribute

Where to start: the open issues, particularly those labelled good first issue. Each one says what it is, how it was found, and what has to be decided before code.

Design decisions: See docs/design-notes.md.

Supported

  • dbt-core 1.7+, and the dbt Fusion engine (verified against 2.0.0-preview.218)
  • Any warehouse: Snowflake, BigQuery, Redshift, Postgres, DuckDB, etc. — read from the manifest, no flag needed
  • Python 3.10+
  • CTE, UNION ALL, QUALIFY, window functions, VARIANT access

License

Apache-2.0

Release files for dbt-plan 0.13.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for dbt-plan 0.13.0
File Size Uploaded
dbt_plan-0.13.0.tar.gz 75.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for dbt-plan 0.13.0
File Interpreter ABI Platform
dbt_plan-0.13.0-py3-none-any.whl Python 3 none any Details

Total release size: 139.7 kB

Release files / dbt_plan-0.13.0.tar.gz

Download URL dbt_plan-0.13.0.tar.gz
Size 75.7 kB
Tags Source
SHA-256 checksum
How to use checksums
a2ccc84db0d0d6723bd487c5ee594f057a9939787acd9df873891a71542a0de2
BLAKE2b-256 checksum
How to use checksums
bb372b081c8c8e653411a332c715599b78b2e3e4098a41906ed53f789fccc28a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release files / dbt_plan-0.13.0-py3-none-any.whl

Download URL dbt_plan-0.13.0-py3-none-any.whl
Size 63.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d47bec1b7014ea83e4f338b8d05511dc1ca70ec9bfd2a6bfbf26560873586d8b
BLAKE2b-256 checksum
How to use checksums
2946e075afb5194a6caed083c1fafd6871a210080a067488017b9aef883cf548
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release history Release notifications | RSS feed

0.17.0

2 release files

0.16.1

2 release files

This release

0.13.0 This release

2 release files

0.9.2

2 release files

0.9.1

2 release files

0.9.0

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.2

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.1

2 release files

0.4.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page