Home DevOps Airflow Scheduling: The Catchup That Ran a Month Late
Intermediate 3 min · September 04, 2026

Airflow Scheduling: The Catchup That Ran a Month Late

Airflow catchup with a past start_date fired 30 runs at once.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.

Follow
Production
production tested
September 04, 2026
last updated
1,750
articles · all by Naren
Before you start⏱ 30 min
  • One running DAG you can reschedule safely
  • Basic cron syntax or willingness to learn it
  • Timezone awareness: UTC versus local time
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • schedule sets the rhythm via presets, cron, timedelta, timetables, or assets in Airflow 3.x
  • start_date marks when intervals start counting; the first run fires one interval later, not at start_date
  • catchup=True with a month-old start_date launches ~30 daily runs instantly and saturates workers
  • Production rule: default new DAGs to catchup=False and rebuild history with backfill CLI limits
  • Data intervals anchor each run to the window just ended, with data_interval_end as the truth
✦ Definition~90s read
What is Airflow Scheduling and Catchup?

Airflow scheduling pairs a schedule rhythm with a start_date anchor and catchup policy, firing each run one interval after the data window it covers.

Airflow scheduling works like a newspaper reporting yesterday's news: the morning edition covers the day that just ended, the first edition arrives one full day after you subscribe, and asking for every missed edition at once buries your doorstep in paper.
Plain-English First

Airflow scheduling works like a newspaper reporting yesterday's news: the morning edition covers the day that just ended, the first edition arrives one full day after you subscribe, and asking for every missed edition at once buries your doorstep in paper.

A team enabled a DAG with last month's start_date and went to lunch. They returned to 30 runs hammering the warehouse at once. Nothing was broken. Everything was working as configured.

catchup=True with a past start_date means every missed interval fires. You'll learn why the first run always waits one full interval.

We'll decode schedule values, start_date truth, and data intervals. Timing stops being magic.

What Schedule Actually Means

schedule answers how often, using presets like @daily, cron strings, timedeltas, timetables, or asset lists. It replaced schedule_interval in 3.x and reads far more plainly. One argument states the rhythm.

It does not answer when the first run fires. That comes from start_date plus one interval, a combination that surprises everyone once. Read them as a pair, never alone.

Keep the value boring. Presets for standard cadences, cron for clock times, timetables only for calendars presets cannot express.

Manual runs don't inherit intervals: triggering from UI/CLI/API sets logical_date to now, and data_interval derives from the timetable — don't assume they match. Read logical_date explicitly when a human picks the date. New 3.x CLI is airflow backfill create --dag-id X --from-date ... --to-date ... --max-active-runs 3 --reprocess-behavior failed.

📊 Production Insight
Exotic schedules confuse every future reviewer.
Presets cover most pipelines honestly.
Rule: justify any cron stranger than daily.
🎯 Key Takeaway
schedule is rhythm; start_date is anchor; first fire is both.
Boring values review cleanly and age well.
Read the pair, never one alone.

Cron Syntax and Presets

Cron fields read minute, hour, day-of-month, month, day-of-week. 0 6 * fires at 06:00 UTC daily. Presets like @hourly and @daily are aliases for the cron you would otherwise hand-write.

Prefer presets when they match. @daily states intent where 0 0 * states mechanics. Reviewers grasp intent faster and DST discussions stay grounded.

Comment any raw cron with its plain meaning and timezone. Future readers should not parse asterisks under pressure.

dags/morning_report.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import pendulum
from airflow.sdk import dag, task

@dag(
    schedule="0 6 * * *",  # 06:00 UTC daily
    start_date=pendulum.datetime(2026, 9, 1, tz="UTC"),
    catchup=False,
    tags=["reporting"],
)
def morning_report():
    @task
    def build() -> None:
        print("building 06:00 UTC report")

    build()

morning_report()
📊 Production Insight
Uncommented cron breeds DST incidents.
Intent-first scheduling reviews faster.
Rule: no uncommented cron without a note.
🎯 Key Takeaway
Presets state intent; cron states mechanics.
Comment every raw cron with meaning plus zone.
Asterisks should never need decoding.

start_date Is NOT When It First Runs

start_date begins the interval count, not the firing. A daily DAG with a Sept 1 start fires its first run on Sept 2, covering Sept 1 data. The run waits for its interval to complete.

This feels backwards once and obvious forever. Airflow processes complete windows, so the run for a day can only fire after that day ends. Today's partial data is never a complete interval.

Set start_date to a fixed past date with catchup=False for new DAGs. Dynamic start dates like datetime.now() shift the anchor daily and produce ever-growing catchup queues.

📊 Production Insight
datetime.now() as start_date is a moving anchor.
Fixed past dates plus catchup=False start clean.
Rule: never compute start_date at parse time.
🎯 Key Takeaway
start_date anchors counting; first fire waits one interval.
Complete windows only; partial today never runs.
Fixed dates anchor, dynamic dates drift.

Catchup Semantics

catchup=True creates runs for every missed interval since start_date. It is a backfill machine wearing a boolean costume. With a month-old start_date, unpausing fires thirty runs before lunch.

Default new DAGs to catchup=False. The DAG starts from now, history stays unbuilt, and today's data flows first. Rebuild history separately and deliberately.

Use airflow dags backfill with date bounds and run-level limits for history. It paces the rebuild instead of stampeding the warehouse.

Cap the blast with max_active_runs (2-3 on warehouse DAGs) so even intentional catchups queue instead of stampeding. Know the config scheduler.catchup_by_default=False in 3.x — unset catchup means latest-interval-only. Re-enable pauses carefully: turning a DAG off and on re-triggers catchup for the gap.

safe_catchup.shBASH
1
2
3
4
5
6
7
8
# preview what catchup will do BEFORE unpausing
AIRFLOW_HOME=~/airflow airflow dags show sales_daily
AIRFLOW_HOME=~/airflow airflow dags list-runs -d sales_daily --limit 5
# deliberate history rebuild under limits (3.x syntax)
AIRFLOW_HOME=~/airflow airflow backfill create --dag-id sales_daily \
  --from-date 2026-08-01 --to-date 2026-08-07 \
  --max-active-runs 3 --reprocess-behavior failed
# cap concurrency in the DAG: max_active_runs=2
📊 Production Insight
Thirty runs at once is catchup working correctly.
Backfill CLI paces what booleans stampede.
Rule: prod DAGs ship catchup=False.
🎯 Key Takeaway
catchup=True is a backfill cannon, not a default.
New DAGs start now; history rebuilds separately.
Stampedes are configuration, not bad luck.

Data Intervals: The Mental Model That Fixes Scheduling Confusion

Every run covers a data interval: the logical window its tasks should process. The interval ends at data_interval_end, which is the scheduling anchor tasks must filter by. A Sept 4 run covers Sept 3.

This mental model fixes the classic confusion. The run date is when processing happens; the interval is what data it covers. Late runs still process their own window, not today's.

Write tasks accordingly. Filter sources by interval bounds passed as parameters, never by now(). Reruns then reproduce identical windows.

Mental Model
Yesterday's Data, Today's Run
A daily run firing Sept 4 processes Sept 3 data. If your query filters today, every late run loads the wrong partition. Anchor on data_interval_end and reruns become safe.
📊 Production Insight
now() in tasks makes reruns lie.
Interval filters make reruns truthful.
Rule: data_interval_end anchors every query.
🎯 Key Takeaway
Run date is processing time; interval is data time.
Filter by interval bounds, never by now.
Late runs still cover their own window.

Schedule in UTC: DST and Timezone Traps

Airflow schedules in UTC, always. A 09:00 local expectation in Berlin fires at 07:00 or 08:00 UTC depending on DST. Twice a year, local-time assumptions breach SLAs by exactly one hour.

Declare start_date with tz=UTC and write schedules as UTC cron. Document the UTC equivalent beside every business-hours promise so reviewers see the mapping.

Test the boundaries. Trigger manual runs across a DST weekend and confirm fire times and interval bounds before the clock does it for you.

📊 Production Insight
Hour-shifted SLAs trace to local-time schedules.
UTC plus documented mapping ends the debate.
Rule: grep schedules for missing tz quarterly.
🎯 Key Takeaway
UTC in config, local only in docs.
Two Sundays a year punish assumptions.
Test DST before DST tests you.

timetable for Custom Schedules

Timetables encode calendars presets cannot: business days, holiday skips, blackout windows, multi-asset triggers. They are code, so they test like code.

Most teams never need one. Presets plus cron cover standard cadences honestly, and simpler schedules review faster. Reach for timetables when the calendar has real exceptions.

When you do, enumerate expected run dates in tests across DST and holidays. A timetable without date-list tests is a rumor.

Choose the right timetable class: CronDataIntervalTimetable snaps to the clock (first 22 minutes can vanish if you start at 12:38), DeltaDataIntervalTimetable counts from activation, and CronTriggerTimetable decouples run time from data window (every minute, previous hour). You'll combine Assets with time via AssetOrTimeSchedule when freshness plus a nightly check both matter.

dags/business_daily.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import pendulum
from airflow.sdk import dag, task
from airflow.timetables.calendar import CalendarTimeTable

UTC = pendulum.timezone("UTC")

@dag(
    schedule=CalendarTimeTable(
        "0 6 * * *",  # 06:00 UTC weekdays handled below
        timezone=UTC,
    ),
    start_date=pendulum.datetime(2026, 9, 1, tz="UTC"),
    catchup=False,
    tags=["reporting"],
)
def business_daily():
    @task
    def build() -> None:
        print("weekday build")

    build()

business_daily()
📊 Production Insight
Holiday runs page people on days off.
Tested calendars respect both data and humans.
Rule: no timetable without expected-date tests.
🎯 Key Takeaway
Custom calendars deserve code, not cron hacks.
Unneeded timetables are complexity theater.
Date-list tests prove calendar claims.
● Production incidentPOST-MORTEMseverity: high

The DAG That Ran a Month Late

Symptom
Within 4 minutes of unpausing, the Grid filled with 30 queued runs and worker slots hit 32 of 32. Warehouse queue depth spiked to 28 and today's partition sat unprocessed for 3 hours. The team first suspected a scheduler bug, but the scheduler was faithfully executing 30 daily intervals at once.
Assumption
The team assumed start_date meant first fire time, so 2026-08-01 looked like a same-day start for a DAG enabled Sept 3. They'd left catchup at its inherited default without reading it and believed a new DAG just goes forward. Nobody counted the 30 intervals sitting between start_date and now.
Root cause
catchup=True tells the scheduler to create a run for every missed data interval since start_date. With a daily schedule and a 33-day-old start_date, enabling the DAG queued ~30 intervals instantly, each competing for the same 4 warehouse slots. The config worked as designed; it was written without understanding that first fire equals start_date plus one interval.
Fix
They paused the DAG, cleared the 30 queued runs, and set catchup=False with max_active_runs=2 and a start_date of 2026-09-01. They re-enabled for today first, then rebuilt history deliberately with airflow backfill create --dag-id sales_daily --from-date 2026-08-01 --to-date 2026-08-07 --max-active-runs 3. Reviews now require previewing run counts before unpausing.
Key lesson
  • catchup=True is a backfill cannon; new DAGs ship catchup=False unless history is intended.
  • start_date plus one interval is first fire, so preview queued counts before enabling.
  • Rebuild history with the backfill CLI under limits, never by flipping catchup on prod.
Production debug guideScheduling surprises are configuration working as designed. Read the run.4 entries
Symptom · 01
Enabling a DAG launched dozens of runs instantly
Fix
Pause the DAG, set catchup=False and max_active_runs=2, and delete excess runs from Grid. Re-enable with a recent start_date, then rebuild with airflow backfill create --dag-id X --from-date ... --to-date ... --max-active-runs 3 --reprocess-behavior failed.
Symptom · 02
First run fires a day later than expected
Fix
Run airflow dags show dag_id and read the schedule plus start_date. Remember first fire equals start_date plus one interval. Adjust start_date forward or trigger one manual run for today instead of waiting.
Symptom · 03
Tasks process the wrong day's data
Fix
Print data_interval_start and data_interval_end inside the task and compare against the source filter. Rewrite queries to filter by the interval window, never by current_date or now().
Symptom · 04
Runs shift an hour across DST boundaries
Fix
Audit every schedule for non-UTC start_dates with grep -rn start_date dags/. Convert to pendulum.datetime(..., tz="UTC"), document the UTC fire time, and add a DST-transition test run.
Schedule Values Compared
Schedule valueMeaningFirst run timingUse when
NoneManually triggered onlyNever automaticEvent or asset-driven DAGs
@onceSingle runAfter start_dateOne-off migrations
@hourlyEvery hourOne hour after start_dateHourly aggregates
@dailyEvery midnight UTCOne day after start_dateDaily rollups
0 6 *06:00 UTC dailyAfter start_date plus one intervalMorning business reports
timedelta(hours=6)Every 6 hoursSix hours after start_dateEven intraday spacing
NoneManually triggered onlylogical_date = trigger timeAssets plus manual DAGs
Asset list [a]On upstream updateWhen producer firesEvent-driven chains
⚙ Quick Reference
3 commands from this guide
FileCommand / CodePurpose
dagsmorning_report.pyfrom airflow.sdk import dag, taskCron Syntax and Presets
safe_catchup.shAIRFLOW_HOME=~/airflow airflow dags show sales_dailyCatchup Semantics
dagsbusiness_daily.pyfrom airflow.sdk import dag, tasktimetable for Custom Schedules

Key takeaways

1
schedule states rhythm but first run fires one interval after start_date
start_date marks the first data window, not the fire time.
2
catchup=True rebuilds every missed interval; 3.x defaults catchup_by_default=False, so be explicit and cap with max_active_runs=2-3.
3
Data intervals anchor runs to the window just ended; manual triggers set logical_date to now, so read it explicitly.
4
UTC everywhere plus documented equivalents prevents twice-yearly DST breaches; cron snaps to clock, delta counts from start.
5
CronTriggerTimetable splits run cadence from data window; AssetOrTimeSchedule mixes event plus nightly safety nets.

Common mistakes to avoid

4 patterns
×

Writing raw cron when a preset already means it

Symptom
Nobody can tell 0 0 * from @daily at review time; DST behavior surprises the team twice a year.
Fix
Use cron only where presets fall short, and comment the intent above the schedule line. Validate with airflow dags show and one manual trigger before enabling.
×

Pairing a past start_date with catchup=True unintentionally

Symptom
Enabling the DAG launches dozens of backfill runs instantly and swamps workers and the warehouse.
Fix
Set start_date in the past with catchup=False for new DAGs, or keep catchup=True only when you genuinely want history rebuilt. Test with a recent start_date first.
×

Scheduling in local time and ignoring DST

Symptom
Pipelines fire an hour early or late across DST boundaries; SLAs breach on two Sundays a year.
Fix
Put all schedules in UTC and convert only at display time. Document the UTC fire time next to every business-hours expectation.
×

Querying today inside a daily task instead of the data interval

Symptom
Yesterday's data lands in today's partition on late runs; reruns produce different results than the original.
Fix
Remember runs cover the interval that just ended: data_interval_end is the anchor. Filter source data by the interval, not by now().
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
A DAG with yesterday's start_date fired 30 runs at once. Why?
Q02SENIOR
Explain data intervals and why tasks must filter by them.
Q03SENIOR
How do you design schedules that survive DST and custom calendars?
Q01 of 03JUNIOR

A DAG with yesterday's start_date fired 30 runs at once. Why?

ANSWER
start_date marks when intervals begin counting, not when the first run fires. With @daily and a Sept 1 start_date, the first run covering Sept 1 fires on Sept 2. catchup=True creates runs for every missed interval, so a month-old start_date launches ~30 runs at once. New DAGs should use catchup=False unless backfilling is intended.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What values can schedule take?
02
What does catchup actually do?
03
What is a data interval in plain terms?
04
Do I need a custom timetable?
05
How do timezones break schedules?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.

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

That's Airflow. Mark it forged?

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

Previous
Airflow Operators Basics
5 / 37 · Airflow
Next
Airflow Task Lifecycle and Retries