dbt-preflight
Built and maintained by JB Analytica — data platform architecture and analytics engineering.
Warehouse-free CI for dbt pull requests. On every pull request, preflight generates synthetic source data from your schema, builds the models the change can reach on DuckDB, runs their tests, checks house conventions, and leaves one review comment. No warehouse credentials anywhere in the workflow.
This is what a reviewer sees when a pull request renames customer_id in a staging model:
🛫 dbt preflight: ❌ failed
Built 7 of 8 models (1 changed) against synthetic data · 42 tests · 0 convention issues · 11 s
Changed models
Model Build Rows Tests stg_webshop__customers✅ built 150 3 passed, 2 failed Unchanged models this change breaks:
stg_webshop__orders— ✅ built, 1 failing testdim_customers— ⏭️ skipped (an upstream model or test failed)Also rebuilt, no new issues:
stg_webshop__products,stg_webshop__order_items,fct_order_items,int_orders__items_aggregated,fct_orders.Failing tests
- ❌
uniqueonstg_webshop__customers.customer_id: Binder Error: Referenced column "customer_id" not found in FROM clause!- ❌
not_nullonstg_webshop__customers.customer_id: Binder Error: Referenced column "customer_id" not found in FROM clause!- ❌ relationships
stg_webshop__orders.customer_id→stg_webshop__customers.customer_id: Binder Error: Referenced column "customer_id" not found in FROM clause!
Each failing test folds a <details> block under it with a plain-English reading of the
error (this model has no column customer_id: renamed or dropped upstream?), dbt's own
test name, and the compiled SQL that failed.
The comment is updated in place on every push, so a pull request carries one preflight comment, not a stack of them.
Six pull-request shapes, four that must fail and two that must pass, are recorded with the comments they produced in docs/scenarios. If you are pointing a coding agent at a dbt project, docs/agents.md says what to tell it.
Why this exists
Letting a person, or a coding agent, change a dbt project without a warehouse to test against is guesswork. The usual fixes need production data in CI (a credential most teams will not hand out) or a dbt Cloud seat. Preflight needs neither: the data is synthetic and relationship-preserving, generated by model2data from your schema, and the warehouse is a DuckDB file that lives for the length of the job.
What it checks, and what it cannot
Checks
- The changed models, everything downstream of them, and every model whose tests read them, compile and run against a schema-faithful dataset.
- Their schema, relationship and accepted-values tests pass or fail, and which rows fail.
- Column renames, dropped
ref()s and broken joins are caught before merge. dbt unit tests run too, and a failing one fails the check. - What the change did to the output. The base branch is built on the same fixtures, and the changed models plus everything downstream are compared: columns added, removed or retyped; row counts; rows whose values differ; and every metric the project defines, evaluated on both sides. A refactor that moves net revenue by 4 percent shows up as a number before a reviewer has to reason about the SQL.
- The change follows the house conventions (below).
Cannot check
- That production numbers are unchanged. A metric that does not move on synthetic data can still move on production, because the fixtures do not carry production's distribution. The diff proves the logic changed, not the size of the effect on real data.
- Warehouse-specific SQL that survives transpiling. Model SQL is rewritten from the project's dialect to DuckDB with sqlglot (see below); a model that still fails because DuckDB lacks something is reported as not verified, not as broken.
- Incremental behaviour across runs. Every preflight run is a full build on a fresh file.
Setup
Add a workflow to the repository that holds the dbt project:
# .github/workflows/preflight.yml
name: dbt preflight
on:
pull_request:
permissions:
contents: read
pull-requests: write
jobs:
preflight:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: JB-Analytica/dbt-preflight@v0
@v0 follows the latest 0.x release; pin a release tag such as @v0.2.1 for an exact version. Pull requests
from forks run with a read-only token, so for those the report lands in the job summary
only, with a note saying so.
If the dbt project is not at the repository root, or its sources.yml reads environment
variables, add a .dbt-preflight.yml next to it:
project_dir: dbt # folder holding dbt_project.yml (default: .)
schema: source_system/webshop.dbml # DBML describing the sources (default: derive from sources.yml)
rows: 200 # rows per source table
rows_for: # per-table overrides, for realistic fact-to-dimension ratios
orders: 800
order_items: 2000
seed: 42 # same seed, same data, on every run
locale: nl_BE # Faker locale for names and addresses
env: # variables your profiles.yml / sources.yml expect
GCP_PROJECT: preflight
dialect: bigquery # SQL dialect to transpile from (default: read from profiles.yml)
metrics: # extra metrics to compare, for projects with none defined elsewhere
- name: gross_revenue
model: fct_orders
sql: sum(gross_amount_eur)
That is the whole setup. Preflight writes its own profiles.yml, so the project's real
profile and its credentials are never read.
Where the schema comes from
Preflight needs to know what the source tables look like. Three options:
-
A DBML file (
schema:). If the repository already describes its source system in DBML, point at it. Note hints in the DBML (weighted statuses, skewed foreign keys, null rates) carry through to the fixtures, so the data behaves like a business. A change to the DBML file counts as a change to every source, so everything is rebuilt. -
Derived from
sources.yml. With noschema:set, preflight reads the project's sources.uniqueandnot_nulltests become keys, andrelationshipstests between sources become foreign keys. -
Inferred from the staging models that read a source, for any table
sources.ymlleaves without columns, or without adata_typeon them. Real projects (jaffle-shop, for one) often declare a source with no columns at all; the staging model that doesselect id as customer_id, ... from {{ source(...) }}already names every column it needs, so preflight reads that instead of asking for YAML nobody wrote. An explicitcast(x as date)orx::datesets the type first; failing that, how the staging SQL uses the column is read next - an operand of/,*,+,-against a numeric literal, or wrapped insum(/avg(/round(, is numeric, while a comparison to a string literal or alower(/upper(/trim(/concat(argument is varchar. Only then does the column's own name decide:_at/_timestamp/_datetimea timestamp,_date/_ona date,id/_idan integer,is_/has_/_flag/enabled/activea boolean, a name built frompaid,cost,tax,fee,discount,revenue,amount,price,total,rateand the like a decimal, one built fromcount,number,qty,quantity,units,age,year,month,dayan integer, and a handful of common attribute names (email,phone,name,status,type,sku, ...) always varchar. A name shaped like a foreign key -customer_id, or a barecustomerwhen araw_customerssource exists - is typed as an integer and gets aref:to that table'sidwhen one can be found, the same referential integrity an explicitrelationshipstest would have set up. The comment says which columns were guessed, so a reviewer can tighten them insources.ymlif a guess is wrong. Only a column no model reads either is reported rather than guessed, because a fixture with the wrong type is worse than no fixture.A source column named
idis always the primary key. A staging model's ownunique/not_nulltests on the alias it gave a source column (id as customer_id, tested ascustomer_id) carry back to that source column too -pkwhen both are declared,unique/not nullalone otherwise - so a project that tests its staging models instead of its sources still gets keys in the derived schema. Acast()around the column is seen through, because a staging layer over a schemaless loader is where types get pinned;lower(email)is not, because it changes the value rather than the type.An
accepted_valuestest carries back the same way, and the column is generated as an enum of exactly those values, so a column the project treats as a vocabulary is not filled with placeholder text its own test then rejects. Only a string column becomes an enum, since an enum's values are strings.
Sources declared with loader: dlt get _dlt_load_id and _dlt_id added to their
fixtures. Other loaders can be declared under loader_columns: in the config.
A project with no sources at all, one whose input is its seeds, needs neither: dbt loads the seeds during the build and preflight generates nothing.
Your warehouse's SQL, on DuckDB
A project written for BigQuery says timestamp_diff(a, b, hour) and initcap(x); DuckDB
has neither. Preflight transpiles each compiled model from the project's dialect to DuckDB
with sqlglot before it runs, after dbt has resolved
every ref() and source(). The dialect is read from a profiles.yml checked into the
project directory (the adapter type of its default target), or set explicitly:
dialect: bigquery # or snowflake, redshift, databricks, trino, ... ; duckdb/none to disable
A model sqlglot cannot parse runs as written and the comment says so. dbt's own SQL, and generic tests rendered from macros, are never transpiled; only model bodies and singular tests are.
Metrics, from wherever the project defines them
The comment's "What changed in the output" section evaluates every metric the project defines, on the base branch and on the pull request, and lists the ones that moved. Three sources are read, and a project needs only one of them:
- dbt's semantic layer. Semantic models and metrics in the project's YAML:
simplemetrics with measure and metric filters,ratioandderivedmetrics, including ones whose inputs sit on different models (orders per customer readsfct_ordersanddim_customers; each side is evaluated on its own model and the comment names both). Acumulativemetric with no window and no grain to date is a running total over all time, and its final value is the plain total, so it is evaluated as one. This is the route for a project that uses dbt and nothing else. A windowed or grain-to-datecumulativemetric and aconversionmetric need a time spine and are reported as not evaluated. - Lightdash
meta.metrics. Aggregate metrics on columns (sum,count_distinct,average, ... with theirfilters) andnumbermetrics on the model whosesqlreferences other metrics with${...}. metrics:in.dbt-preflight.yml. A name, a model and an aggregate SQL expression, for teams with neither of the above.
With no metrics defined, columns, row counts and differing rows are still compared, and the comment says where metrics can be defined.
Conventions
The convention checks encode the JB Analytica warehouse conventions. They run on the changed models only, so existing debt does not resurface on every pull request.
| Rule | Severity | What it wants |
|---|---|---|
naming |
error | staging/ models named stg_<source>__<entity>, intermediate/ named int_<entity>__<verb>, marts/ named dim_<entity> or fct_<event> |
layering |
error | A staging model reads exactly one source() and no ref(); nothing outside staging reads a source() |
primary_key |
error | At least one column tested unique and not_null |
description |
warn | The model has a description |
column_naming |
warn | snake_case; timestamps end in _at, dates in _date, booleans start with is_ or has_ (read from the built table's real types) |
Models outside those three folders are exempt from the naming and layering rules.
That is the jba preset, at full strength when the repository has a .dbt-preflight.yml.
A repository without one gets the same rules as warnings only: it never signed up for
anyone's conventions, and a warning is advice where an error would be a demand. A project
with its own conventions adjusts them in the config:
conventions:
preset: jba # jba (default) or none
rules:
description: off # off | warn | error, per rule
column_naming: warn
layers: # folder under models/ -> regex a model name must match
staging: "^stg_[a-z0-9]+__[a-z0-9_]+$"
marts: "^(dim|fct|rpt)_[a-z0-9_]+$"
source_layer: staging # the only folder allowed to read source(); null allows any
Running it locally
uv tool install dbt-preflight # or: pipx install dbt-preflight, pip install dbt-preflight
cd your-repo
dbt-preflight run --base-ref origin/main
Without --base-ref, every model counts as changed and the whole project is built. Add
--comment-file preflight.md to write the comment to a file instead of stdout, and
--keep-workdir to leave .preflight/ (fixtures, DuckDB file, dbt artefacts) behind for
inspection.
Integrating
--summary-file preflight-summary.json writes a JSON document alongside the comment, with
the verdict, counts and every finding as structured data, for a hook, a bot or a plugin to
act on without parsing Markdown. docs/integration.md has the full
contract: every flag, the exit code, the JSON schema, and a worked pre-pull-request hook.
How it works
dbt parseon the pull request, to learn the sources, models and tests.- Fixtures: model2data generates data for every source table, cast to the declared types,
loaded into a DuckDB file whose catalog is named after the sources'
database. dbt parseon the base branch in a temporary worktree, thendbt ls --select state:modifiedto find what changed.- Each compiled model is transpiled from the project's dialect to DuckDB with sqlglot.
- The selection is closed: downstream models, models whose tests read a changed model,
and all their ancestors.
dbt buildruns on that set with--indirect-selection cautious, so every test that runs has all its inputs built. - Conventions are checked on the changed models, column rules against the built tables.
- The changed models and everything downstream are built on the base branch too, into their own schemas on the same fixtures, and compared: columns, row counts, differing rows and metric values.
- One Markdown comment, posted or updated through the GitHub API.
The bundled example in examples/webshop/ is the JB Analytica
reference architecture's dbt
project, made portable with two adapter.dispatch macros. It is what the test suite and
the action's self-check run against.
Development
uv sync --extra dev
uv run poe check # ruff, ty, pytest
Bugs and feature requests belong in
the issue tracker. A pull request
is welcome; poe check has to be green and, if you change what the comment says,
uv run python scripts/scenarios.py re-records the six scenarios so the change shows up
in docs/scenarios/.
Licence
MIT. See LICENSE.
Release files for dbt-preflight 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| dbt_preflight-0.3.0.tar.gz | 103.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| dbt_preflight-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 178.3 kB
Release files / dbt_preflight-0.3.0.tar.gz
| Download URL | dbt_preflight-0.3.0.tar.gz |
|---|---|
| Size | 103.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c7abbb7b64ff578e6fde1f7d4851d64aa5c1456096e2bb6e9405930e6e0d7366
|
|
BLAKE2b-256 checksum How to use checksums |
793289385444b440e607527384dff75fc9d21e195bb9a28351aa4ee3c08ed38d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 16, 2026.
Transparency logRelease files / dbt_preflight-0.3.0-py3-none-any.whl
| Download URL | dbt_preflight-0.3.0-py3-none-any.whl |
|---|---|
| Size | 74.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
65955305ea5b5affb161fa1141884f95fadbf841fa90c914c98118446021f2c0
|
|
BLAKE2b-256 checksum How to use checksums |
78fbbe998679b32aee72095deb8bc43b2ba0339c1e0b0ef928c071bb837ea353
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 16, 2026.
Transparency log