This is a six-step playbook for migrating to dbt with an AI agent. Every step has a command to run and an exit condition that tells you it is finished. An agent earns its place on the two mechanical steps, inventorying every legacy object and translating the SQL dialect, because reading and rewriting SQL is what a model does well. The rest of the migration is not mechanical, and that is where the schedule goes.
The step that decides the migration is parity: proving the new pipeline returns the same numbers as the old one. Prove it with a data diff, not a code review, because SQL that compiles only shows the target engine accepts the statement, not that the rows match. Diff a full month, and when the two pipelines sit on different platforms, compare them with bisection hashing so no row leaves its engine. Four causes produce most mismatches: integer division, null handling in aggregates, timezone semantics, and undocumented business filters.
Roll out one consumer at a time, internal dashboards first, so a gap surfaces quietly instead of on a customer-facing report. Decommission on evidence, not silence: suspend the old objects for one more business cycle rather than dropping them, so a job nobody documented fails with an error you can undo instead of destroying data you cannot. Target dbt Core v1 today, and run dbt parse --use-v2-parser before you treat v2 as an option.
A dbt migration plan that budgets the schedule for translating SQL finishes the translation early. The rest of the schedule then goes to parity testing, which means proving the new pipeline returns the same numbers as the old one. That proof is slow because the old pipeline holds years of business decisions nobody wrote down. Each decision has to be found in the old code, then reproduced in the new pipeline or explained away.
Start parity testing in week one, against whatever you have translated so far.
Risk concentrates between steps four and six, not at the start.
Step 1. Inventory Before You Translate Anything
Start a dbt migration with an AI agent by building an inventory of every database object you are moving: tables, views, stored procedures and jobs. Build that list from recollection and you miss the ones nobody thinks of, which are the ones that break the migration later.
The inventory has four columns:
- The object name.
- What reads that object.
- The last-read date, meaning when a query last touched it.
- Who owns it.
The last-read date saves the most work, and you do not have to fill it in by hand. Your data platform already logs it. Snowflake records every object a query read in ACCESS_HISTORY, which retains 365 days.
-- Snowflake. Every base object, with the last time a query read it.
-- An object that returns no row here has not been read in a year.
select
f.value:objectName::string as object_name,
max(a.query_start_time) as last_read,
count(*) as reads
from snowflake.account_usage.access_history a,
lateral flatten(input => a.base_objects_accessed) f
group by 1
order by last_read asc;
An object missing from this result, or carrying a last_read a year old, is a first candidate to cut. Step 2 decides which objects actually go.
Hand this step to an agent. Reading hundreds of stored procedures is slow for a person and fast for a model. A misreading here is cheap, because you correct one row of the inventory and carry on.
Step 2. Decide What Not to Migrate
Deciding what not to migrate is the only step that makes the project smaller. Proving that nothing reads an object is harder than migrating it anyway, so scope stays larger than it needs to be. Evidence shrinks the scope back. An opinion about what looks unused does not survive the first objection.
Column-level lineage supplies that evidence. Traced at table level, lineage reports every downstream model and tells you to migrate. Traced at column level, lineage names only the reports that actually read the field. Column-level lineage use cases shows how to trace a single field to its downstream consumers.
dbt answers the table half of the question on its own:
# Every model downstream of one legacy staging model, as a countable list.
dbt ls --select stg_legacy_orders+ --resource-type model --output name
dbt ls follows the ref() links between models, so it lists whole models and never individual columns. Cut an object only when both checks agree:
- The last-read date from step 1 shows no reads inside the retention window.
- Column-level lineage shows nothing still reading it.
Every object you cut is one fewer to translate in step 3 and one fewer to diff in step 4.
Step 3. Translate the Syntax
An agent translates SQL from one dialect to another quickly, so step 3 is one of the shorter steps in a migration. Migration plans still give translation the most room, because it is visible progress while the risk sits later in step 4.
Dialect differences are mechanical and most of them have deterministic mappings:
| Dialect difference | Why it needs a rewrite |
|---|---|
| Date functions | Names and argument order differ between platforms. |
| String concatenation | The concatenation operator differs, so CONCAT is the portable form. |
| Window syntax | Frame clauses and default frames vary between engines. |
| Null handling in aggregates | Nulls sort first on one engine and last on another. |
| Division | Integer division truncates on one engine and keeps decimals on another. |
Division is the difference most likely to change a number without anyone noticing:
-- Redshift divides two integers and truncates the result.
select 7 / 2 as ratio; -- 3
-- Snowflake divides the same two integers and scales the result.
select 7 / 2 as ratio; -- 3.500000
Both statements are valid SQL on both platforms. Each platform returns a different value, and neither raises an error.
Open-source tooling already handles cross-database SQL translation. SQLGlot translates between more than 30 dialects, and its README states what that does not cover.
SQLGlot is a transpiler, not a validator. A query that parses successfully may still fail at execution time.
A transpiler rewrites a query from one dialect into another. A transpiler answers whether the target engine accepts the statement. It does not answer whether the rows the new query returns match the rows the old query returned.
Altimate Code connects to over a dozen data platforms: Snowflake, Databricks, BigQuery, Redshift, Postgres, DuckDB, Trino, ClickHouse, MongoDB, MySQL, SQL Server, Oracle and SQLite. Dialect translation covers fewer of those platforms. The documented cross-dialect translation covers Snowflake, BigQuery, Databricks, Redshift, PostgreSQL, MySQL, SQL Server and DuckDB.
Our migration guide publishes one 47-model project end to end. 38 models translated cleanly, 6 needed manual review for VARIANT columns, and 3 used Snowflake-only features. That is 81% automatic. Snowflake STREAMS map to change data capture and TASKS to scheduled queries, which are architecture calls rather than syntax.
How the object set narrows across the six steps. Step 4 is where the differences surface.
Step 4. Prove Parity With a Data Diff, Not a Review
Parity testing runs both pipelines against the same input and compares the results to prove they agree. Scheduled as final QA, after every model is translated, parity testing is the step that slips the schedule. The number of mismatches decides how long it takes, and nobody knows that number until the first diff runs. Leave parity to the end and you learn it with the deadline already fixed.
Parity has an exact answer, which is why a review is the wrong tool. A reviewer reads two queries and says they look equivalent. A data diff is a query that compares the rows the two pipelines produced and reports where they differ.
Inside one platform the diff is two set operations:
-- Row-level parity inside one platform. A pass returns zero rows.
select 'legacy_only' as side, d.* from (
select * from legacy.fct_orders
except
select * from target.fct_orders
) d
union all
select 'target_only' as side, d.* from (
select * from target.fct_orders
except
select * from legacy.fct_orders
) d;
Run an aggregate diff as well. The row-level diff above uses EXCEPT, which removes duplicate rows before it compares the two sides. A row that appears twice on one side and once on the other passes that diff, and a SUM over the table drifts by one row's value:
-- The check a row-level diff passes straight through.
with legacy_monthly as (
select date_trunc('month', order_date) as month, sum(net_amount) as total
from legacy.fct_orders group by 1
),
target_monthly as (
select date_trunc('month', order_date) as month, sum(net_amount) as total
from target.fct_orders group by 1
)
select
coalesce(l.month, t.month) as month,
l.total as legacy_total,
t.total as target_total,
l.total - t.total as delta
from legacy_monthly l
full outer join target_monthly t on l.month = t.month
where l.total is distinct from t.total;
EXCEPT only works when both tables sit on one engine. In most migrations they do not, because the old pipeline runs on the platform you are leaving and the new one on the platform you are moving to. Bisection hashing is the diff for that case, and it compares checksums instead of rows. It splits both tables into blocks and hashes each block on its own engine, so no row leaves its platform. It then repeats on only the blocks whose hashes disagree. You compare a 100M-row table by moving checksums, and the rows stay where they are.
Altimate Code's diff covers twelve data platforms in any combination, so a table on one engine compares against a table on another. The diff can print up to 5 sample rows into its output, and your model provider sees those rows. On regulated tables, use profile mode instead, which compares column statistics and moves no row values. Our data parity guide names the partition modes.
A high mismatch count does not always mean the migration is wrong, so read what the differences are before you act on the count. One published SQL Server to Microsoft Fabric run shows why:
- What ran. A row-level diff ran on the
fact_salestable. - What came back. The diff returned 92 differing rows.
- Why it passed. All 92 differed by trailing decimal zeros alone, because the staging layer cast to a fixed scale. The values matched, so the table passed.
When an agent rewrites a query, a second model reviewing that query is no substitute for a diff. We covered that on our DataCamp panel about what AI agents are really changing in data engineering.
Step 5. Cut Over One Consumer at a Time
Cutting over means pointing each consumer at the new pipeline. A consumer is any dashboard, report or job that reads your data. Move the consumers across one at a time. Start with the consumers whose owners sit close to the data team, because those owners notice a wrong number quickly and report it privately. In practice that means internal dashboards before customer-facing reports.
Switching every consumer on the same day turns a small parity gap into a public one. A gap in one revenue model then surfaces as a wrong number on the board's dashboard. Caught one consumer at a time, the same gap is a quiet fix on an internal dashboard, before anyone outside the data team sees it.
Keep the diff running on a schedule while both pipelines are live, rather than as a one-time gate. A model that matched once stops matching when a source changes shape. While both pipelines run, you can see which side moved. Once the old pipeline is off, nothing is left to compare against.
Step 6. Decommission the Legacy Pipeline on Evidence, Not Silence
Turn the legacy pipeline off on evidence that nothing depends on it. A quiet week proves nothing, because a quarterly job does not run in a quiet week.
Three things have to hold before you decommission:
- Lineage shows nothing reads the legacy objects.
- The diff has come back clean through a full business cycle.
- The last-read date shows no unexpected recent activity.
Then suspend the objects rather than dropping them, for one more cycle:
-- Reversible. A surprise consumer raises an error instead of losing data.
alter task legacy_db.public.load_orders_daily suspend;
alter view legacy_db.public.v_orders rename to v_orders_retired;
A drop destroys the data. A suspend leaves the data in place, so the extra cycle costs only calendar time. A quarterly job nobody documented then fails with a clear error instead of quietly returning nothing. You undo a suspend by resuming the task and renaming the view back. Once a full cycle passes with no surprises, drop the objects and archive the step 1 inventory.
Each dbt Migration Step and How to Know It Worked
| Step | Run this | What proves it worked | Agent value | Human decision |
|---|---|---|---|---|
| 1. Inventory | the ACCESS_HISTORY query above | every object has an owner or an out-of-scope note | ✓ reads and summarizes hundreds of objects | scope and ownership |
| 2. Decide what to cut | dbt ls --select <model>+, then a lineage traversal | each cut object shows zero live consumers | ≈ runs the lineage queries | the risk appetite for deleting |
| 3. Translate | dbt parse, then dbt compile | every model in scope compiles and runs | ✓ this is the mechanical part | the target platform and version |
| 4. Prove parity | the row diff and the aggregate diff, on a full month | zero rows back, or one signed-off reason per mismatch | ≈ explains a mismatch once found | whether a mismatch is acceptable |
| 5. Cut over | both pipelines live, diff scheduled daily | the diff stays clean through a month end | ✗ this is coordination | the order of consumers |
| 6. Decommission | alter task ... suspend | one full cycle of silence | ✗ the agent does not make this call | the decision to drop |
Four Semantic Differences Cause Most Parity Failures
Four causes account for most parity failures, so you can work step 4 as a checklist rather than an open investigation.
| Cause | Why it drifts | How you catch it |
|---|---|---|
| Implicit type coercion | One engine truncates integer division and another keeps decimals, so a ratio changes value and shows up as a rounding difference three models downstream. | The aggregate diff on any SUM that depends on the ratio. |
| Null handling in aggregates | Nulls sort first on one engine and last on another, so a query that keeps one row per group keeps a different row. | The row-level diff, which returns different rows when the sort order flips. |
| Timezone and timestamp semantics | A timestamp with no timezone means different things per platform, and a session timezone setting shifts results with no code change. | The aggregate diff run across a month end, where the boundary rows move. |
| Undocumented business filters | The legacy pipeline hides filters nobody remembers, and cleaning up the translation drops them. | The row-level diff, which returns the extra rows the cleanup let through. |
Null handling drifts on sort order. Engines disagree on whether a null sorts before or after every other value. That disagreement matters in a query that keeps one row per group. A common shape is row_number() over (partition by customer_id order by updated_at) where updated_at holds nulls. Each engine keeps a different row for those groups, and the row-level diff catches it.
Timezone drift starts with a timestamp column that carries no timezone. Each platform decides for itself which clock that value is on. One platform reads it as UTC and another reads it in the session timezone, and a session setting shifts every result with no change to the code.
Take an order placed late on the last day of the month. A date_trunc('month', order_date) moves that order into the next month on one platform and leaves it in place on the other. The two monthly totals differ by exactly the orders in that boundary window. The aggregate diff run across a month end catches it, because those boundary rows move.
Undocumented filters are the hardest of the four causes to spot, because nothing marks them. A legacy pipeline hides three kinds:
- A
WHEREclause that excludes test accounts. - A hardcoded date cutoff nobody remembers setting.
- A customer ID excluded after an incident.
An agent translating faithfully keeps all three filters, which is correct. The numbers break when somebody later tidies the translation and removes them.
Diff a full month rather than a sample, because timezone boundaries and hidden date cutoffs only show up across a month end. Workload migration covers the same ground as a use case.
The four causes of parity failures. Each one changes a number while both engines run the query without an error.
Choose dbt Core V1 or V2
As of August 2026, target dbt Core v1. dbt Labs published the first alpha of dbt Core v2.0 on 1 June 2026. The launch post describes two free distributions of the v2 engine. One is Fusion, a binary that contains some proprietary code, and the other is the Apache 2.0 code in the dbt-core repo. Advanced SQL comprehension, linting and column-level lineage ship in Fusion only. The v2 roadmap says Core v1 "is not going away tomorrow, or any time soon."
dbt Core v1.12 ships the v2 parser behind a flag:
# Run from dbt Core v1.12. If the project parses, v2 becomes a real option.
dbt parse --use-v2-parser
If your project parses under that flag, v2 is a realistic target. If it fails, the errors name the stricter rules you would have to fix first, so you learn the size of the job early. The dbt-autofix package handles much of the mechanical part. Migrating to v1 now and v2 later is two migrations, so pick one and write down why.
Where a dbt Migration With an AI Agent Helps and Where It Does Not
An agent earns its place in the step 1 inventory and the step 3 syntax translation. Steps 4 through 6 are where migrations fail. Those steps need a diff and a human decision, and more generated code does not help there. Run the inventory and the translation unattended, then gate parity on a diff before anything moves. The broader argument for that split is why deterministic tooling beats LLM-only data agents.
Run an aggregate diff against the models you have already translated, before you translate the next one.
Frequently Asked Questions
The translation work is fast, because reading and rewriting SQL is what a model is good at. Proving parity takes nearly as long as it did before agents. You wait for the comparisons to run and for people to sign off on the differences. Plan for the second half to dominate the schedule.
Business-logic parity takes the time. The old pipeline encodes years of decisions nobody documented. Proving that the new pipeline reproduces each one is slow work. Teams that run a diff from week one of a dbt migration with an AI agent find the mismatches earlier.
EXCEPT and FULL OUTER JOIN only work when both sides live on one engine. Bisection hashing works across two engines. It splits both tables into blocks, hashes each block on its own engine, and repeats only where the hashes disagree. Only the checksums move, so the rows stay put.
An agent handles the mechanical translation well, including control flow rewritten as models and tests. An agent cannot tell you whether a procedure's behavior was intentional or a bug somebody worked around downstream. That question needs a diff and a person.
Yes. Compiling proves the target engine accepts the syntax. It says nothing about whether the numbers match. Skipping the diff is the most common false economy in a migration. dbt's built-in tests check for nulls, uniqueness and accepted values, and none of them compares the new numbers with the old ones.
