A staging table gets renamed on a Tuesday. Somewhere downstream, an intermediate model still refs the old name. dbt does not compile per-model — it compiles the whole DAG before it runs anything — so one dangling ref does not fail one model. It fails the run. Every mart, every metric, every model that was scheduled to materialize that hour sits untouched, because dbt never got past parsing the graph.
This is a worse failure mode than it sounds like, and it gets more likely, not less, the more of your dbt project an AI agent is generating. An agent writing a new intermediate model reasons from the schema it was shown. If a staging model it depends on was renamed, quarantined, or dropped since that context was gathered, the new model refs something that no longer exists — and dbt will not tell you which one until it hits the missing node during compilation.
The reflex fix doesn't scale
The obvious response is to page someone: dbt run failed, go look at the compile error, delete or fix the offending model, rerun. That works for one orphan. It falls apart the moment a pipeline generates and evolves models continuously, because dbt only ever reports the first missing dependency it finds. Fix that one, recompile, and if a second unrelated model also refs something gone, you get a second failure — a fresh page, a fresh investigation, for a graph that had two problems the whole time.
Multiply that by a materialization run touching hundreds of models a day, generated in part by an agent, and "an engineer manually triages every compile error" is not a monitoring strategy. It's a queue that grows every time someone renames a column.
Quarantine the orphan, not the run
The fix we run in production treats a missing-ref compile error as data, not just a failure signal. When dbt reports a model referencing a node that doesn't exist, the materialization flow checks two things before touching anything: does dbt actually say this ref is missing, and is the referenced node genuinely absent from the project's models, seeds, and snapshots — not just missing from what got synced this run. Only if both hold does the model get quarantined.
Quarantine itself is a soft-delete, and the order matters. The catalog row gets marked deleted first, then local and remote artifacts get cleaned up after. If cleanup fails partway — a GCS timeout, a filesystem error — the catalog already reflects reality instead of silently disagreeing with storage. A model that's gone from the catalog but still sitting in a bucket is a much smaller problem than one the catalog still thinks is live.
Seeds are deliberately exempt from this. A seed can look like an orphan to a naive sweep — nothing else refs it this run — without actually being one; it might be reference data multiple models pull from intermittently. Quarantine only fires on an explicit dbt missing-node error against the project namespace, never a proactive catalog sweep, specifically so seeds don't get soft-deleted for the crime of being quiet.
One orphan is rarely the only orphan
Because dbt surfaces missing refs one at a time, quarantining the first orphan and recompiling can just reveal a second, then a third — a graph can have several disconnected models that all lost their staging dependency in the same rename. The flow loops: quarantine, recompile, check for another missing-ref error, repeat, capped at a fixed number of attempts so a genuinely broken graph doesn't spin forever.
The cap alone isn't enough, though. If a soft-delete quarantines the catalog row but the local SQL file doesn't actually get removed for some reason, dbt will report the exact same (model, missing_ref) pair again next iteration — and a naive loop will burn through its whole attempt budget re-quarantining the same model instead of making progress. The loop tracks which (model, missing_ref) pairs it has already seen and stops as soon as one repeats, rather than treating repetition as still-live progress.
compile → missing ref: int_orders_by_channel → stg_shopify_orders
✓ stg_shopify_orders absent from models/seeds/snapshots
✓ dbt-reported, not a catalog sweep
→ quarantine int_orders_by_channel, recompile
compile → missing ref: fact_revenue_daily → int_orders_by_channel
✓ int_orders_by_channel absent (just quarantined)
→ quarantine fact_revenue_daily, recompile
compile → clean
→ materialize remaining graphWhat survives is the rest of the run. Two orphaned models get pulled out and logged for review; the other few hundred that were never touched by the rename materialize on schedule, on the same run, without anyone opening a terminal.
Fixing the graph doesn't fix the cause
Quarantine keeps the pipeline alive. It does not explain why a model went orphaned in the first place, and that's a separate problem worth being honest about. Sometimes it's a legitimate rename that should have come with a migration for downstream refs. Sometimes it's an agent that generated a model against a schema snapshot that was already stale by the time the model ran. Either way, a quarantined model is a signal, not a resolution — it belongs in front of a person, with the missing ref and the reason attached, even though the run itself didn't wait around for that person to look at it.
The broader shape of the fix applies past dbt: any pipeline where one node's failure can block a graph benefits from treating "this specific piece is broken" as separable from "therefore nothing runs." The trick is doing it without turning quarantine into a place where broken things go to be forgotten — which is why every soft-delete here is loud, logged, and reversible, not a silent skip.
