dbt-plan
Predicts the DDL a dbt change will execute, before you run it. Reads files, not the warehouse.
Open source Open in the app JSON README (API)
About
Predicts the DDL a dbt change will execute, before you run it. Reads files, not the warehouse.
Details
- Kind
- MCP servers
- Topic
- No topic detected
- Publisher
- presentjay
- Origin
- official
- Category
- ferramentas
- Transport
- local
- Version
- 0.15.0
- Stars
- 1
- Forks
- 6
- Open pull requests
- 2
- Last push
- 2026-09-05T06:21:19Z
- Repository state
- ativo
- Language
- Python
- License
- Apache-2.0
- Added
- 2026-09-05 06:00:29
- Updated
- 2026-09-07 16:03:58
- Origin id
io.github.PresentJay/dbt-plan
README
# 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 that reference dropped columns
- **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
```bash
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:
```bash
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:
```bash
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:
```bash
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
```bash
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 model1 # Check specific model only
```
## 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 ref / build failure analysis | `seed` / `source` change detection |
| Config change detection (materialization, osc) | `pre_hook` / `post_hook` DDL analysis |
| 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](docs/use-cases.md) 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 | (none set) | `UNKNOWN materialization` | WARNING |
| materialized_view / custom | (osc set) | follows the incremental rules | per osc |
## CI Integration (GitHub Actions)
```yaml
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` | `snowflake` | sqlglot dialect for parsing compiled SQL. |
| `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](docs/ci-integration.md).
## How It Works
```mermaid
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](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](https://github.com/PresentJay/dbt-plan/issues),
particularly those labelled [good first issue](https://github.com/PresentJay/dbt-plan/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
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](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. (`--dialect`)
- Python 3.10+
- CTE, UNION ALL, QUALIFY, window functions, VARIANT access
## License
Apache-2.0