Monorepo surgery, CI discipline, semantic layers, and cost levers — Discord's petabyte dbt to Datadog's 5,000-person self-serve.
Past a few thousand models, dbt stops being a tool and becomes a platform you engineer: Discord's petabyte-scale surgery on dbt internals, Checkout.com's 27-project CI library, the Airbnb/DoorDash/Netflix semantic-layer canon, and the SQLMesh-era platform decisions.
Four walls past a thousand models: developer isolation and parse speed, the incremental trap on OLAP engines, the guardrail stack, and the dbt/orchestrator boundary.
The pull request is one line: a changed date variable. The developer waits twenty-two minutes for dbt to recompile the project before a single model runs. Meanwhile, in a shared dev schema, her rebuilt dim_users just overwrote a teammate's differently-shaped version mid-demo. And last night, someone's "quick fix" to an incremental model quietly kicked off a full scan of a petabyte table — the third time this quarter. None of these people did anything wrong by the docs. At what scale did the docs stop applying?
dim_users
is_incremental()
One dbt project, one warehouse, a hundred concurrent developers: the first wall is *namespace collision*, and the fix is mechanical once seen. Override generate_alias_name so the same model name resolves per execution context — in dev, the alias appends the developer's username (dim_users__chris_dong); in CI, the PR number and commit hash; in prod, the clean name. Every developer gets an isolated sandbox inside one project, with zero coordination.
generate_alias_name
dim_users__chris_dong
The second wall is *parse time*. dbt's partial parsing caches the compiled project — but any variable change invalidates it, forcing full recompilation. At 2,500+ models that's a 20-minute wait to change a date range. The production-grade workaround is surgery: when only date-window variables change, strategically overwrite the hash inside dbt's partial-parse metadata so dbt believes nothing changed and skips the recompile — unsupported, fragile, and worth 5× on execution. Pair it with the cost-side trick: build dev and test tables by copy-partitioning production partitions instead of re-running transformations — on BigQuery, a metadata copy instead of a paid merge.
🏭 In production at Discord: those mechanisms — alias isolation, the "dbt turbo" hash bypass, copy-partition dev builds — are how 100+ developers operate 2,500+ models over petabytes. The register to notice: at this scale, the platform team patches *dbt's own internals* and accepts the fragility, because a 20-minute compile tax across a hundred engineers is a bigger liability than an unsupported hack.
🤔 Check yourself: Your 40-developer dbt project uses one shared dev schema and developers keep clobbering each other. A teammate proposes one Snowflake schema per developer, configured manually. What does the alias-override approach give you that per-developer schemas don't?
dev
Zero configuration per developer (the alias derives from the execution context automatically), CI isolation for free (PR-hash aliases mean two open PRs never collide either), and one grant surface instead of forty. Manual schemas solve dev collisions but leave CI unsolved and multiply administration — the override makes isolation a *property of the project*, not a convention.
Incremental models are dbt's biggest cost lever and its best-hidden trap, because two of their default mechanics assume an OLTP world.
Trap one: the guard can be the expensive part. is_incremental() patterns typically probe MAX(timestamp) on the target — and on BigQuery that probe can cost a full table scan before any incremental work begins. Trap two: the write path doesn't prune. The delete+insert strategy issues a DELETE matching on unique_key — and a key-only DELETE is a near-full scan *even when deleting ten rows*, because nothing tells the engine which partitions could contain them. One team watched that DELETE time out at 45 minutes on a 2XL warehouse against a 300-billion-row table. Their fix: a forked materialization adding the date column to the DELETE's join, so micro-partition pruning fires before any key matching — bytes scanned fell 97% (15 GB → 431 MB) and the scan stopped spilling to remote storage. The general law, which you met from the warehouse side in that cohort: *the predicate, not the data volume, drives cost* — and dbt's static incremental_predicates can't express per-batch dynamic ranges, which is why custom materializations exist.
MAX(timestamp)
unique_key
incremental_predicates
The deeper critique goes to architecture: dbt is stateless. It keeps no record of which intervals were processed — so a skipped run is a *silent, permanent gap*; retries re-do completed batches; and a ten-year daily backfill is thousands of sequential queries. The new microbatch strategy (event_time, batch_size, lookback) splits runs into per-batch queries and auto-filters upstream refs — real progress — but the state question remains, and production shops answer it in one of three places: the orchestrator (an internal partition-status store with locking as the source of truth, dbt invoked per-partition with variables), the engine (interval-tracking transform frameworks), or nowhere — with humans as the gap detector.
event_time
batch_size
lookback
⚠️ Gotcha: two sharp edges from the field reviews: self-referencing models (running totals, sessionization) need *previous batches of themselves*, forcing sequential execution that naive parallel batching corrupts; and microbatch's automatic event_time filtering of upstream refs surprises models that need unfiltered dimension reads — know the opt-out before the join silently loses history.
🤔 Check yourself: An incremental model's nightly run costs about the same as its full refresh, despite is_incremental() being present and correct. Give the two OLAP-side explanations from this section and the check for each.
(1) The guard's own MAX() probe or the merge/delete's key-only matching is scanning the target — check bytes-scanned attribution in the query plan, and whether the DELETE/merge carries a partition-column predicate. (2) The incremental filter doesn't hit the partition column, so nothing prunes — check the filter column against the table's partitioning. In both cases the SQL *looks* incremental; only the plan tells the truth.
MAX()
A thousand-model project with seventy contributors is an ecosystem, and ecosystems need immune systems. The production pattern is a stack, cheapest checks first.
In CI: Slim CI builds only changed models; convention linters ban hard-coded table references and require tested primary keys on critical models; SQL style enforcement; and — the load-bearing governance — tier rules in CI: every model carries an owner tag and a priority tier, and *a P0 model may not depend on a P1* — enforced by a custom check, because a wiki rule without CI is a wish. At the high end, CI grows teeth the docs never mention: per-PR query-cost analysis, breaking-change detection, and *macro blast-radius analysis* posted as PR comments — because a one-line macro edit can rewrite five hundred models.
At runtime: per-model query cancellation policies derived from baselines (longest known runtime + 1 hour — a global timeout fits nobody), cancellations logged to observability; and a modified macro that refuses any dbt run without an explicit --select, making the accidental full-project run structurally impossible.
--select
Over time: warehouse access history feeds a monthly *unused-model digest* — dead models don't announce themselves — and schema evolution respects storage engine semantics: on Snowflake, CREATE OR REPLACE TABLE silently destroys time travel, so critical-table schema changes go truncate-and-insert through a toolkit that preserves the 90-day history.
CREATE OR REPLACE TABLE
🏭 In production at Whatnot and Discord: the tier enforcement, kill-switches, and time-travel-preserving toolkit are Whatnot's, holding a 4×-grown 1,200+ model project for ~70 developers; the 42-check CI with cost and blast-radius analysis is Discord's. Both learned the same meta-lesson: *governance checks belong in CI hooks, not in documentation.*
🤔 Check yourself: A junior engineer's PR adds orders_summary (tagged P0, feeds finance) reading from experiments_scratch (P2, rebuilt ad hoc). All tests pass. What should stop this merge, and why won't tests ever catch it?
orders_summary
experiments_scratch
The tier-dependency CI check: P0 depending on P2 makes finance's reliability hostage to a scratch model's whims. Tests can't catch it because nothing is *wrong with the data today* — the violation is structural (a reliability contract), visible only in the dependency graph. That's exactly the class of failure lineage-aware CI exists for.
The last scaling wall is deciding what dbt *is*. The clean answer from the largest deployments: dbt owns modeling — SQL, tests, metadata; the orchestrator owns every scheduling decision — when to materialize, partition dependencies, retries, backfill sequencing. The boundary earns its keep on three problems dbt alone can't express: declarative automation (materialize when upstream data changes, not when a cron guesses), partition-grain mismatches (hourly assets feeding daily ones, mapped natively instead of glue code), and backfill atomicity — all partitions of a backfill built from *one code version*, which requires orchestrator-level pinning; dbt has no concept of it.
⚖️ Tradeoff: the reference implementation bet on a newer orchestrator over battle-proven Airflow to get the declarative, partition-aware model — accepting maturity risk and co-developing features with the vendor. Two of their trenches are worth quoting: dbt's temp-table naming race-conditions when the *same model's partitions* run in parallel (they patched the naming), and partition-by-partition backfills were unusable until batch sizes became configurable. The boundary is right; the seams still need welding.
| Concept | One-line mechanism | Number to remember | Production proof |
|---|---|---|---|
| Context aliasing | generate_alias_name per env: username / PR hash / clean | 100+ devs, one project | Discord |
| Parse-time surgery | var changes invalidate partial parsing; hash bypass skips recompile | 20-min waits → 5× faster | Discord "dbt turbo" |
| The incremental trap | guard probes and key-only DELETEs scan; add the partition predicate | 15 GB → 431 MB scanned | Atheon fork; 300B-row table |
| Stateless gaps | no processed-interval record: skipped run = silent hole | 10-year backfill = thousands of serial queries | the microbatch critique |
| Guardrail stack | Slim CI + tier rules + baseline kill-switches + --select required | 1,200+ models, ~70 devs | Whatnot |
| The boundary | dbt models; orchestrator schedules, partitions, pins backfill versions | ~4,000 materializations/day | Discord on Dagster |
dbt-at-scale questions probe whether you've run it as a *platform*. Expect: *"How do 80 engineers share one dbt project?"* (context aliasing, parse-time economics, copy-partition dev builds). *"Your incremental model costs like a full refresh — why?"* (the guard's probe, the key-only DELETE, the partition predicate — read the plan, not the SQL). *"Where does incremental state live?"* (orchestrator store vs engine intervals vs stateless-with-humans; name the silent-gap failure). *"What's in your dbt CI beyond dbt test?"* (conventions, tier enforcement, cost and blast-radius analysis — governance as hooks). *"Split of responsibilities with the orchestrator?"* (modeling vs scheduling, and the three problems that force the boundary: declarative triggers, grain mismatch, backfill version pinning). The senior tell: you talk about parse time, plans, and dependency graphs — the invisible surfaces where dbt scale actually lives.
dbt test