Airflow Backfill: 90-Day Catchup Storm Tamed in Prod
Airflow backfill queued 90 runs overnight and stalled every worker.
20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.
- ✓You can write and trigger a basic Airflow DAG
- ✓You understand start_date, schedule, and logical_date basics
- ✓You have CLI access to an Airflow 2.x or 3.x environment
- Airflow backfill creates DAG runs for past intervals so you can rebuild history or reprocess failed dates on demand
- Key components: catchup flag, backfill CLI (airflow backfill create / airflow dags backfill), airflow tasks clear for surgical reruns, and max_active_runs caps
- Performance insight: an uncapped 90-run catchup on 4 workers turned 6-minute tasks into 3-hour queue waits; capping --max-active-runs at 2 drained the same range with zero SLA misses
- Production insight: keep catchup=False in prod and treat every history rebuild as a capacity event with dry-run, idempotent loads, and staggered ranges
Think of a newspaper delivery route. Catchup is telling the new carrier to deliver every missed paper since January to every house on day one. Backfill is handing them a short list of five addresses that complained and letting them deliver just those. One floods the truck, the other fixes the actual problem.
Nobody fears the scheduler until it does exactly what they asked. You deploy a daily DAG on a Monday, glance at the Grid view, and find 90 queued runs staring back at you.
That's catchup doing its job. It saw 90 missed intervals between your start_date and today and queued every single one. Your workers, your warehouse, and your on-call rotation are about to have a very bad morning.
There's a calmer path. Explicit backfills with capped concurrency rebuild exactly the dates you name and nothing else. You'll learn both mechanisms here.
Catchup Versus Backfill: Know the Difference
Catchup is a scheduler behavior, not a command. When a DAG has catchup=True, the scheduler compares start_date to now on every parse and creates a run for each missed interval. A daily DAG with a 90-day-old start_date creates 90 runs on its first heartbeat. That's the trap: the default looks harmless in dev and explodes in prod.
Backfill is the deliberate alternative. You name the DAG, the date range, and the concurrency cap, and Airflow creates exactly those runs. Airflow 3.x uses airflow backfill create --from-date/--to-date with --max-active-runs and --reprocess-behavior. Airflow 2.x uses airflow dags backfill --start-date/--end-date. Both funnel through the scheduler, but only backfill lets you dry-run first.
The rule you'll keep: catchup=False everywhere in production. History becomes a conscious command with a cap, not a deploy side effect that pages you at 2 AM.
Catchup Mechanics and the Default Trap
This DAG shows the safe production shape. catchup=False stops the scheduler from inventing history. max_active_runs=3 bounds how many runs compete for workers at once. The load task deletes its own partition before inserting, so replaying a date changes zero net rows.
Notice the pool assignment. Backfill runs share pools with scheduled runs, so isolating heavy loads in warehouse_pool keeps a rebuild from starving lightweight hourly DAGs. You'll thank yourself the first time a backfill overlaps the morning peak.
Tags look trivial until you run list-runs across 100 DAGs. Tag every prod DAG so your backfill audit commands can filter fast.
Backfill CLI: Dates, Reruns, and Limits
Airflow 3.x made backfills first-class: they are scheduled and tracked like regular runs with UI and API support. The create command takes --from-date and --to-date (inclusive), --max-active-runs for this backfill only, --reprocess-behavior (none, failed, completed), --run-backwards for newest-first ordering, and --dag-run-conf '{"my": "param"}' to inject run config. --dry-run prints dates without creating anything. The same rebuild exists in the UI: open the DAG details page, hit Trigger, pick Backfill, and choose Missing Runs, Missing and Errored Runs, or All Runs with an optional JSON conf. Backfills also work through the REST API for scripted rebuilds.
Reprocess behavior decides what happens when a run already exists. none skips existing runs entirely. failed creates new runs only where the prior run failed. completed re-creates runs that finished or failed. If the latest run is still queued or running, Airflow creates nothing for that date no matter which behavior you pick. Pick failed for incident recovery and completed only when you distrust old outputs. Backfill max_active_runs applies independently of the DAG-level max_active_runs, so the two caps stack.
Two edge cases bite teams. Backfill makes no sense for DAGs without a time-based schedule, so don't fire it at @once or asset-only DAGs. Partitioned-timetable DAGs (CronPartitionTimetable) reuse the same --from-date/--to-date flags, with Airflow interpreting the range as partitions and creating one run per partition. Run one date first, diff row counts against the warehouse, then widen the window. Backfills replay code against current tables, so a schema change since June can break a May rerun in ways scheduled runs never show.
Clearing Task Instances Safely
Clearing resets task instances to queued so they run again. That's powerful and dangerous in equal measure. A bare airflow tasks clear with no dates resets the whole DAG history, including months of green runs your finance team already reconciled.
Scope every clear three ways: the DAG id, a date window, and a task regex. The regex is the part people skip. Clearing load_day tasks is safe when extracts are expensive; clearing extracts too means re-pulling APIs you already paid for.
After clearing, watch one date go green before walking away. Grid view shows task instance history per cell, so you can confirm the retry actually replaced the failure instead of stacking beside it.
Scheduled vs Manually Triggered Runs
Scheduled runs move forward; manual runs and backfills rewrite the past. The scheduler paces scheduled runs by max_active_runs and pool slots. Backfills add extra runs alongside them, which is why prod slows during big rebuilds even though nothing is broken.
Manually triggered runs use the current code and current data, not the historical context. That's fine for testing but wrong for rebuilding June with June's logic. Where your version supports it, backfill against the DAG version that originally ran so history replays faithfully.
If downstream DAGs consume your outputs, tell their owners before you backfill. Rewritten partitions change row counts downstream dashboards already published, and nobody likes explaining a restated metric on a Friday.
Rerun Without Double-Loads
Idempotency is what makes reruns boring. A task keyed on its logical_date partition can run five times and leave the table identical. An append-only task doubles revenue every time you touch it. Guess which one pages finance.
The pattern is simple: derive ds from logical_date, delete that partition, then insert exactly that partition's rows. Row-count checks after the load catch grain changes before downstream sees them. Dedupe keys catch the rest.
Backfill day is the wrong day to discover your loads append. Test reruns in staging with --reprocess-behavior completed and assert zero net change. If the count moves, don't backfill prod yet.
Backfill on Prod: The Checklist
Big rebuilds are capacity events. Announce the range, the expected duration, and who to ping if SLAs slip. Pause only when catchup is actively flooding; otherwise leave the schedule running so today stays green while history rebuilds beside it.
Stagger ranges longer than two weeks into weekly chunks with verification between them. Each chunk gets a row-count query per ds before the next starts. That cadence caught a grain change on day 9 of a 30-day rebuild in one real incident, saving 21 days of bad rewrites.
Keep the concurrency low until the first chunk drains cleanly. Raising --max-active-runs from 2 to 4 is easy; explaining missed SLAs to three teams is not.
The Catchup That Launched 90 Days of Runs at Once. Ninety queued runs starved every worker in the cluster.
- catchup=False is the only safe default in production; history should be an explicit backfill, never a deploy side effect.
- Every backfill needs a concurrency cap and a dry-run, because the scheduler will happily queue more work than your workers can drain.
airflow dags pause sales_dailyairflow dags list-runs sales_daily -s 2026-05-01 -e 2026-08-01 -o table| File | Command / Code | Purpose |
|---|---|---|
| dags | from datetime import datetime, timedelta | Catchup Mechanics and the Default Trap |
| ops | airflow backfill create --dag-id sales_daily \ | Clearing Task Instances Safely |
| ops | airflow dags list-runs sales_daily -s 2026-07-20 -e 2026-08-02 -o table | Backfill on Prod |
Key takeaways
Common mistakes to avoid
4 patternsLeaving catchup=True with a past start_date on a production DAG
Running airflow tasks clear without a date window or task filter
Writing load tasks that are not idempotent, then backfilling over them
Backfilling with no concurrency cap on a heavy DAG
Interview Questions on This Topic
What happens when you deploy a DAG with catchup=True and a start_date 90 days ago?
Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.
That's Airflow. Mark it forged?
4 min read · try the examples if you haven't