"Transformations as Code" — dbt Fundamentals
Rohan wants to put the star schema's SQL in a repo and a cron job. Ingrid wants it treated like the order service: modules, a dependency graph, tests, review. Models, sources and refs, materialisations, snapshots, data and unit tests, docs, and dbt v1 vs v2. Built with dbt 1.12 and dbt-trino on Iceberg, it reproduces Part 5's corrected numbers to the penny; dbt-oss 2.0 then runs a slice of it on DuckDB.
Skim Card
One thing to remember: SQL that produces numbers people sign is code: give it modules, a dependency graph, tests in every build, and history it can’t lose.
- A model is one
SELECT;ref()andsource()give dbt the graph, so build order and selection come from the code, not from cron.- Clean each source once in staging (re-sent baskets dropped in
stg_pos_sale_lines); marts only join and aggregate.- Run
dbt build: unit tests before a model, data tests after, everything downstream skipped on failure. Every fact gets a grain test.- A snapshot keeps history the source overwrites, but only as often as it runs: weekly runs lost a promotion version that priced 1,908 lines.
- On Trino and Iceberg,
on_table_exists: replacemakes each build one commit to the same table; earlier builds stay readable by time travel.- The dbt-built star reproduced Part 5 exactly: UK Q3 net revenue £29,790,674.06; stock 91.1% as reported, 88.5% at close.
- dbt v2 has no Trino adapter and parses strictly: dbt-oss 2.0.8 rejected a misspelt key v1 ignored, and bundles DuckDB 1.5.4.
Use … When … dbt-core 1.12 + your engine’s adapter v2 has no GA adapter for the engine (Trino), or the project relies on v1-only behaviour dbt-oss 2.0 The adapter is GA in v2 and you need Apache 2.0; fix every strict-parse error first dbt2.0You want its language server, linting and SQL comprehension, under its proprietary licence A snapshot The source overwrites rows and nothing else keeps the old versions A model over raw history The raw zone already keeps every version (daily extracts, a CDC history table) Verified against: dbt-core 1.12.6, dbt-trino 1.11.0, dbt-duckdb 1.11.1, dbt-oss 2.0.8 (DuckDB 1.5.4 inside), dbt_utils 1.4.1, Trino 484, Apache Polaris 1.8.0, Iceberg format v2 tables, DuckDB 2.0.0 · vendor docs · 28 October 2026
Jump to: Concepts · Hands-On · Landscape · Design Drill
Story Opening
Since Part 4, Larkspur’s star schema has been a Python script running SQL files, in an order only Rohan knows, on one laptop. Farah’s question was short: “Which version of that code produced the Q3 number I signed?”
“Fair,” Rohan said. “I’ll put the SQL files in Git and schedule them with cron.”
“That’s how teams end up with 900 scripts,” Ingrid said. “You wouldn’t ship the order service as scripts and a crontab. Modules, a build that knows the dependencies, tests in every build, review. And someone has to keep the history the promotions tool throws away.”
The Quick Map
| Backend instinct | Data engineering reality | Why it matters |
|---|---|---|
| SQL is glue: keep the scripts and schedule them | Each SELECT is a module; ref() makes the dependency graph | Cron order is a guess that breaks when one job runs long |
| Tests are for application code | Data tests check every build’s rows; unit tests check the logic | A join that drops rows raises no exception |
CREATE TABLE AS is all a build needs | How a model lands (view, table, incremental) is a per-model choice | Rebuilding 11.7 million rows for one new day adds up |
| The source database keeps the history | If the source overwrites, only a snapshot taken in time keeps it | Last month’s promotion terms are gone for good |
Concepts, with use cases
dbt compiles a folder of SQL SELECTs and YAML into a dependency graph and runs it on a warehouse or query engine; it moves no data itself. It is the T in ELT (Part 2).
Models, sources and ref()
A model is one SELECT in one file; dbt wraps it in the DDL for a view or table named after the file. A source declares a table dbt didn’t build (source('raw', 'pos_uploads')); ref('stg_stores') points at another model. From those dbt knows the graph before running anything: build order, what --select +fct_sales_line means (the model and everything upstream), what a change touches.
What breaks without it. Hard-coded table names run in cron’s order, always against production; a second environment means editing every file. Larkspur: 13 sources, 33 models. Elsewhere: a hospital group’s billing marts, where one changed staging model must rebuild exactly the reports that read it.
Layers: staging, intermediate, marts
Staging models are one per source: rename, cast, deduplicate. Intermediate models hold joins and business logic. Marts are what people query: facts and dimensions. Staging and intermediate go to Part 9’s silver namespace, marts to gold.
What breaks without it. Each report repeats the cleaning, slightly differently: three numbers, two answers each. Larkspur: the till re-send fix from Part 6 lives once, in stg_pos_sale_lines, and every fact inherits it. Elsewhere: a SaaS company defining “active subscription” once, upstream of finance’s and product’s dashboards.
Materialisations
How a model lands, chosen per model:
| Materialisation | Builds | Use when |
|---|---|---|
view | A view; nothing stored | Cheap logic read rarely (staging) |
table | The whole result, every run | Small or medium marts; queried often |
incremental | Only new or changed rows, appended or merged | Big facts where a full rebuild wastes time (Part 18) |
ephemeral | Nothing: inlined into downstream models as a CTE | A step no one queries directly |
materialized_view | The engine’s materialised view | The engine refreshes it; Trino 484 refuses them on Iceberg REST catalogs (Part 13) |
dbt also builds snapshots (below) and seeds: small CSVs kept in the repo and loaded as tables, such as an airline’s hand-kept airport-code list (Larkspur needs none: its country map derives from the stores). What breaks if you pick blind. All views, and every dashboard re-runs the chain; all tables, and every build rewrites rows that didn’t change. Larkspur: staging views, intermediate and mart tables; the big facts go incremental in Part 18. Elsewhere: an adtech team that keeps hourly aggregates incremental and the advertiser dimension table.
Tests: data tests and unit tests
A data test is a query that returns the rows breaking a rule; zero rows passes. Generic ones are declared in YAML (unique, not_null, relationships, accepted_values, plus packages); singular ones are a SELECT in tests/. A unit test (dbt 1.8+) feeds a model fixed input rows and compares its output with expected rows: it checks the logic, not today’s data.
What breaks without them. A join to a dimension with a gap silently loses lines; a fan-out double-counts revenue. Neither throws. Larkspur: grain tests on every fact, a no-line-lost test, a re-sent-basket unit test (step 4). Elsewhere: an insurer asserting one row per claim per day before the reserving model reads it.
Snapshots: keeping history the source throws away
A snapshot is dbt’s Type 2 table (Part 4): each run compares the source’s current rows with the latest version of each key and closes a changed row (dbt_valid_to) as it inserts the new one. The timestamp strategy trusts an updated_at column; check compares listed columns.
What breaks without it. If the source overwrites in place, yesterday’s value exists nowhere else, and a snapshot can’t be rebuilt later (Gotcha). Larkspur: the catalogue lands a full extract daily, so dim_product is rebuilt from raw as a model; the promotions tool overwrites edits, so dim_promotion reads a snapshot. Elsewhere: a telecom’s tariff table, overwritten when a plan’s price changes.
Docs, packages, macros
dbt docs generate turns the graph, column descriptions and types into a static site. Packages are other people’s projects (dbt_utils 1.4.1 here). Macros are Jinja functions: use them where dialects differ, not to generate SQL nobody can read. What breaks without them. Analysts ask in chat what a column means; every team rewrites the same grain test. Larkspur: the column descriptions Farah’s analysts read, and local_date(), which lets one model file run on Trino and DuckDB (step 6). Elsewhere: a bank feeding its data catalogue from dbt’s docs artefacts.
dbt v1 and v2
dbt v2, formerly the dbt Fusion engine, is a rewrite in Rust, generally available since 14 September 2026. It ships as two distributions: dbt, proprietary under the dbt Product Licensing Agreement (adds SQL comprehension, linting and LSP support), and dbt-oss, Apache 2.0. Adapters are built in: GA for Snowflake, BigQuery, Databricks, Redshift and DuckDB (CLI only), Spark (beta, CLI only), ClickHouse (private beta). Trino isn’t listed. v2 parses strictly: a key v1 silently ignores is an error. dbt Core v1 stays Apache 2.0 and supported (1.12 has active support to July 2027).
So this series runs dbt-core 1.12 with dbt-trino for the lakehouse and shows dbt-oss with DuckDB alongside (step 6). dbt-trino 1.11.0’s changelog names dbt-core 1.11, and its pin (>=1.11.2,<2.0) allows 1.12: it ran this whole project on 1.12.6 without a problem. If v2 gains a GA Trino adapter, that changes.
The company changed too: Fivetran and dbt Labs announced their merger on 13 October 2025 and completed it on 1 June 2026, “initially operating as Fivetran + dbt Labs”. dbt Labs wrote that day that dbt Core remains Apache 2.0.
Under the Hood
What dbt build does
- Parse. Read every
.sqland.yml, render the Jinja far enough to findref,sourceandconfig, and write the graph totarget/manifest.json. - Compile. Render each node’s SQL for the target (
profiles.yml):ref('stg_stores')becomeslakehouse_pipeline.silver.stg_stores. - Execute in graph order,
threadsat a time. Indbt build, a model’s unit tests run before it, its data tests after it, and a failure skips everything downstream, so a broken grain never reaches the marts people read.dbt runthendbt testbuilds everything first: usebuild.
A table on Iceberg through Trino
dbt-trino’s default for table is on_table_exists: rename: create a temporary table, rename the live table to a backup, rename the new one in, drop the backup. That drop is the weak step here: Trino drops Iceberg tables with purgeRequested=true, and Polaris refuses a purge unless the catalog allows it. The project sets on_table_exists: replace, so each build is one CREATE OR REPLACE TABLE: a single Iceberg commit on the same table, which keeps its snapshot history, so earlier builds stay readable by time travel (step 5).
Views are Iceberg views in Polaris. Snapshots MERGE each run’s changes through a staging table they create and drop, and that drop needs purging, so the lab sets polaris.config.drop-with-purge.enabled (what that costs: Gotcha).
Hands-On
The code is in the every-record-counts folder of the companion-workspaces repository (tag every-record-counts/part-17): the project in dbt/larkspur/, a DuckDB slice of it in dbt/larkspur_subset/, the driver in scripts/part17_dbt.py. Only core and Trino run. The commands, in step order:
make up-core && make up-trinomake dbt-installuv run python -m scripts.part17_dbt setupuv run python -m scripts.part17_dbt landuv run python -m scripts.part17_dbt dbt builduv run python -m scripts.part17_dbt replay --every 7uv run python -m scripts.part17_dbt historyuv run python -m scripts.part17_dbt replayuv run python -m scripts.part17_dbt historyuv run python -m scripts.part17_dbt dbt builduv run python -m scripts.part17_dbt unit-demouv run python -m scripts.part17_dbt checkuv run python -m scripts.part17_dbt buildsuv run python -m scripts.part17_dbt dbt docs generatemake dbt-oss-installuv run python -m scripts.part17_dbt dbt parse --target localuv run python -m scripts.part17_dbt v2 parse --target localuv run python -m scripts.part17_dbt strictuv run python -m scripts.part17_dbt extract && uv run python -m scripts.part17_dbt subset-v2 debuguv run python -m scripts.part17_dbt extract v1.5.0 && uv run python -m scripts.part17_dbt subset-v2 builduv run python -m scripts.part17_dbt subset-v2 show --inline 'select version() as duckdb_version'uv run python -m scripts.part17_dbt subset builduv run python -m scripts.part17_dbt timingStep 1: who dbt runs as, and what it reads
dbt connects to Trino through a new catalog file, lakehouse_pipeline: Polaris as Part 9’s pipeline principal, with vended storage credentials and no S3 key. setup grants what dbt needs beyond writing tables and sets the purge property from Under the Hood:
pipeline principal: + catalog role transformer on silver, gold: NAMESPACE_READ_PROPERTIES, TABLE_READ_PROPERTIES, TABLE_WRITE_PROPERTIES, VIEW_CREATE, VIEW_DROP, VIEW_LIST, VIEW_READ_PROPERTIES, VIEW_WRITE_PROPERTIEScatalog larkspur: polaris.config.drop-with-purge.enabled = trueThe sources are raw tables plus Part 15’s checked silver.supplier_deliveries: Part 16’s CDC replicas of orders and refunds, and ten more that land copies from the raw zone in 9 s, including all 2,338,812 till events, re-sends and all. The profile, dbt/larkspur/profiles.yml (trimmed: host, port, user, http_scheme):
larkspur: target: lake outputs: lake: type: trino # the dbt-trino adapter method: none # no authentication in the lab; the catalog carries the identity database: lakehouse_pipeline # in dbt-trino, the Trino catalog schema: silver # default namespace; models name theirs (a small macro stops "silver_gold") timezone: UTC # the session's zone; without it, the laptop's (Gotcha) threads: 2 # models built in parallel (two: the lab Trino has a 2.4 GB heap)Step 2: the models
dbt_project.yml gives each layer its namespace and materialisation, and every model +on_table_exists: replace. The re-send fix, models/staging/stg_pos_sale_lines.sql (comment and one column trimmed):
with first_upload as ( select event_id from ( select event_id, row_number() over (partition by basket_id order by uploaded_at, event_id) as upload_rank from {{ source('raw', 'pos_uploads') }} ) ranked where upload_rank = 1)select 'pos' as channel, b.basket_id as transaction_id, l.line_no, b.store_id, b.currency, b.event_ts, b.tenders[1]."type" as tender_type, l.sku, l.qty, l.unit_price_minor, l.discount_minor, l.promo_id, l.qty * l.unit_price_minor - l.discount_minor as amount_minorfrom {{ source('raw', 'pos_uploads') }} bjoin first_upload f on f.event_id = b.event_idcross join unnest(b.lines) as l (line_no, sku, qty, unit_price_minor, discount_minor, promo_id)Ranking whole events, nested lines and all, ran past the lab Trino’s 739 MB per-node query memory limit. The other models translate Parts 4 and 5 into Trino SQL, query for query. Two changes: surrogate keys are dbt_utils.generate_surrogate_key hashes, the same on every rebuild (Part 18’s incremental facts need that), and what Part 4 did with UPDATEs (placeholder products, the delivery milestone) a rebuilt model computes in one pass. A first dbt build creates everything; its snapshots, having seen a single morning, are rebuilt in step 3.
Step 3: snapshots, every morning or every week
snapshots/snapshots.yml (descriptions, comments and the product snapshot trimmed; it uses strategy: check over six attributes with updated_at: extracted_at):
snapshots: - name: snp_promotion relation: ref('stg_promotions_on_morning') config: unique_key: promo_id strategy: timestamp updated_at: updated_at dbt_valid_to_current: "timestamp '9999-12-31 00:00:00.000000 UTC'"stg_promotions_on_morning is what the tool shows at 06:00 UTC on var('as_of') (the generator kept the history, so the lab can play any morning back). replay runs dbt build on both snapshots and their views once per morning, 1 June to 4 October, through dbtRunner:
126 runs, every 1 day(s) from 2026-06-01: 243 s (1.9 s per run) first run creates the tables: snp_product 1,867 rows, snp_promotion 1 later runs that merged anything: 90 of 125 (245 product and 77 promotion rows)snapshot rows keys closedsnp_promotion 63 48 15snp_product 2,056 2,000 56snapshot vs extracts 2,056 vs 2,056 versions: 0 only in the snapshot, 0 only in the sourcesnapshot vs admin history 63 vs 63 versions: 0 only in the snapshot, 0 only in the sourceDaily runs rebuild both histories exactly: Part 4’s 2,056 product versions and the tool’s 63 promotion versions. Weekly (--every 7, 18 runs), they didn’t:
snapshot vs extracts 2,054 vs 2,056 versions: 171 only in the snapshot, 173 only in the sourcesnapshot vs admin history 62 vs 63 versions: 0 only in the snapshot, 1 only in the sourcenever seen: PR-0045 v1, in force 2026-07-01 16:00:00.000000 UTC to 2026-07-05 18:00:00.000000 UTC, priced 1,908 sale linesPR-0045 was created at 20% and edited to 25% four days later, between two weekly runs: version 1 exists nowhere, and its 1,908 sale lines would join to no promotion. Two product versions vanished the same way; 171 are dated by the run that noticed them, up to six days late. A snapshot only knows what was there when it looked.
Step 4: tests, in the build
Grain tests sit next to the models in models/marts/_marts.yml (excerpt, description trimmed):
- name: fct_sales_line data_tests: - dbt_utils.unique_combination_of_columns: arguments: combination_of_columns: [transaction_id, line_no]Every fact has one, and relationships tests tie the sales fact’s keys to their dimensions. fct_stock_daily’s grain test groups by a 64-bit hash of its keys (tests/generic/unique_combination_hashed.sql): two 32-character keys on 11.7 million rows ran out of memory. Singular tests check that no sale line or penny is lost between int_sales_lines and the fact, and that each product’s versions tile time. The unit test feeds stg_pos_sale_lines three events, two of them one basket (models/staging/_staging.yml, description and the fixture’s rows trimmed):
unit_tests: - name: a_resent_basket_counts_once model: stg_pos_sale_lines given: - input: source('raw', 'pos_uploads') format: sql expect: rows: - {transaction_id: B1, line_no: 1, sku: SKU-00001, amount_minor: 300, has_loyalty_card: false} - {transaction_id: B2, line_no: 1, sku: SKU-00002, amount_minor: 450, has_loyalty_card: true}dbt build (lines trimmed):
18:03:38 Found 33 models, 30 data tests, 2 snapshots, 13 sources, 637 macros, 2 unit tests18:03:39 17 of 67 PASS stg_pos_sale_lines::a_resent_basket_counts_once .................. [PASS in 0.27s]18:04:40 Finished running 2 snapshots, 18 table models, 30 data tests, 2 unit tests, 15 view models in 0 hours 1 minutes and 2.17 seconds (62.17s).18:04:40 Done. PASS=67 WARN=0 ERROR=0 SKIP=0 NO-OP=0 REUSED=0 TOTAL=67A minute, mostly the three tables of 6.7 and 11.7 million rows (15–18 s each). unit-demo deletes the join first_upload line and runs the unit test (output trimmed):
18:04:43 1 of 1 FAIL 1 stg_pos_sale_lines::a_resent_basket_counts_once .................. [FAIL 1 in 0.44s]actual differs from expected:@@ ,transaction_id,line_no,sku ,amount_minor,has_loyalty_card ,B1 ,1 ,SKU-00001,300 ,False+++,B1 ,1 ,SKU-00001,300 ,false ,B2 ,1 ,SKU-00002,450 ,TrueThe +++ row is basket B1 again: the defect behind £118,076.98 of the 0.7%, caught in under half a second on three fixture rows.
Step 5: the same numbers as Part 5
check counts every table, compares the product and promotion version each sale line points at with Part 5’s DuckDB star, and runs sql/part17/check.sql as the analyst (dimension rows and the fill-rate line trimmed):
table dbt on Trino Part 5 starfct_sales_line 6,658,738 6,658,738 samefct_basket 2,506,704 2,506,704 samefct_refund 63,780 63,780 samefct_stock_daily 11,659,200 11,659,200 samefct_stock_adjustment 41,278 41,278 samefct_po_line 24,657 24,657 same...sale lines per (product version, promotion version): 7,040 groups, sameUK net revenue, Q3, GBP 29,790,674.06 manifest 29,790,674.06 exactstock available, as reported 0.910509 manifest 0.910509stock available, at close 0.885037 manifest 0.885037£29,790,674.06, Part 5’s corrected UK net revenue to the penny, and stock at 91.1% as reported and 88.5% at close. builds lists gold.fct_sales_line’s Iceberg snapshots, one per build (eight today, most by test runs); FOR VERSION AS OF the first still returns 6,658,738 rows. dbt docs generate then took 4 s.
Step 6: the slice on dbt v2
First, the whole project under dbt-oss 2.0.8:
$ uv run python -m scripts.part17_dbt v2 parse --target local[error] [SerializationError (dbt1013)]: Invalid model definition `larkspur.+on_table_exists`: Unrecognized key `larkspur.+on_table_exists`. Custom keys must go under `+meta`. --> dbt_project.yml:18:23v2 has no Trino adapter, so a Trino setting is an unknown key, and unknown keys are errors; dbt-core 1.12 parsed the same project against the same target silently. strict shows why that silence matters, on a temporary copy of the slice with one British spelling added:
v1 parse, with `+materialised: view` exit 0 no error, no warningv2 parse, with `+materialised: view` exit 1 [error] [SerializationError (dbt1013)]: Invalid model definition `larkspur_subset.+materialised`: Unrecognizedv2 parse --no-partial-parse exit 0 no error, no warningv2 run -m dim_date exit 0 no error, no warningv1 builds tables while the author thinks they asked for views. The last two lines go the other way: per the upgrade guide, -m should error and --no-partial-parse should warn (dbt1700); 2.0.8 did neither. A Trino project can’t be half-moved to v2 today.
So dbt/larkspur_subset/ symlinks the eight portable models and builds them on data/lab/part17/extract.duckdb, three raw tables copied from the lake: v2’s built-in DuckDB driver can’t load httpfs or iceberg without a separately installed (dbc) driver. The first extract, written by DuckDB 2.0:
IO Error: Trying to read a database file with version number 999, but we can only read versions between 64 and 68.dbt-oss bundles DuckDB v1.5.4 (select version() via dbt show; undocumented), which can’t open 2.0 files. Written with STORAGE_VERSION 'v1.5.0', the file works, and both engines build the slice with its six data tests and unit test. timing, three runs each:
| Command | dbt-core 1.12 + dbt-duckdb | dbt-oss 2.0.8 |
|---|---|---|
Start-up only (dbt --help) | 0.75 s | 0.08 s |
| Parse the slice | 1.93 s | 0.27 s |
| Build the slice (8 models, 7 tests) | 1.69 s | 0.55 s |
(Medians; v1 parsed the full 33-model project in 2.23 s.) At this size most of v1’s time is fixed cost (0.75 s before any work); the queries take milliseconds in both. Big projects are where v2’s speed is claimed to matter; here, strict parsing is what you can count on.
The whole sequence peaked at 4.1 GiB of containers (Trino at its 3 GB limit) on a MacBook Pro M3 Pro with a 9.8 GB Docker VM; tests/test_part17.py replays it all and asserts the counts, versions and money above.
The Landscape
As of 28 October 2026, from each vendor’s docs:
| Need | Open source | AWS | GCP | Azure | Snowflake / Databricks | Notes |
|---|---|---|---|---|---|---|
| SQL transformations as code: models, dependencies, tests | dbt Core v1 (Apache 2.0); dbt-oss v2 (Apache 2.0); SQLMesh (Apache 2.0, a Linux Foundation project since March 2026) | not verified (2026-10-28) | Dataform (GA) | dbt job in Fabric Data Factory (preview), dbt-core 1.11 | dbt Projects on Snowflake (GA; dbt Core 1.12 and v2 versions listed); dbt task in Lakeflow Jobs (Databricks, dbt Core) | dbt platform (formerly dbt Cloud) runs v1 or v2 hosted. |
Tips & Gotchas
Gotcha — the session time zone came from the laptop. Without
timezonein the profile, the Trino client sends the machine’s zone. The first replay ran on an IST laptop, andCAST(snapshot_date AS timestamp(6) with time zone)dated all 2,056 product versions 18:30 UTC the day before. Any cast between dates and zoned timestamps depends on it: settimezone: UTC.
Gotcha — a snapshot table is a source, not a model. dbt can rebuild every model from raw, never a snapshot: what it recorded exists nowhere else. dbt’s
--full-refreshleaves snapshots alone; the danger is a manualDROPto “start clean” (the lab’s replay does it only because it holds the history), which with purging on deletes the files too. Restrict who can drop them, and keep Iceberg snapshots long enough to undo a bad run.
Gotcha —
dbt-core,dbtanddbt-ossall install a command calleddbt. The docs say v2’s two distributions can’t share a Python environment; the lab keeps dbt-oss in.venv-dbt2.
Gotcha — purge on drop is catalog-wide. On Trino and Polaris, dbt snapshots need
polaris.config.drop-with-purge.enabled(their staging-table drop is a purge), and then any principal withTABLE_DROPdeletes a table’s files for good. Contain it with privileges: dbt’s principal getsTABLE_DROPonsilverandgoldonly, the CDC writer has its own principal forraw, and no human role can drop snapshot tables. The lab’s singlepipelineprincipal holds all three namespaces.
Tip — unit tests belong in development and CI. Their inputs are fixed, so running them in the production job proves nothing new; dbt’s docs recommend excluding them there (
--exclude-resource-type unit_test).
Troubleshooting
| Symptom | Likely cause | How to confirm | Fix |
|---|---|---|---|
Trino: Failed to load metadata for namespace 'silver' on CREATE TABLE AS | The principal can write tables but not read the namespace’s properties | The same statement through the root principal’s catalog (lakehouse) works | Grant NAMESPACE_READ_PROPERTIES on the namespace (step 1) |
Trino exits: OutOfMemoryError: Java heap space, after several replays and builds | Finished queries stay in history 15 minutes (query.min-expire-age) and a replay sends thousands; the image sets the coordinator’s in-heap metadata cache to 2 GB of a 2.4 GB heap | jcmd 1 GC.class_histogram: byte arrays and query statistics on top | query.min-expire-age=2m; the metadata cache back to its default (2% of heap); dbt threads: 2 |
Design Drill
The question: “A pharmaceutical company’s analytics team runs 900 SQL scripts from cron on one warehouse: sales by territory, distributor stock, batch-release lead times, and four reports for a regulator. Nobody knows the dependency order; when a script runs long, the next ones read half-built tables. Plan the migration.”
Clarify first
- Which outputs have consumers? (Query logs answer this, not the script folder.)
- Which regulatory reports must be reproducible as filed?
- Is the warehouse staying, or is this also an engine move? (One at a time.)
- Who owns models afterwards: a platform team or the analysts who wrote the scripts?
The shape of a strong answer
- Inventory first: the tables each script reads and writes (query history, or parsing the SQL) give the real graph; outputs nobody queried in 90 days are retired, not migrated.
- dbt Core 1.12 on the warehouse’s adapter (v2 if its adapter is GA); one repository, review on every change, CI building changed models and their children; dev and prod targets in separate schemas (Part 18).
- The half-built tables go away twice over:
ref()orders the runs, so nothing reads a model before it’s built, and each table is replaced in one atomic swap (or a whole schema is built aside and switched in, blue-green), so readers see the old version or the new one, never half. - Sources for tables no script creates, one staging model per source, shared logic in intermediate models.
- Migrate subgraph by subgraph, most-used reports first; each model runs beside its script until a parity test (counts, key sets, sums per day and territory,
EXCEPTboth ways) shows zero differences for an agreed period. - Grain,
not_nullandrelationshipstests on every mart from day one; regulatory reports from tagged releases, with the manifest and run results archived per filing. - A snapshot on day one for every source that overwrites history (price lists, territory assignments).
- An orchestrator calls
dbt build(Part 19); cron goes with the last script.
Trade-offs to say out loud
- One script per model is fast and keeps the duplication; shift first, refactor behind parity tests.
- Full-table models are simple and slow; big facts go incremental after parity (Part 18).
- Running both side by side doubles warehouse cost for weeks: budget for it.
Common weak answers
- Translating 900 files mechanically, with no inventory.
- No parity testing: “the dbt version looks right”.
- Forgetting overwritten source history and the regulator’s need to reproduce a past filing.
Further Reading
- dbt Labs, “Add snapshots to your DAG” (strategies,
hard_deletes,dbt_valid_to_current). https://docs.getdbt.com/docs/build/snapshots - dbt Labs, “Unit tests”. https://docs.getdbt.com/docs/build/unit-tests
- dbt Labs, “Starburst/Trino configurations” (
on_table_exists, incremental strategies, contracts, snapshots on Iceberg). https://docs.getdbt.com/reference/resource-configs/trino-configs - dbt Labs, “Upgrading to dbt v2” (strict parsing, removed features). https://docs.getdbt.com/docs/dbt-versions/dbt-upgrade/upgrading-to-v2
- Ralph Kimball and Margy Ross, The Data Warehouse Toolkit, 3rd edition (Wiley, 2013), chapter 5 on slowly changing dimensions.
Story Closing
Farah asked again. Rohan showed Farah a commit, the manifest that build wrote, and the sales fact’s Iceberg snapshots. “The Q3 number is that commit, built on that day. Change the code and the tests run before anything you read changes.”
Of the five promises from Part 2, this part strengthens correctness: Season 1’s corrected numbers now come from reviewed code with its tests in the build, not from a script on one laptop.
“Now look at the build log,” Ingrid said. “A minute, mostly rewriting 18 million rows that didn’t change to add one day. And a late till upload still has to land in the right day.”
In Part 18, the big facts go incremental, a local CI target builds only what a change touches, and dev and prod stop sharing one namespace.
This is Part 17 of “Every Record Counts: Data Engineering for Backend Engineers” — Season 2: Pipelines in Production.