DVT FLIP-TARGET-TO

dvt flip-target-tomoves your project's default target to another output in your profile — and takes the project's SQL with it, transpiled into the new engine's dialect. The project that ran on Postgres this morning runs on Snowflake this afternoon, and everything the command could not carry across is named, per file, with the line.

START WITH --DRY-RUN

Always run it with --dry-run first. Loudly, deliberately, every time: it shows you the complete ledger — every file that would move, every role that would change, every limitation — and writes nothing. It is the cheapest look you will ever get at a warehouse migration.

$ dvt flip-target-to snow_prod --dry-run
✅ models/staging/stg_orders.sql
✅ models/staging/stg_customers.sql
✅ models/marts/orders_enriched.sql
⏭  models/marts/vendor_audit.sql  — pinned to a target override

3 flipped, 1 skipped, 0 not converted, 0 converted with a reported limitation (postgres → snowflake)
dry run — nothing written

Don't remember what your outputs are called? Run it bare and it lists them, marking the current default:

$ dvt flip-target-to
Pick a target:
  pg_local                     postgres  ← current default
  snow_prod                    snowflake

Usage: dvt flip-target-to <target> [--dry-run]

IN PLAIN DBT

Changing warehouses means rewriting every model by hand. You edit target:in profiles.yml, and from that moment every model, snapshot, test and macro written in the old engine's dialect is quietly broken — IFNULL here, QUALIFY there, date functions everywhere. There is no tool, no report, no list of what breaks; you find out model by model, at run time, over days. DVT does the rewrite for you in one command, deterministically, and hands you a ledger of the handful of places that still need a human.

THE REAL RUN

Happy with the ledger? Drop the flag. The same report prints, and this time every converted file is written (each with a timestamped .bak beside it), the target:line in profiles.yml moves, and the new engine's adapter is provisioned if it isn't already there:

$ dvt flip-target-to snow_prod
✅ models/staging/stg_orders.sql
✅ models/staging/stg_customers.sql
✅ models/marts/orders_enriched.sql
⏭  models/marts/vendor_audit.sql  — pinned to a target override

3 flipped, 1 skipped, 0 not converted, 0 converted with a reported limitation (postgres → snowflake)
default target: pg_local → snow_prod in /Users/you/acme/profiles.yml
environment: provisioned 'snowflake' for 'snow_prod'

The rewrite domain is every path class whose SQL executes on the default target, read from your own dbt_project.yml paths: models, snapshots, tests (singular and generic), analyses and macros. Block-wrapped files keep their semantics untouched — a snapshot's strategy and unique_key, a test's signature, a macro's parameters are Jinja, and Jinja comes back byte-for-byte. Only the SQL between the tags is translated.

And some things are never touched, by design: federated models that stay federated (their body is DuckDB SQL by doctrine, whatever the default is), models pinned with config(target='...')(a pin is a declaration that the model does not live on the default), and DVT's own compatibility macro file (machinery, not your SQL — the skip is stated in the ledger).

THE FLIP ALWAYS COMPLETES

This is the command's charter, and it is a founder ruling: a refused flip strands you. Your model works today on the old target; if the flip refused, you could neither move nor fix a limitation that lives in the transpiler rather than in your code. So the deterministic lane never gives up halfway — every file is attempted, the target always moves, and the run always finishes. Where the transpiler cannot carry something across, it names it — per file, with the line — and moves on:

$ dvt flip-target-to pg_prod
✅ models/staging/stg_products.sql
✅ models/staging/stg_orders.sql
✅ models/marts/search_products.sql  — 1 function(s) SQLGlot could not carry across — see below
     ⚠️  MATCH() is mysql-only — postgres has no equivalent, so it was carried across unchanged
         and will fail at build (line 14)
          WHERE MATCH(name, description) AGAINST ('wireless speaker' IN NATURAL LANGUAGE MODE)

⚠️  WHAT SQLGLOT COULD NOT CARRY ACROSS — ruling 243. The flip COMPLETED and the target has
moved; these are the places where the translation is bounded by SQLGlot rather than by your
SQL, named so you can fix them rather than discover them:

    /Users/you/shop/models/marts/search_products.sql:14
      • MATCH() is mysql-only — postgres has no equivalent, so it was carried across unchanged
        and will fail at build
          WHERE MATCH(name, description) AGAINST ('wireless speaker' IN NATURAL LANGUAGE MODE)

    1 file(s) WERE converted, but call a function postgres does not have. The SQL was carried
    across as written and will fail at build on exactly the line above.
    This is not a partial flip: the project is on the new target and every one of these is a
    known, named, local fix. A flip that refused instead would have left you unable to move OR
    to fix it.

3 flipped, 0 skipped, 0 not converted, 1 converted with a reported limitation (mysql → postgres)
default target: mysql_local → pg_prod in /Users/you/shop/profiles.yml
environment: provisioned 'postgres' for 'pg_prod'

There are exactly two kinds of limitation, and the ledger keeps them apart. Converted with a reported limitation: the SQL translated, but it calls a function the new engine provably lacks — the file is written, and the call is named with the line where it will fail at build. Not converted: the transpiler could not produce the new dialect at all — the file is left exactly as it was (nothing is ever half-written), and the run tells you where the old dialect stopped making sense:

❌ models/legacy/order_stats.sql  — does not transpile: Invalid expression / Unexpected token. Line 6, Col: 9.
     ⚠️  does not transpile: Invalid expression / Unexpected token. Line 6, Col: 9. (line 6)
          procedure analyse()

Translate these by hand (full paths, straight to your editor):
  /Users/you/shop/models/legacy/order_stats.sql
or connect AI-Proxy (hub → Settings → AI CREDENTIALS) and rerun — AI finishes what the
deterministic pass refuses, validated before anything is written.

Jinja is never a blocker either. Every {{ ref() }}, {{ config() }} and {% if %}is masked behind a sentinel, the SQL spans between them are translated in place, and the Jinja comes back byte-for-byte — flip evaluates no branch and counts no loop; rendering is dbt's job. A reported limitation is not a failed command: the exit code is 0, because the flip did what it said it would — it moved the project and told the truth about the boundary.

WHAT ONE FLIP ACTUALLY RUNS — FIVE PHASES

A flip is not a single rewrite pass. It is five phases, in a fixed order, so the project is provably healthy on both sides of the move:

config-checkup  →  config-fix  →  flip-target-to  →  config-checkup  →  config-fix
   (before)         (before)      the move itself       (after)           (after)

Before: checkup diagnoses and fix repairs anything that would stop the project parsing — an illegal materialization string, for instance — because a project has to parse before anything can flip:

config-checkup then config-fix ran first — the project has to PARSE before anything flips:
  ✅ models/marts/promo_lift.sql — materialized='tabel' is not a DVT materialization

The move:every model's role is recomputed under both targets, the files are transpiled, and the profile's target: line is written. After: the same checkup/fix pair runs again — this time against the target that is default now, reconciling whatever the move changed:

config-checkup then config-fix ran again AFTER the flip — leftovers are reconciled against
the target that is default NOW ('snow_prod'), which is the pass that could not exist while
the target moved last:
  ✅ models/marts/blended_sessions.sql — materialized='table' but its dependencies are
     federated — the engine already coerces this at runtime

Then the landing. A flip ends with a real dvt parse on the new default — not a copied manifest, an actual parse of what is now on disk — because a flip lands finished or it has not landed. Silent when it succeeds; loud, with a non-zero exit code, when it does not:

❗ THIS FLIP DID NOT LAND: the project does not parse on 'snow_prod'. The target moved and
the files were rewritten, but `dvt parse` failed, so target/manifest.json is not the new
engine's and every verb that reads it will refuse (ruling 282):
      • Compilation Error in model orders_enriched (models/marts/orders_enriched.sql)
    Fix what it names, then run `dvt parse` — the flip itself does not need repeating.

IN PLAIN DBT

Each of these phases is a manual chore you'd have to remember: sanity-check the configs, edit profiles.yml, install the new adapter, fix the configs the move broke, re-run dbt parse and read the wreckage. Forget one — most people forget the re-parse — and the first sign is a failed production run. DVT runs all five every time, in order, and the exit code tells you whether the flip landed.

ROLES MOVE WITH THE TARGET

Federated-vs-native is a property of a model relative to the default target— so moving the target moves the boundary, and models trade places. A model that joined a Snowflake source into your Postgres warehouse was federated (DVT's engine ran it); flip the default to Snowflake and every input is suddenly local, so it becomes a plain native table. Its mirror image — a model that read only Postgres tables — now reads a non-default engine, and becomes federated. The flip handles both directions automatically, and the dialect follows the role: a model going native is rewritten into the new target's dialect, a model going federated is rewritten into DuckDB, because that is who runs it now.

✅ models/marts/daily_kpis.sql  — ROLE CHANGED: f_table -> table (now native)
     ↳ every engine it reads is now the default target, so it builds in place — no round trip
       out through Sling and back in. It still stores its rows, exactly as it did before.
✅ models/marts/blended_sessions.sql  — ROLE CHANGED: table -> f_table (now federated)
     ↳ it now reads an engine that is NOT the default target, so it LANDS its data on the
       default target — that costs storage and a load; that is the physics, not a preference.

2 model(s) changed ROLE because the default target moved — federated-vs-native is relative to
the default, so moving it moves the boundary:
      • models/marts/daily_kpis.sql: f_table -> table
      • models/marts/blended_sessions.sql: table -> f_table

The pairs are closed — table ↔ f_table, incremental ↔ f_incremental — so a flip there and back is exact. Every role change states its physics in the ledger, never silently: a model that was ephemeral and becomes federated now persists rows it never stored before, and the run says so in capitals. One conversion is one-way: no view is flippable— a view the flip rewrites becomes a table, permanently, and the ledger calls that what it is, the cost of flipping.

--AGENTIC — AI TRANSLATES THE HARD FILES, MACHINES JUDGE

The deterministic lane already includes a light AI safety net: when AI-Proxy is connected, each file it refused gets one AI attempt, validated before anything is written (--no-ai turns that off). --agentic goes further — it is the migration-campaign lane:

$ dvt flip-target-to snow_prod --agentic
✅ models/legacy/order_stats.sql  — agentic (attempt 2; was: does not transpile: Invalid
   expression / Unexpected token. Line 6, Col: 9.)
engine judge: 2 file(s) failed compile on 'snow_prod' — one agentic repair round

Every hard file — refused outright orconverted with a named limitation — gets its own bounded conversation, and they run in parallel: propose, validate, feed the validator's exact words back, retry, up to three attempts per file. The deterministic output is attempt zero, so the flip can never come out worse than the deterministic lane. No answer touches a file until it passes validation: every Jinja block byte-for-byte identical, the output parses in the new dialect, and no call to a function the new engine provably lacks.

Then the final judge, and it is not an AI: after the target moves, the real new enginecompiles the project. Any model it rejects buys one more parallel repair round, driven by the engine's own error text — and whatever still fails after that is named in the ledger for a human. The flip still always completes.

FLIPPING PART OF THE PROJECT

-s / --select and -x / --exclude scope the file rewrite with shell-style patterns, matched against names and paths across every path class. The classic use is finishing the stragglers after a full flip:

$ dvt flip-target-to snow_prod -s 'staging/*'        # one folder
$ dvt flip-target-to snow_prod -s stg_orders          # one model
$ dvt flip-target-to snow_prod -x 'legacy/*'          # everything but

BACKUPS, ROLLBACK, EXIT CODE

Every file the flip rewrites keeps a timestamped .bak beside it — profiles.yml included — so any single decision is reversible by hand. And if a run is interrupted — a crash, a Ctrl+C, a write that dies halfway — the flip restores every file it had already rewritten, verified byte-for-byte, because an unfinished run must leave the project exactly where it started:

❗ This flip did not finish, so it rewrote NOTHING: 12 already-translated file(s) restored
to exactly what they were (ruling 149 — an unfinished run leaves the project in an UNKNOWN
state, which is the one thing all-or-nothing still guards).

A reported limitation is the opposite case — a known state, not an unknown one — so it never triggers a rollback and never fails the command. The exit code is 0unless the final parse fails, in which case the parse's own exit code is carried through. On a terminal, unconverted files also get an interactive aftercare pass — the flip offers to open each one in your editor at the work that is left; --silent (or any non-TTY run, like CI) skips it.

REFERENCE — EVERY FLAG

FLAGARGUMENTSDEFAULTWHAT IT DOES
<target>positionalThe profile output to make the default. Omit it to list every output in the profile with the current default marked.
--dry-runoffShow the complete ledger — conversions, role changes, limitations — and write nothing. Run this first, every time.
-s, --selectPATTERN...all filesOnly these files (name or path, shell-style fnmatch patterns — matched across every path class: models, snapshots, tests, analyses, macros).
-x, --excludePATTERN...noneDrop these files (same pattern language as --select).
--agenticoffAI translates every hard file — parallel bounded conversations per file, validated before any write, then the new engine compiles the project and failures get one repair round. The flip still always completes.
--no-aioffSkip the AI rescue pass for files the deterministic transpiler refuses. Those files stay unchanged and are listed for a human.
--silentoffNo interactive prompts — the plain ledger only. Automatic when not on a TTY (CI, cron).
--project-dirPATH.The dbt project to flip.
--profiles-dirPATHdbt's resolutionWhere profiles.yml lives. When given explicitly it is the ONLY place consulted — a missing profiles.yml there is a named error, never a silent substitution. Otherwise: $DBT_PROFILES_DIR, the project dir, then ~/.dbt.
--forceRetired: accepted so old scripts keep working, does nothing. The flip always completes and the target always moves, so there is nothing left to force.