Home DevOps Airflow Backfill: 90-Day Catchup Storm Tamed in Prod
Advanced 4 min · September 04, 2026
Airflow Backfill and Catchup in Depth

Airflow Backfill: 90-Day Catchup Storm Tamed in Prod

Airflow backfill queued 90 runs overnight and stalled every worker.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.

Follow
Production
production tested
September 04, 2026
last updated
1,750
articles · all by Naren
Before you start⏱ 25 min
  • 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
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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
✦ Definition~90s read
What is Airflow Backfill and Catchup?

Airflow backfill creates DAG runs for past intervals so you can rebuild history or reprocess failures, while catchup is the scheduler flag that auto-creates those runs on deploy.

Think of a newspaper delivery route.
Plain-English First

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.

📊 Production Insight
A team redeployed with catchup=True and queued 90 runs in 40 seconds.
Four workers drained 6-minute tasks in 3-hour wall time.
Rule: catchup=False in prod, backfill with --max-active-runs 2.
🎯 Key Takeaway
Catchup replays history automatically; backfill rebuilds it deliberately.
Keep catchup=False in prod and rebuild with explicit capped commands.
Your scheduler should never surprise you with 90 runs.

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.

dags/sales_daily.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from datetime import datetime, timedelta
from airflow.sdk import dag, task

@dag(
    dag_id="sales_daily",
    schedule="@daily",
    start_date=datetime(2026, 5, 1),
    catchup=False,  # prod default: never auto-replay history
    max_active_runs=3,  # caps scheduled + backfill pressure
    default_args={"retries": 2, "retry_delay": timedelta(minutes=5)},
    tags=["sales", "prod"],
)
def sales_daily():
    @task(pool="warehouse_pool")
    def load_day(logical_date=None):
        ds = logical_date.strftime("%Y-%m-%d")
        # idempotent: delete partition, then insert
        return f"DELETE FROM sales WHERE ds='{ds}'; INSERT INTO sales SELECT * FROM staging WHERE ds='{ds}'"
    load_day()

sales_daily()
📊 Production Insight
Prod DAGs without max_active_runs let one backfill consume all workers.
A 3-run cap kept morning SLAs green during a 10-day rebuild.
Rule: cap every DAG that touches the warehouse.
🎯 Key Takeaway
Safe DAGs pair catchup=False with max_active_runs and partition-keyed loads.
Pools isolate rebuild pressure from the rest of the fleet.
Boring defaults prevent exciting pages.

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.

📊 Production Insight
A team skipped --dry-run and rebuilt 40 good dates alongside 3 bad ones.
Warehouse bill spiked 4x for zero new rows.
Rule: --dry-run, then --reprocess-behavior failed.
🎯 Key Takeaway
Dry-run every range, pick failed unless you distrust old outputs.
Backfill caps stack on top of DAG caps; queued runs block recreates.
UI Trigger-Backfill and REST API cover the same rebuild.

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.

ops/backfill_sales.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Airflow 3.x: targeted, capped, resumable
# 1) preview the window (creates nothing)
airflow backfill create --dag-id sales_daily \
  --from-date 2026-06-01 --to-date 2026-06-10 \
  --dry-run

# 2) rebuild only failed dates, 2 at a time
AIRFLOW_CTX=prod airflow backfill create --dag-id sales_daily \
  --from-date 2026-06-01 --to-date 2026-06-10 \
  --reprocess-behavior failed --max-active-runs 2

# 3) legacy 2.x spelling (same idea, older flags)
# airflow dags backfill --start-date 2026-06-01 --end-date 2026-06-10 sales_daily

# 4) track progress
airflow dags list-runs sales_daily -s 2026-06-01 -e 2026-06-10 -o table
📊 Production Insight
A bare clear re-queued 30 good days and re-pulled a paid API 30 times.
Scoped regex clears cut the rerun to 4 tasks and $0 extra spend.
Rule: --task-regex and --start-date/--end-date on every clear.
🎯 Key Takeaway
Clear with DAG plus dates plus task regex, never bare.
Verify one green date before expanding the window.
Surgical clears keep finance's reconciled months intact.

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.

📊 Production Insight
A backfill restated 10 days of sales without warning downstream.
Two dashboards published corrections the next morning.
Rule: announce rewrites before running them.
🎯 Key Takeaway
Scheduled runs pace forward; backfills add pressure alongside them.
Replay history with the version that ran it when you can.
Warn downstream owners before rewriting published partitions.

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.

⚠ Rerun Safety Is Idempotency
A backfill that isn't idempotent is a data corruption tool. Key every write on logical_date, delete-then-insert per partition, and prove a second run changes zero rows before touching prod.
📊 Production Insight
An append-only load doubled 7 days of revenue on rerun.
Partition delete-then-insert made the next rerun a zero-row delta.
Rule: no idempotency, no backfill.
🎯 Key Takeaway
Delete-then-insert per ds partition makes reruns no-ops.
Prove zero net change in staging before prod.
Idempotency turns backfills from scary into routine.

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.

ops/backfill_checklist.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Prod backfill checklist (run in order)
# 0) announce: who is affected, what range, rollback plan
airflow dags list-runs sales_daily -s 2026-07-20 -e 2026-08-02 -o table

# 1) pause only if catchup is still flooding the queue
# airflow dags pause sales_daily

# 2) dry-run the exact window
airflow backfill create --dag-id sales_daily \
  --from-date 2026-07-20 --to-date 2026-08-02 --dry-run

# 3) capped rebuild of failed dates only
airflow backfill create --dag-id sales_daily \
  --from-date 2026-07-20 --to-date 2026-08-02 \
  --reprocess-behavior failed --max-active-runs 2

# 4) verify row counts per ds, then unpause
# SELECT ds, COUNT(*) FROM sales WHERE ds BETWEEN '2026-07-20' AND '2026-08-02' GROUP BY ds;
📊 Production Insight
Weekly chunks with row-count gates caught a grain change on day 9.
A single 30-day blast would have rewritten 21 bad days.
Rule: verify between chunks, never blast the full range.
🎯 Key Takeaway
Announce, dry-run, cap at 2, verify per ds, then widen.
Stagger big ranges into weekly chunks with checks.
Low and slow beats fast and paged.
● Production incidentPOST-MORTEMseverity: high

The Catchup That Launched 90 Days of Runs at Once. Ninety queued runs starved every worker in the cluster.

Symptom
Monday's deploy looked innocent until the Grid view filled with 90 queued runs within a minute. Workers stopped picking up production tasks because the queue was flooded with history. The warehouse hit its connection ceiling, task durations tripled, and two unrelated DAGs missed their morning SLAs. The on-call engineer found the backlog growing faster than workers could drain it.
Assumption
The team assumed catchup only affected new runs going forward. They believed the scheduler would start from today and ignore the three idle months. Nobody had load-tested what 90 simultaneous daily runs would do to their four workers and their Postgres-backed warehouse.
Root cause
The DAG carried catchup=True with a start_date 90 days in the past. On its first parse the scheduler computed every missed daily interval and created a run for each one. Ninety runs hit four workers at once, warehouse slots saturated, and tasks that normally finished in 6 minutes sat queued for hours. The mechanism worked as designed; the configuration was the bug.
Fix
They paused the DAG immediately to stop new runs from queuing. Then they flipped catchup to False and redeployed so the scheduler would stop generating history on its own. Finally they rebuilt the two weeks that actually mattered with a capped command: airflow backfill create --dag-id sales_daily --from-date 2026-07-20 --to-date 2026-08-02 --max-active-runs 2, watching pool slots until the range drained. Older gaps were left alone because nobody queried them.
Key lesson
  • 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.
Production debug guideDiagnose runaway catchup, scope rebuilds, and clear without collateral damage.4 entries
Symptom · 01
Grid view shows dozens of queued runs right after a deploy
Fix
List runs in the window: airflow dags list-runs sales_daily -s 2026-05-01 -e 2026-08-01 -o table. Count queued vs running. If queued exceeds 3x your max_active_runs, pause the DAG first: airflow dags pause sales_daily. Then cancel or let the backfill drain with --max-active-runs 2.
Symptom · 02
You need to rebuild a date range but fear touching good runs
Fix
Dry-run before anything else: airflow backfill create --dag-id sales_daily --from-date 2026-06-01 --to-date 2026-06-10 --dry-run. It prints the dates it would create. Compare against airflow dags list-runs output. If the dry-run lists dates that already succeeded, add --reprocess-behavior failed so good runs are skipped. Prefer the UI path (DAG details > Trigger > Backfill > Missing and Errored Runs) when operators need a clickable audit trail, and pass --dag-run-conf '{"reason": "incident-42"}' so reruns carry context. If a date's latest run is still queued or running, wait: Airflow won't create a duplicate for it under any reprocess setting.
Symptom · 03
A few tasks failed inside otherwise green runs
Fix
Clear surgically: airflow tasks clear sales_daily --task-regex '^load_.*' --start-date 2026-06-01 --end-date 2026-06-07 --yes. Verify in Grid view that only the failed tasks reset to queued. Re-run one date, confirm row counts, then expand the window.
Symptom · 04
Unrelated DAGs start missing SLAs during your backfill
Fix
Check scheduler and pool pressure: airflow dags list-jobs -o table, then inspect pool slots in the UI under Admin > Pools. If default_pool is saturated, the backfill is starving prod. Lower --max-active-runs to 1-2 or move the backfill to a dedicated pool.
★ Airflow Backfill Catchup Storm Debug Cheat SheetStop a runaway catchup and rebuild only the dates you need without starving production.
90 queued runs flood Grid view after deploy
Immediate action
Pause the DAG to stop new runs from queuing
Commands
airflow dags pause sales_daily
airflow dags list-runs sales_daily -s 2026-05-01 -e 2026-08-01 -o table
Fix now
Set catchup=False in the DAG file, redeploy, then rebuild only needed dates with --max-active-runs 2.
Unsure which dates a backfill would touch+
Immediate action
Dry-run the rebuild before creating anything
Commands
airflow backfill create --dag-id sales_daily --from-date 2026-06-01 --to-date 2026-06-10 --dry-run
airflow dags list-runs sales_daily -s 2026-06-01 -e 2026-06-10 -o table
Fix now
Re-run with --reprocess-behavior failed and --max-active-runs 2 once the date list looks right.
Failed tasks buried inside green runs+
Immediate action
Clear only the failed tasks in the window
Commands
airflow tasks clear sales_daily --task-regex '^load_.*' --start-date 2026-06-01 --end-date 2026-06-07 --yes
airflow dags list-runs sales_daily -s 2026-06-01 -e 2026-06-07 --state failed -o table
Fix now
Re-run one cleared date, verify row counts, then expand the window.
Unrelated DAGs miss SLAs mid-backfill+
Immediate action
Check scheduler and pool saturation during backfill
Commands
airflow dags list-jobs -o table
airflow config list | grep -iE 'max_active_runs|parallelism|pool'
Fix now
Drop backfill concurrency to 1 and move it to a dedicated pool until prod recovers.
Backfill vs Catchup vs Clear vs Rerun Compared
ApproachWhen to useConcurrency controlRisk
catchup=True on deployYou genuinely want all missed intervals since start_dateDAG max_active_runs onlyHigh: 90 runs queue at once by default
airflow backfill createTargeted history rebuild with explicit date range--max-active-runs + --reprocess-behaviorLow: dry-run first, capped parallelism
airflow tasks clearRe-run specific tasks inside existing runsPool slots + executor queueMedium: clears state, re-queues fast
Manual UI trigger per dateOne or two ad-hoc rerunsNone, one at a timeLow but slow: not viable past 5 dates
catchup=False + scheduled forwardNormal prod operation, late data handled separatelyScheduler default pacingLowest: no surprise history
UI Trigger > Backfill / REST APIClick-driven or scripted rebuilds with audit trail--max-active-runs per backfill, independent of DAG capLow: same engine, needs paused-DAG unpause check
⚙ Quick Reference
3 commands from this guide
FileCommand / CodePurpose
dagssales_daily.pyfrom datetime import datetime, timedeltaCatchup Mechanics and the Default Trap
opsbackfill_sales.shairflow backfill create --dag-id sales_daily \Clearing Task Instances Safely
opsbackfill_checklist.shairflow dags list-runs sales_daily -s 2026-07-20 -e 2026-08-02 -o tableBackfill on Prod

Key takeaways

1
catchup=True replays every missed interval automatically; keep it False in production.
2
Use airflow backfill create with --from-date/--to-date, --dry-run, --max-active-runs, and --reprocess-behavior; UI and REST API rebuild the same way.
3
Clear tasks with explicit date windows and --task-regex, never a bare clear.
4
Idempotent loads keyed on logical_date make reruns safe instead of doubling rows.
5
Treat every backfill as a capacity event
cap runs, watch pools, and stagger big ranges.

Common mistakes to avoid

4 patterns
×

Leaving catchup=True with a past start_date on a production DAG

Symptom
Redeploy launches dozens or hundreds of runs at once, the scheduler queue explodes, and the metadata DB slows to a crawl.
Fix
Set catchup=False on every prod DAG unless you explicitly want history. Use the new backfill API for intentional history: airflow backfill create --dag-id sales_daily --from-date 2026-05-01 --to-date 2026-05-10 --max-active-runs 3. Review the dry-run dates before confirming.
×

Running airflow tasks clear without a date window or task filter

Symptom
An entire month of good runs resets to queued and re-executes, double-loading partitions that were already correct.
Fix
Scope every clear to explicit dates plus a task regex: airflow tasks clear sales_daily --task-regex '^load_.*' --start-date 2026-06-01 --end-date 2026-06-07 --yes. Check Grid view first so you clear only what failed.
×

Writing load tasks that are not idempotent, then backfilling over them

Symptom
Backfilled dates insert duplicate rows, revenue dashboards double-count, and the cleanup takes longer than the backfill itself.
Fix
Gate every load on its logical_date partition: DELETE FROM sales WHERE ds = '{{ ds }}' before INSERT. Test reruns in staging with --reprocess-behavior completed so a second run changes zero rows.
×

Backfilling with no concurrency cap on a heavy DAG

Symptom
Parallel runs exhaust warehouse slots and worker slots, production tasks starve, and SLAs miss across unrelated DAGs.
Fix
Cap concurrency at both levels: max_active_runs=3 on the DAG and --max-active-runs 2 on the backfill call. Watch pool slots and DB connections during the first 10 runs before raising limits.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What happens when you deploy a DAG with catchup=True and a start_date 90...
Q02SENIOR
Walk me through your production backfill checklist.
Q03SENIOR
Why do backfills cause double-loads even when scheduled runs are correct...
Q01 of 03JUNIOR

What happens when you deploy a DAG with catchup=True and a start_date 90 days ago?

ANSWER
catchup=True tells the scheduler to create a run for every interval between start_date and now. With a 90-day-old start_date on a daily DAG, that is 90 runs queued instantly. The fix is catchup=False by default and explicit backfills with capped concurrency when history is truly needed.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between catchup and backfill?
02
Should I use airflow dags backfill or airflow backfill create?
03
Can I backfill while the DAG keeps running on schedule?
04
How do I rerun only failed tasks inside a date range?
05
Does backfill fix late-arriving data?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.

Follow
Verified
production tested
September 04, 2026
last updated
1,750
articles · all by Naren
🔥

That's Airflow. Mark it forged?

4 min read · try the examples if you haven't

Previous
Airflow Dynamic DAGs and Task Mapping
19 / 37 · Airflow
Next
Airflow Datasets and Assets Scheduling