← Notebook
Lab Notes · No. 01
Migration · Experiment Report

Porting 65 dbt models to Snowflake: zero model changes, parity proven

How much of a technology migration is judgment, and how much is labor? This experiment ports a working DuckDB analytics platform to Snowflake, proves the output equivalent to the source row by row, and counts which parts actually needed a person. Four did.

Date
2026-06-21
Project
Gazetteer (public data)
Stack
dbt · DuckDB · Snowflake · AutomateDV
Verdict
Prediction held

What I predicted, before running it

A migration is a good test case for how far an AI-assisted workflow can carry a task on its own, because all the ambiguity is already gone. The source platform is a complete, executable statement of the right answer: the Snowflake version should compute exactly what DuckDB already computes. The target is documented and finite. Nothing in the middle is a research question.

So my prediction was specific enough to be wrong. If what historically made migrations expensive was line-by-line translation labor, then this port should come out mostly autonomous, the gaps that surfaced should be mechanical rather than structural, and the result should be provably equivalent to the source rather than merely plausible. If instead the gaps forced redesign decisions, the prediction fails and the interesting constraint is somewhere else.

Method

A faithful port, deliberately. No re-architecture, no improvements in flight. The scope was the platinum-marts DAG: 65 models across bronze, silver (raw vault), gold (business vault plus dimensional), and platinum. Orphan models nothing downstream consumed were deferred rather than ported, and the tests attached to them were excluded rather than counted as failures.

Bootstrap on the Snowflake side was four scripts: account and schema setup, an external stage with a Parquet file format, a PUT per source across 16 mappings, and an INFER_SCHEMA plus COPY per source. Runtime was the local dbt-snowflake adapter against a trial account over key-pair auth.

Equivalence was not judged by eye. A match contract ran inside Snowflake at three tiers: grain, then typed content row by row, then a raw-vault spot check. Matching is on natural keys and content, never on hashes, because the two engines produce different hash representations for identical inputs and a hash comparison would report a difference that is not one.

Results

Measure Result
Build on Snowflake 65 models, 468 tests, 0 errors, 0 failures
Files changed under dbt/models/ 0
Parity, grain PASS
Parity, typed content PASS — 0 mismatches across 180,533 mart rows and 3,222 dimension rows
Parity, raw-vault spot check PASS — 5 of 5 tables exact
Dialect gaps surfaced 4, none of which changed output

The number that surprised me was zero. Not one file under dbt/models/ was modified. The DuckDB DAG and the Snowflake DAG are byte-identical. Every portability problem got fixed outside the models, in bootstrap SQL or in a single dispatched macro.

That is a result about how the platform was built rather than about either engine. The models encode business logic; the dialect-specific parts had already been pushed to the edges, into adapter dispatch and source resolution. So the surface that had to move was the plumbing, and the logic stayed put.

The four gaps

Every gap is listed, including the one the first fix caused. The last column is the one that matters: a gap that changes output is a redesign problem wearing a syntax costume, and none of these did.

# Symptom Cause Fix Output changed
1 18 bronze models failed with invalid identifier "GEOFIPS" INFER_SCHEMA preserved the Parquet files' lowercase column names as case-sensitive identifiers. The models reference them unquoted, so the parser uppercases the reference and the match fails. Uppercase the column names in the INFER_SCHEMA template. Bootstrap side. No
2 One table broke because of fix 1 Its source columns contain spaces, so they must be quoted, and quoting makes them case-sensitive. The blanket uppercase rule broke the one table it could not apply to. Exempt that table and keep its native case. No
3 dim_date would not build Snowflake's GENERATOR requires a constant rowcount and rejects the date expression the DuckDB branch used. Rewrite the Snowflake branch of the date-spine macro as a fixed span bounded by a WHERE. Output-identical; DuckDB branch untouched. No
4 Selection and seed artifacts Two objects needed explicit selection, and 10 foreign-key tests pointed at the orphan models deferred from scope. Explicit selection; excluded the tests belonging to models deliberately not ported. No

Two of the gaps were domain lessons

Gap 1 had a correct-looking mechanical fix and a better one. Snowflake stores identifiers uppercase by default, and mixed-case identifiers degrade micro-partition pruning, which is a large part of what makes Snowflake fast. So uppercasing was not just the way to make the error go away; it was the native choice for the target. DuckDB never cared about case. The rule I took from it: port toward the target's grain rather than carrying the source's habits across.

Gap 2 is the correction to gap 1. One table cannot take the uppercase rule, and a blanket rule would have quietly broken it. Migration rules are almost uniform, and the residue is where the judgment lives.

What it cost

One focused session, start to finish: re-entry, probes, scope, green build, parity proven, wrap. Infrastructure cost was a Snowflake trial credit against an extra-small warehouse on a small dataset, which rounds to nothing at this size.

The dominant cost was neither the porting nor the compute. It was review bandwidth: reading diffs, making the case-versus- pruning call, deciding which orphan models to leave behind. Four decisions genuinely needed a person. Everything else was mechanical translation that ran on its own.

That ratio is the finding I care about. A handful of judgment calls sitting on top of a large volume of autonomous translation means the throughput limit is how fast a human can verify, and staffing the work as though translation were the bottleneck would size it wrong.

What this does not prove

Scope limits

This was a lift and shift on public data at small scale. It is not evidence for any of the following, and I have not run them:

  • Re-architecting during the port. Changing the shape while proving the new shape equivalent to the old is the harder problem, and it is where a match contract earns its keep. Here nothing changed shape, so the contract was never stressed.
  • Recomputed rather than ported outputs. One model's forecast was carried across rather than recomputed on the target, so a real divergence path went untested.
  • Scale and sensitivity. 65 models of public data with no regulated content and no downstream consumers. Nothing here speaks to how the approach behaves at ten times the model count, under actuarial tolerances, or with protected data in the pipeline.
  • Rebuilding from the specification alone. The source code was available throughout. Whether the spec by itself is sufficient to reproduce the platform is a separate experiment.

What I would do differently

The one thing that aged badly was a runtime decision made during scoping. By the time the build ran, the researched choice was stale and got revised mid-flight. Cheap to fix, but it says something about sequencing: research done at scoping time has a shelf life, and the build report rather than the spec ends up holding the corrected truth.

Next time I would date the technology choices in the spec and re-check them at build time rather than trusting them because they were written down.

Receipts

The full build history, feature specifications, structured build reports, bootstrap SQL, and the parity contract live in the project repository. The data-quality page renders the three reconciliation tiers directly from the build reports rather than from anything hand-written.
gazetteer.danbrickey.dev