DVT CONFIG-CHECKUP
dvt config-checkup reads every model, source and project file in your project and tells you where the written configs stop telling the truth — models declared standard that provably run federated, materialization typos, ambiguities only you can settle, missing project state. It reports, and that is all it does: no file is written, no .bak, not a byte — so it is always safe to run, on any project, at any moment.
RUN IT
No flags needed. On a healthy project, the whole report is one line:
$ dvt config-checkup Every config tells the truth — nothing to do.
That line is the goal state. Here is the same command on a project that has drifted a little — a typo, a model whose declared materialization no longer matches what actually runs, a deleted project key, a source missing its connection, and an incremental worth a look:
$ dvt config-checkup
🔧 models/staging/stg_orders.sql
materialized='f_tabel' is not a DVT materialization
`dvt config-fix` sets it to 'f_table' (the one legal neighbour)
🔧 models/marts/customer_ltv.sql
materialized='table' but its dependencies are federated — the
engine already coerces this at runtime
`dvt config-fix` sets it to 'f_table' (the config then says
what actually runs)
ℹ models/marts/orders_daily.sql
incremental with no unique_key
fine for append; merge and direct-path incrementals want one.
DVT can name it for you: `dvt metadata refresh --all` records
each source's primary key, and checkup then suggests the exact
column.
💡 models/staging/schema.yml
source 'mysql_crm' has no meta.connection
name a profiles.yml output — one rule, no exceptions
🔧 dvt_project.yml
suite_port is missing — deleted or never scaffolded; the suite
would fall back to its silent default (46100) — the port is
defined in dvt_project.yml and NOWHERE else (no sticky record,
no dynamic allocation), so a missing key leaves no record of
where this project serves
`dvt config-fix` restores the scaffolded key (suite_port: 46100),
keeping a .bak of dvt_project.yml
3 repairable, 1 suggested, 1 informational (default target: pg_local)
config-checkup only REPORTS — nothing was written.
`dvt config-fix` repairs the 3 provable one(s), each file keeping a .bak.Every finding is three lines: the file, what is wrong, and what to do about it — in enough detail that you can repair your own project by hand if that is how you like to work. If you would rather have the provable ones repaired for you, dvt config-fix acts on exactly this report — it knows nothing checkup does not.
THREE MARKS, THREE TIERS
Each finding carries one of three marks, and the mark tells you who acts next:
🔧 repairable — the finding is provable and dvt config-fix repairs it automatically, keeping a timestamped .bak. Checkup names it and leaves it alone. The mark is a spanner, not a tick — a tick would claim an edit that never happened.
💡 suggested — the finding needs a human decision, and the fix is spelled out. Checkup never guesses on your behalf; config-fix offers these as an interactive choice instead.
ℹ informational — perfectly legal, worth knowing. Nothing is wrong; something might be wasteful or fragile.
CONFIGS THAT LIE ABOUT FEDERATION
The headline check. DVT classifies every model at runtime from facts — which connections its sources live on, and whether anything it refs is federated. A model can be written materialized='table' and still run federated, because the engine coerces it when its dependencies cross engines. The project runs fine — but the file now says one thing and the run does another:
-- models/marts/customer_ltv.sql
{{ config(materialized='table') }} -- says standard...
select c.customer_id, sum(o.amount) as lifetime_value
from {{ source('pg_prod', 'customers') }} c -- postgres
join {{ source('mysql_crm', 'orders') }} o -- mysql
using (customer_id)
group by c.customer_id -- ...runs federated, in factCheckup asks the exact same question the engine asks — same classification, same rule, one implementation — and reports every model where the written answer and the real answer disagree. The disagreement cascades the way federation itself does: a model that refs a federated model is federated too, so one cross-engine join can make every config downstream of it start lying at once. Checkup walks the whole cascade and names each one.
One deliberate exception: a model with its own target= pin is left exactly as declared. A pin is the author opting out of the default target on purpose, and the default moving underneath it changes nothing — checkup honours that in both directions, never coercing a pinned model and never nudging one to de-federate.
IN PLAIN DBT
There is no lie to catch, because there is no runtime that reads across engines — a model joining Postgres to MySQL simply cannot be written. The nearest real-world workflow is the audit you do by hand after replatforming: grep every config(), trace every model's sources, and hope you spot the ones whose declared behavior no longer matches reality before the scheduler does. dvt config-checkup is that audit, automated, and honest about what it can prove.
TYPOS IN MATERIALIZED=
A materialization string outside the legal set stops the project parsing — so checkup finds it first. When the typo has exactly one legal neighbour, it is provable and lands in the repairable tier: f_tabel can only mean f_table. A handful of spellings are recognized outright — ftable, f-incremental, federated_table — because their intent is known even when raw edit distance is ambiguous.
When nothing legal is close, guessing would be wrong, so checkup hands you the menu:
💡 models/marts/weird_rollup.sql
materialized='materialise' is not a DVT materialization and
nothing close is
pick one of: ephemeral, f_incremental, f_table,
federation_incremental, federation_python, federation_table,
incremental, table, viewAMBIGUITIES ONLY YOU CAN SETTLE
The suggested tier is everything with more than one right answer. Each finding spells out the choices; none is ever auto-applied.
Ephemeral with federated dependencies. dvt parse blocks this outright — an ephemeral model cannot carry federated dependencies — so checkup flags it before parse refuses, and lists the full legal set to pick from. This one is never auto-fixed: a human always chooses what the model becomes.
A target pin on a standard materialization. target='mysql_eu' on a plain table is an illegal combination — standard materializations run on the default target only. Two legal ways out, and only you know which was meant:
💡 models/marts/eu_extract.sql
target='mysql_eu' on a standard materialization — dbt runs
those on the default target only
federate it (materialized='f_table') or drop the target=A source with no meta.connection. Every source names its connection — one rule, no exceptions — and checkup cannot guess which of your profile outputs was meant.
Needless federation. A federated model whose every dependency provably lives on the default target still computes correctly — but the result round-trips out of the engine and back in for nothing. A standard table would build in-place with zero data movement. Checkup says so and leaves the call to you (and never nudges a target-pinned model — pinned means deliberate).
A DVT key in dbt_project.yml. Your dbt_project.yml stays byte-pure dbt; DVT settings live in dvt_project.yml. dvt run refuses on a stray DVT key — checkup finds it before a run is even attempted, and neither command ever edits your dbt file.
Cross-engine hooks addressed at the wrong place. A hook that names a connection must name a real, SQL-capable profile output. A typo'd name or a bucket connection (S3, GCS — no SQL engine there) would fail the model at run time, so checkup says it now:
💡 models/marts/audit_log.sql
hook addressed at 'pg_prd', which is not an output in this
profile
fix the connection name — the hook would fail the model at
run timeMISSING KEYS IN DVT_PROJECT.YML
Every key the dvt init scaffold writes must exist in the file. The project still runswith one deleted — every reader supplies its own default — but the engine's silent default must never be a project's only record of a decision, so a missing scaffolded key is drift and checkup reports it as repairable. config-fix restores the key at its scaffolded default, with a comment saying why, keeping a .bak. The full story lives on the dvt_project.yml page.
INCREMENTALS WITH NO UNIQUE_KEY
Legal, and fine for append-only loads — merge and direct-path incrementals want a key. By default this is a quiet ℹ. But once the metadata store has profiled your sources (dvt metadata refresh --all), checkup stops shrugging and names the exact column, because DVT already learned the driving table's primary key:
💡 models/marts/orders_daily.sql
incremental with no unique_key
the metadata store records the primary key of pg_prod
public.orders as order_id — add unique_key='order_id' to the
config block (checkup never edits models; Martin can propose
the edit behind APPLY)Still a suggestion — checkup never edits a model — but now a concrete one, copy-paste ready.
IN PLAIN DBT
An incremental without a unique_keyquietly appends duplicates until a dashboard looks wrong, and finding the right key means opening the warehouse console and inspecting the source table yourself. DVT's metadata store already recorded the primary key when it profiled the source — checkup just hands it to you.
IT NEVER WRITES — BY DESIGN, NOT BY DEFAULT
The read-only guarantee is the whole point of the command, and it is absolute: no model, no yml, no .bak, no temp file. You can run it mid-refactor, on production checkouts, in a cron job, while a colleague has the project open — your files never move underneath you. The report ends by saying so, every time:
config-checkup only REPORTS — nothing was written.
Two flags exist purely so muscle memory from config-fix never bites you. Both are accepted and ignored, and the help text says exactly why:
--dry-run accepted and ignored — config-checkup never writes, so
every run is a dry one
--silent accepted and ignored — config-checkup has no prompts;
`dvt config-fix` doesSCOPING THE REPORT
-s/--select and -x/--excludetake one or more patterns, matched case-insensitively against a model's name or its project-relative path — the same shell-style globs as everywhere else in DVT. With the source and project-file findings from earlier already repaired, a run scoped to one model reads:
$ dvt config-checkup -s customer_ltv
🔧 models/marts/customer_ltv.sql
materialized='table' but its dependencies are federated — the
engine already coerces this at runtime
`dvt config-fix` sets it to 'f_table' (the config then says
what actually runs)
1 repairable, 0 suggested, 0 informational (default target: pg_local)
config-checkup only REPORTS — nothing was written.
`dvt config-fix` repairs the 1 provable one(s), each file keeping a .bak.-s 'models/marts/*' scopes to a folder; -x 'stg_*' drops the staging layer. Selection narrows what is reported — the federation classification underneath always considers the whole project, so a scoped report is never wrong, just shorter. Project-level findings (sources, dvt_project.yml, dbt_project.yml) are outside model selection and always appear.
THE EXIT CODE — A CI GATE FOR FREE
Checkup exits 1 when anything sits in the suggested tier — a decision is pending that only a human can make — and 0 otherwise. Repairable and informational findings alone exit 0, because nothing there needs a human: config-fix can settle the repairable tier on its own. Which makes the honest CI gate a one-liner:
$ dvt config-checkup && echo "no decisions pending" Every config tells the truth — nothing to do. no decisions pending
WHERE IT SITS: CHECKUP, THEN FIX, THEN FLIP
The two config commands are one diagnosis with two verbs. dvt config-checkup tells; dvt config-fix repairs — and fix literally calls checkup for its findings, so the two can never disagree about what is wrong. The everyday sequence is read the report, let fix handle the provable tier, decide the suggested tier yourself.
dvt flip-target-to runs the pair automatically — before the flip as a parsability gate, and after it to reconcile every config against the new default target — so a flip never starts from a broken project and never leaves one behind. See switching targets for that whole story.
IN PLAIN DBT
Changing your default target means re-auditing the project yourself: grep for hardcoded behavior, re-read every model's config, run the DAG and triage what breaks. DVT turns that audit into a command you can run any time — and wires it into the flip so you never have to remember to.
REFERENCE — EVERY FLAG
| FLAG | ARGUMENT | WHAT IT DOES |
|---|---|---|
| -s, --select | PATTERN ... | Limit the report to models whose name or project-relative path matches any pattern (case-insensitive shell glob). Project-level findings always appear. |
| -x, --exclude | PATTERN ... | Drop matching models from the report. Same matching rules as --select; exclude wins where both match. |
| --project-dir | PATH | The project to check. Defaults to the current directory. |
| --profiles-dir | PATH | Where to find profiles.yml, when it isn't in the standard location. |
| --dry-run | — | Accepted and ignored — config-checkup never writes, so every run is a dry one. |
| --silent | — | Accepted and ignored — config-checkup has no prompts; dvt config-fix does. |