Airflow + dbt: The 200-Model Run That Hit the Timeout
Airflow + dbt integration done right: task-per-model vs mapped runs, Airflow 3.0 dbt operators, and the timeout pattern that breaks monolithic dbt runs..
20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.
- ✓A working Airflow environment (see the getting-started guide in this series).
- ✓A dbt project with models and at least one test.
- ✓TaskFlow basics: @task decorators and
expand()for task mapping.
- Airflow orchestrates dbt: scheduling, retries, and monitoring live in Airflow, transformation stays in dbt.
- Two integration patterns: one dbt run per task, or task mapping that fans out one task per model.
- Airflow 3.0 ships first-party dbt operators; older setups used DbtRunOperator from the airflow-dbt plugin.
- A monolithic dbt run of 200 models blows past task timeouts; the mapped pattern isolates the slow model.
- Production rule: dbt tests and freshness checks run as Airflow tasks with their own retry and alert policy.
- Managed dbt Cloud jobs run via the provider's DbtCloudRunJobOperator with wait_for_termination and deferrable modes; Cosmos turns dbt Core projects into Airflow tasks.
Think of dbt as the engine that transforms your data and Airflow as the air traffic controller that decides when each transformation takes off. If you pack all 200 models into one giant dbt run, you're flying one jumbo jet that can't land until the slowest passenger gets off. Split it into per-model flights and the controller can delay one flight without grounding the whole airport.
dbt is the transformation layer. Airflow is the orchestrator. Together they're the default ELT stack of the modern warehouse, and the integration is where most teams stumble.
The failure is predictable: a team schedules a single dbt run task with a 60-minute timeout. The model graph grows to 200 models. One heavy model hits 55 minutes, the task times out, the whole pipeline fails, and the alert lands at 3 AM.
Here are the two integration patterns, the Airflow 3.0 operators, and the timeout math that decides which one you need.
1. Why dbt Belongs Inside Airflow
dbt owns the transformation: models, tests, documentation, and the dependency graph between them. Airflow owns everything around the transformation: when it runs, what runs first, what happens when it fails, and who gets paged.
The common mistake is letting one tool do both. Some teams try to schedule dbt from cron alone and lose retries, state, and visibility. Others try to re-implement the model graph in Airflow with one task per model written by hand — duplicating what dbt already knows.
The clean split: dbt describes the graph, Airflow executes and watches it.
2. The Two Integration Patterns
There are two production patterns for wiring dbt into Airflow.
Pattern one: a single task that runs the whole project. Simple, one task, one log. It's the right call for small projects (under ~50 models) where the whole run completes well inside your timeout.
Pattern two: task mapping over model groups. You enumerate model groups in a config file, then expand one task per group. Failures localize to the exact group, and parallel groups cut total runtime.
Pattern two is what the incident article needed — but it costs a bit of setup. Here's the trade-off table.
dbt's own guidance threads the needle: bundle as many models per command as is practical — dbt orders them by dependency and parallelizes where it can, and a failed run can resume from the point of failure (turn on retry_from_failure for dbt Cloud jobs) — but group by run frequency or department rather than one job per node. The bug isn't bundling; it's bundling everything behind a single Airflow task timeout.
3. Airflow 3.0 dbt Operators
Airflow 3.0 ships first-party dbt integration as part of the standard library, and the old community plugin pattern (airflow_dbt with DbtRunOperator) is on its way out.
The new operators accept the same arguments you'd pass to the dbt CLI: project_dir, profile_name, select, and flags. You get templated SQL via Jinja — {{ ds }} works inside dbt run arguments — and full integration with Airflow's retry and alerting model.
The practical upgrade path: keep your dbt project untouched, swap the custom plugin calls for the first-party operators, and delete the plugin dependency from requirements.
Two official paths cover the rest of the integration space. For managed dbt Cloud jobs, the apache-airflow-providers-dbt-cloud package ships DbtCloudRunJobOperator (trigger a job and wait, or set wait_for_termination=False and pair it with DbtCloudJobRunSensor), DbtCloudGetJobRunArtifactOperator (download manifest.json, catalog.json, and run_results.json from the run's target/), plus job-listing operators — the account is bound via a dbt_cloud connection or an explicit account_id. For dbt Core, the Astronomer-maintained Cosmos package is the ecosystem standard: DbtTaskGroup or DbtDag turns the dbt project into real Airflow tasks, resolves profiles from Airflow connections, and can run dbt in its own virtual environment to avoid dependency conflicts.
4. Task Mapping Over Model Groups
Task mapping is the pattern that fixes the timeout incident. You define the model groups once, then a dbt task over them.expand()
Each group becomes its own task instance — visible in the grid view, retryable in isolation, and monitored separately. The slow model's group gets a dedicated timeout, the fast groups finish in minutes.
What about map_index? Every mapped task instance gets an index. Downstream tasks that depend on the mapped run receive the full set of indices, so joins and aggregates over the group results stay correct.
- expand() turns one task into N task instances, one per input row
- Each instance has its own state, logs, and retry count
- map_index identifies each instance; downstream tasks see all indices
- A pool caps how many instances run at once
5. dbt Tests as Airflow Quality Gates
dbt tests are only useful if a failure stops the right thing. The production pattern: dbt tests run as their own task after the model run, so a test failure doesn't re-run the load — it blocks promotion to the next stage.
Your quality gate chain looks like this: run models -> run tests -> mark the table promoted. If a test fails, the promotion task never runs, and the alert goes to the data team with the failing test name in the task log.
Use --store-failures so failed test rows land in the warehouse where your team can query them instead of hunting through logs.
6. Timeouts, Retries, and the 3 AM Alert
The incident article taught the whole team one lesson: a timeout is a signal, not a knob. Raising the timeout turns a nightly failure into a nightly near-failure.
Set per-task timeouts. The staging group gets 15 minutes, the monthly rebuild gets 90. Retries with exponential backoff apply only to the failing group, so a transient warehouse blip retries the rebuild without touching the fast groups.
The alert policy: on_d_failure callbacks should carry the failing task id and map_index into the notification. The on-call engineer reads "monthly_rebuild (index 2) timed out" instead of "DAG failed".
Two dbt Cloud knobs belong in this section. deferrable=True on the job-run operator hands the polling off to the triggerer, so a long job doesn't pin a worker slot. And retry_from_failure=True resumes a failed job from the point of failure instead of starting the whole run over — the managed equivalent of isolating the slow model.
- Raising a global timeout never fixes the slow model
- It converts a fast failure into a slow one
- It hides regression in the model graph
- Measure per-model runtime, then set per-task timeouts
The 200-Model dbt Run That Timed Out Every Night
- A monolithic dbt run inherits the worst-case runtime of its slowest model.
- One task per model (or per model group) turns a nightly coin-flip into a debuggable graph.
- Timeouts should be per-task, not a global DAG setting.
- Retrying a monolithic run re-executes every model — including the ones that succeeded.
dbt test --select <model> to isolate. Add the test as a separate Airflow task so a test failure doesn't fail the load itself.grep -A 30 'Run Stats' <task_log>dbt run --select <slow_model> --profile prod| File | Command / Code | Purpose |
|---|---|---|
| dags | from airflow import DAG | 1. Why dbt Belongs Inside Airflow |
| dags | from airflow import DAG | 3. Airflow 3.0 dbt Operators |
| dags | from airflow import DAG | 4. Task Mapping Over Model Groups |
| dags | from airflow import DAG | 5. dbt Tests as Airflow Quality Gates |
Key takeaways
Common mistakes to avoid
4 patternsRaising the timeout instead of splitting the run
Re-implementing the dbt model graph as Airflow tasks
dbt run with --select over groups.Running dbt tests inside the model-run task
--store-failures and a distinct alert policy.Task mapping without a pool
Interview Questions on This Topic
How would you integrate dbt with Airflow?
Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.
That's Airflow. Mark it forged?
3 min read · try the examples if you haven't