Most dbt projects we inherit work. They build green, the dashboards render, and the team can usually explain what each model does in a meeting. But when we trace the invoices for warehouse spend, the on-call pages, and the analyst hours lost to “why is this number different now,” the same handful of structural mistakes show up. Here are six we see almost every engagement, and what we replace them with.
1. One mart to rule them all
Somewhere in models/marts/ lives a 1,400-line fct_orders with sixty-eight columns, joining a dozen dimensions, scoring three customer-lifetime metrics, and computing finance-flavored revenue alongside marketing-flavored revenue. Every team consumes it. Every team has carved out a CASE branch for their definition. Every change requires a Slack negotiation.
Monolithic marts collapse three failure modes into one model: they entangle definitions across functional domains, they force a single grain on consumers who need different ones, and they make the cost of any change proportional to the entire downstream surface. The fix is not “refactor into smaller marts.” It is to introduce a deliberate seam between domain marts (one team owns it, one grain, one definition) and presentation models that combine them for specific consumers. When finance and marketing disagree on revenue, that is a contract negotiation between two domain marts, not a CASE statement.
2. Reusable macros that hide failure
The macro library starts innocently — dbt_utils.surrogate_key, a date-spine helper, a soft-delete filter. Then someone writes safe_divide(a, b) that returns NULL on zero, and coalesce_to_unknown(col) that turns NULLs into the string 'unknown', and safely_cast_to_numeric(x) that swallows cast errors. Six months later the team is debugging a metric that has been silently wrong for a quarter because three layers of “safe” macros absorbed the signal that a join key was malformed.
Macros are not a place to hide failure modes. They are a place to standardize them. A macro that swallows errors should be quarantined to one or two intentional cases (e.g., division-by-zero in a ratio dashboard) and named to make the swallowing explicit (ratio_or_null, not safe_divide). Anywhere else, the macro should propagate the failure, and the model author should make a deliberate choice — a test, a contract, a default — at the call site.
3. Ambient tests
Every model has not_null on its primary key and unique on its surrogate key. The test suite has 4,200 assertions and a 92 percent pass rate that nobody investigates because the 8 percent has been failing for so long it is treated as background noise. This is ambient testing: tests exist, but they are not load-bearing.
Ambient tests are worse than no tests, because they create the appearance of coverage. The remediation is to treat the test suite as a contract: every failing test is either fixed, paged on, or deleted within a sprint. We track a “stale failure” metric — tests failing for more than seven days — and treat any non-zero value as a release blocker. This forces the team to either invest in the test or admit the assertion was never meaningful.
4. Over-incremental
Incremental models are a performance optimization that introduces correctness obligations. Late-arriving rows, deletes, schema changes, and backfills all become the modeler’s problem. We routinely find projects where 40 percent of the warehouse models are incremental, half of them save under a minute of build time, and a quarter of them have known correctness drift the team has stopped chasing.
The default should be full-refresh. Make a model incremental only when (a) the build cost is genuinely material, (b) the late-data semantics are well-defined, and (c) the team has a runbook for backfills. Everything else — including most fact tables under ten million rows — is cheaper to rebuild than to maintain incrementally. If your warehouse compute bill is the bottleneck, the answer is usually a smaller mart graph, not more incrementality.
5. Ref-graph spaghetti
dbt ls --select +fct_revenue returns 87 models. The DAG visualization looks like a hairball. Staging models reference other staging models. Marts reach across domains to grab a column from a sibling mart. A schema change in a single source table touches a quarter of the project.
The fix is layer discipline. Staging is a one-to-one rename and typing layer over sources, full stop — no joins, no business logic, no inter-staging references. Intermediate is where joins and grain shifts happen, scoped to a single domain. Marts publish. Cross-domain reuse goes through marts, not laterally through intermediates. We enforce this with a dbt-project-evaluator configuration that fails CI on layer violations, plus a graph-complexity budget (max fan-out per model, max depth from source).
6. Inconsistent surrogate-key strategy
Half the marts use dbt_utils.generate_surrogate_key(['col_a', 'col_b']). The other half use MD5(CONCAT(...)) written by hand four years ago, with subtly different NULL handling. A new analyst joins two of these models and gets a 0.3 percent join miss because the conventions disagree on how NULLs and casts behave. The miss is small enough to escape the not-null tests, large enough to corrupt cohort metrics.
Surrogate-key generation needs to be a single, project-wide convention, applied without exception. Pick one — we default to dbt_utils.generate_surrogate_key with explicit pre-cast macros for non-string columns — document it in the project README, and lint for it. Any handwritten hash in the codebase is a finding. The cost of this discipline is low. The cost of finding out two years in that your customer dimension has been silently dropping the 0.3 percent of rows where email IS NULL is enormous.
Pattern behind the patterns
These six are different surfaces of the same underlying failure: dbt’s flexibility lets teams encode local convenience that becomes a global constraint. The work of an analytics engineering function is to keep that local-to-global pressure visible — through layer rules, contracts, key conventions, and a test suite that pages — so the project compounds rather than accretes. We have yet to find a shortcut.