Home DevOps Airflow Scheduling: The Catchup That Ran a Month Late
Intermediate 4 min · August 1, 2026

Airflow Scheduling: The Catchup That Ran a Month Late

Airflow scheduling explained: cron, start_date, catchup, and data intervals.

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
August 01, 2026
last updated
1,750
articles · all by Naren
Before you start⏱ 15-20 min read
  • Airflow installed and a first DAG running (see the getting-started guide).
  • Comfort with DAG basics: dag_id, tasks, and default_args.
  • A basic idea of what a cron expression looks like.
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Airflow scheduling turns a schedule, a start_date, and a timezone into a set of runs, one per data interval.
  • Key components: five-field cron expressions, presets like @daily, start_date, catchup, and the data interval window each run processes.
  • With catchup=True and a start_date one month back, the scheduler fires 30 backfilled runs at once — not one run a day.
  • The first run never fires at start_date; it fires after start_date plus one full interval.
  • Production rule: keep catchup=False by default and backfill explicitly with airflow dags backfill.
  • A schedule can be a cron string, a preset, a timedelta, or a custom Timetable — each generates intervals its own way.
✦ Definition~90s read
What is Airflow Scheduling?

Airflow scheduling is the mechanism that turns a cron-like expression plus a start_date into a set of data-interval runs, with catchup and timezone semantics governing when each run fires.

Think of Airflow as a newspaper that publishes one edition per day.
Plain-English First

Think of Airflow as a newspaper that publishes one edition per day. The schedule decides how often an edition comes out, and the data interval is the news day that edition covers. Your start_date says when the paper first starts covering news — but the first edition only lands after the first news day ends. Catchup is the dangerous "print all the missed back issues at once" button. Press it with a month-old start date and you'll get 30 newspapers in one afternoon.

Everyone's first Airflow DAG uses a cron string and a start_date, and everyone gets burned by the exact same gap. The schedule doesn't fire at start_date. It fires after each data interval closes, and catchup decides whether every missed interval from start_date to now runs at once. That gap is where pipelines go wrong.

We deployed a daily revenue DAG with a start_date set a month in the past "to be safe." The scheduler read that as a month of unpaid bills and fired 30 runs in the first few minutes. Nobody had misconfigured a cron. Everybody had misunderstood what a schedule actually means.

This article walks the scheduling stack from cron syntax to custom timetables, and builds the data-interval mental model that fixes the confusion for good. Get the interval right and scheduling stops being a guessing game. Get it wrong and you'll get a month-late DAG of your own.

1. What "Schedule" Actually Means

A schedule is not a timer. It's a generator of data intervals. Airflow asks the schedule "what intervals exist," then creates one run per interval, and each run processes the window that just closed.

That distinction is the whole ballgame. A cron fires at a timestamp; an Airflow schedule fires a run that owns a window of time. The window is called the data interval, and it's why two DAGs with the same cron can still mean completely different things.

Once you accept that runs process intervals, everything else — start_date, catchup, backfills — clicks into place. The clock is only the trigger. The interval is the contract.

📊 Production Insight
A schedule is a generator of intervals, not a timer.
Every run belongs to a data interval, even manual ones.
Decide what an interval means before you write a cron.
🎯 Key Takeaway
Schedules produce runs; each run processes a data interval.
Never reason about a run without asking which interval it covers.
The interval is the contract; the clock is just the trigger.
airflow-scheduling-diagram3 A Schedule Is a Generator of Intervals Not a timer — the clock only triggers Schedule: cron or @daily the question: what intervals exist? Data Intervals one run per interval, in order Each Run Owns a Window the interval that just closed The clock is the trigger the interval is the contract THECODEFORGE.IO
thecodeforge.io
Airflow Scheduling

2. Cron Syntax and Presets

Airflow accepts standard five-field cron: minute, hour, day of month, month, day of week. The trap is the field order — minute comes first, and the most common mistake is writing "0 6 *" and believing it means 6 minutes past midnight. It means 06:00.

Presets are sugar over the same five fields. @daily is "0 0 ", @hourly is "0 *", and @weekly, @monthly, @yearly follow the same pattern. @once schedules a single run, and @continuous runs back-to-back intervals for streaming-ish workloads.

Use presets until you need minute-level control. A team reading "@daily" instantly knows the cadence; a team reading "17 3 1-5" needs a man page.

Two more options round out the menu: a datetime.timedelta object schedules by elapsed time with no cron at all, and Airflow parses cron through the croniter library, so extended syntax like */15 and day-of-week lists works as expected. One preset carries a caveat: @continuous fires back-to-back intervals, and the docs pair it with max_active_runs=1 so runs never overlap.

daily_revenue_rollup.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from datetime import datetime
from airflow import DAG
from airflow.operators.python import PythonOperator

with DAG(
    dag_id="daily_revenue_rollup",
    schedule="0 6 * * *",              # 06:00 UTC, every day
    start_date=datetime(2026, 8, 1),
    catchup=False,
    tags=["billing", "daily"],
) as dag:
    def rollup(**context):
        interval_start = context["data_interval_start"]
        print(f"Rolling up revenue for {interval_start.isoformat()}")

    rollup_task = PythonOperator(
        task_id="rollup_revenue",
        python_callable=rollup,
    )
Output
Rolling up revenue for 2026-08-01T00:00:00+00:00
📊 Production Insight
Cron is five fields with minute first, hour second.
Presets like @daily hide the same five-field semantics.
Write cron in UTC and let the UI translate display time.
🎯 Key Takeaway
Cron syntax is compact but unforgiving.
@daily is sugar, not magic.
Prefer presets until you need minute-level control.

3. start_date Is NOT When It First Runs

Here's the sentence that fixes most scheduling confusion: the first run fires after start_date plus one full interval, never at start_date.

Set a @daily DAG with start_date of August 1, and the first run fires on August 2 at 00:00, processing August 1's data. The start_date anchors the first data interval, not the first execution. Airflow waits for that interval to end before it has anything to process.

This is also why "start_date yesterday" feels like a safe default and isn't. With catchup off it mostly is harmless, but with catchup on, a start_date in the past means a backlog you never asked for. Pick start_date as exactly one interval before the first data you want processed.

📊 Production Insight
start_date anchors the first interval, not the first run.
The first run fires after start_date plus one interval.
A past start_date still starts the clock — catchup decides what happens next.
🎯 Key Takeaway
Your DAG won't run at start_date.
It runs after the first interval closes.
Set start_date one interval before the first data you want.

4. Catchup Semantics: The Month-Late Incident, Deep Dive

Catchup answers one question: what do we do with the intervals between start_date and now that never ran? With catchup=False, nothing — the scheduler picks up from the next interval and moves forward. With catchup=True, every missed interval fires, all of them, as fast as the scheduler can queue them.

Our revenue DAG had a start_date one month back and catchup=True. Thirty missed intervals became thirty queued runs in minutes. The scheduler didn't stagger them over thirty days; it reconciled the ledger instantly. Runs weren't late by a month — they all happened at once.

The fix had three parts: catchup=False in the DAG, start_date moved to go-live, and explicit backfills with airflow dags backfill --start-date --end-date whenever history was actually needed. Backfill became an intentional act, not an accident of deployment.

Two semantics make backfill safe to use: the scheduler executes backfilled runs sequentially, one after another, and each run is atomic and idempotent — the properties that make re-running any interval harmless. There's also a fleet-wide default: catchup_by_default in airflow.cfg sets the catchup value for every DAG that doesn't declare one, so the global default can be flipped instead of auditing each file.

daily_revenue_rollup.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from datetime import datetime
from airflow import DAG
from airflow.operators.python import PythonOperator

with DAG(
    dag_id="daily_revenue_rollup",
    schedule="@daily",
    start_date=datetime(2026, 8, 1),
    catchup=False,          # never auto-backfill missed intervals
    tags=["billing"],
) as dag:
    def aggregate(**context):
        start = context["data_interval_start"]
        end = context["data_interval_end"]
        print(f"Aggregating revenue for {start} -> {end}")

    aggregate_task = PythonOperator(
        task_id="aggregate_revenue",
        python_callable=aggregate,
    )
Output
Aggregating revenue for 2026-08-01T00:00:00+00:00 -> 2026-08-02T00:00:00+00:00
📊 Production Insight
catchup=True backfills every missed interval at once.
Thirty missed days become thirty queued runs in minutes.
Default to catchup=False; backfill explicitly.
🎯 Key Takeaway
Catchup is a reconciliation tool, not a safety net.
Every missed interval fires immediately when it's on.
Run backfills with explicit date ranges instead.
When Will This DAG Run?
Ifschedule is None and no trigger is attached
UseRuns only when you trigger it manually via the UI or API.
Ifstart_date is in the past and catchup=True
UseEvery missed interval fires immediately, as fast as the scheduler can queue the runs.
Ifstart_date is in the past and catchup=False
UseOld intervals are skipped; the DAG picks up from the next interval after now.
Ifstart_date is today and schedule is @daily
UseThe first run fires after today's interval closes — not at start_date.
IfSchedule is defined in local wall-clock time
UseRuns shift by an hour twice a year unless the schedule is in UTC.
Ifschedule is a custom Timetable
UseRun times come entirely from your timetable's logic, not cron semantics.
airflow-scheduling-diagram1 Catchup: The Month-Late Incident How catchup handles missed intervals catchup=False catchup=True Missed intervals skipped — nothing runs every one fires at once Scheduler behavior picks up next interval 30 runs queued in minutes Result one run per day a month of runs at once The fix: catchup=False backfill only with explicit dates THECODEFORGE.IO
thecodeforge.io
Airflow Scheduling

5. Data Intervals: The Mental Model That Fixes Scheduling Confusion

Stop thinking in execution dates. Think in data intervals. A run's identity isn't when it ran — it's the window of data it processed: data_interval_start to data_interval_end.

For a @daily DAG, the run that fires at midnight on August 2 processes August 1 00:00 to August 2 00:00. The scheduler fires on the interval end, because that's when the data for the interval is complete. Airflow's legacy term execution_date is really just data_interval_start — same value, worse name.

Every task gets both values through context, and production tasks should use them constantly: log them, filter on them, name output files with them. A task that knows its interval is a task that survives reruns, backfills, and late schedules.

Version note for older code: the execution_date concept was deprecated in Airflow 2.2, which is why every current doc says data_interval_start instead.

nightly_orders.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from airflow.decorators import dag, task
from airflow.utils.dates import days_ago

@dag(
    schedule="@daily",
    start_date=days_ago(2),
    catchup=False,
    default_args={"retries": 2},
)
def nightly_orders():
    @task
    def count_orders(data_interval_start, data_interval_end):
        print(f"Interval: {data_interval_start} -> {data_interval_end}")
        return f"count_orders_{data_interval_start:%Y%m%d}"

    count_orders()

nightly_orders()
Output
Interval: 2026-07-30T00:00:00+00:00 -> 2026-07-31T00:00:00+00:00
Mental Model
Intervals, Not Timestamps
Data intervals, not execution dates, are the mental model that fixes scheduling confusion.
  • A run processes the interval that just ended, not the interval that's starting.
  • data_interval_start is when the window opens; data_interval_end is the anchor the scheduler fires on.
  • execution_date is legacy naming for data_interval_start — the UI still shows it, the semantics changed.
📊 Production Insight
Always read data_interval_start and data_interval_end in tasks.
The interval end is the anchor the scheduler fires on.
Make every task name its output with its interval.
🎯 Key Takeaway
Data intervals, not execution dates, drive scheduling.
A run's interval is its identity.
Log the interval in every task; your future self will thank you.
airflow-scheduling-diagram2 Data Intervals: The Run's Identity The window of data each run processes A run's identity is its data window not when it ran — what it processed fires at interval end — data complete Aug 1 00:00 → Aug 2 00:00 Aug 2 00:00 → Aug 3 00:00 run fires run fires data_interval_start — window opens data_interval_end — the fire anchor execution_date — legacy start name THECODEFORGE.IO
thecodeforge.io
Airflow Scheduling

6. Schedule in UTC: DST and Timezone Traps

Airflow defaults to UTC, and that default is a feature. Cron in UTC means the schedule never blinks when daylight saving rolls around. The same expression in a local timezone fires one hour early in spring and one hour late in autumn — and a poorly timed DST boundary can even skip or double-fire a run.

The rule: schedules live in UTC wall-clock time. "0 6 *" means 06:00 UTC, full stop. If the business wants 06:00 Paris time, compute the UTC offset once and put it in the schedule, or do timezone translation inside tasks where it belongs.

Set default_timezone=utc in airflow.cfg and never let a local timezone sneak into a schedule string. Your pipeline doesn't care what the office clock says.

⚠ DST Eats Runs
A cron in local time fires one hour early or late across DST boundaries, and midnight-adjacent schedules can skip a run entirely. Airflow's UTC default exists for exactly this reason — don't override it without a fight.
📊 Production Insight
Airflow defaults to UTC; keep it that way.
DST shifts local cron by an hour and can double-run jobs.
Set default_timezone=utc and translate only for display.
🎯 Key Takeaway
Wall-clock scheduling breaks twice a year.
UTC scheduling is stable and reproducible.
Timezone logic belongs in display, not in schedules.

7. Timetables for Custom Schedules

Cron expresses regular calendars well and almost everything else badly. Trading-hours schedules, quarter-end runs, fiscal weeks, skip-the-holiday logic — all of it is a Timetable's job.

A Timetable is a class that answers two questions: given a time, what interval does it belong to (infer_data_interval), and given the last run, what's the next one (next_dagrun_info). Register it as a plugin, reference it from schedule=, and you've replaced cron with real logic.

The beauty is testability. A Timetable is a plain class — you can unit-test its interval math without a running scheduler. If your schedule is worth writing, it's worth testing, and cron strings can't be tested at all.

The interface expects two methods: next_dagrun_info (the next scheduled interval) and infer_manual_data_interval (what a manual trigger's interval should be), both returning a DataInterval with a start and an end.

market_hours_timetable.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from airflow.plugins_manager import AirflowPlugin
from airflow.timetables.base import DagRunInfo, DataInterval, Timetable

class MarketHoursTimetable(Timetable):
    def infer_data_interval(self, run_after):
        start = run_after.set(hour=9, minute=30)
        return DataInterval(start=start, end=start.add(days=1))

    def next_dagrun_info(self, last_automated_data_interval, restriction):
        next_start = last_automated_data_interval.end.add(days=1)
        return DagRunInfo.interval(DataInterval(next_start, next_start.add(days=1)))

class MarketHoursPlugin(AirflowPlugin):
    name = "market_hours_timetable"
    timetables = [MarketHoursTimetable]
Output
Registered timetable: MarketHoursTimetable
📊 Production Insight
Timetables replace cron for irregular calendars.
Market hours, quarter-end, fiscal weeks — all custom logic.
Register timetables as plugins and unit-test their math.
🎯 Key Takeaway
Custom schedules live in a Timetable class.
Cron can't express most real calendars.
Test your timetable's interval math before production.
● Production incidentPOST-MORTEMseverity: high

The DAG That Ran a Month Late

Symptom
The team deployed a daily revenue DAG at noon, and within minutes the grid view showed 30 runs. The scheduler had backfilled every missed interval since the start_date — a full month of runs queued at once.
Assumption
The team assumed start_date was simply "when the DAG first runs" and set it a month in the past so no run would be missed while the DAG was in review. They never expected old runs to fire.
Root cause
catchup=True with a past start_date tells the scheduler to reconcile every missed interval at once. The DAG ran a month's worth of runs in minutes instead of one run a day.
Fix
Set catchup=False on the DAG, moved start_date to the intended go-live date, and used airflow dags backfill with explicit --start-date and --end-date when a historical run was genuinely needed.
Key lesson
  • catchup=True with a past start_date backfills every missed interval at once — that's the month-late DAG.
  • start_date anchors the first data interval; the first run fires after start_date plus one interval, never at start_date.
  • Treat catchup as an explicit backfill tool, not a safety net.
  • Test with a recent start_date and catchup=False before enabling anything in production.
Production debug guideSymptom to Action5 entries
Symptom · 01
DAG fires a flood of backfilled runs on deploy
Fix
Inspect the catchup flag and start_date in the DAG file, then run airflow dags list-runs <dag_id> to see how many runs were queued at once.
Symptom · 02
DAG never runs at the expected time
Fix
Verify schedule, timezone, and start_date with airflow dags next-execution <dag_id> and check the schedule section in airflow dags show.
Symptom · 03
First run doesn't fire on the day you expected
Fix
Remember the first run fires after start_date plus one full interval; confirm the computed window with the next-execution CLI before debugging anything else.
Symptom · 04
Runs shift by an hour after daylight saving
Fix
Check default_timezone in airflow.cfg; schedule in UTC wall-clock time and translate to local time only in the UI.
Symptom · 05
DAG runs at the wrong frequency
Fix
Re-read the cron expression field order — Airflow uses standard five-field cron with minute first, and getting hour/minute swapped doubles the rate.
★ Quick Debug Cheat SheetCommon scheduling issues and immediate actions.
DAG fires a flood of backfilled runs
Immediate action
Check catchup and start_date
Commands
airflow dags list-runs daily_revenue_rollup
airflow dags show daily_revenue_rollup
Fix now
Set catchup=False and clear the stray runs.
DAG never runs on time+
Immediate action
Ask the scheduler what's next
Commands
airflow dags next-execution daily_revenue_rollup
airflow config get-value core default_timezone
Fix now
Fix the schedule or timezone; re-check next-execution.
First run never fired+
Immediate action
Verify interval math
Commands
airflow dags next-execution daily_revenue_rollup
airflow dags list-runs daily_revenue_rollup
Fix now
First run fires one interval after start_date — nothing is broken.
Runs shifted after daylight saving+
Immediate action
Check timezone config
Commands
airflow config get-value core default_timezone
airflow dags next-execution daily_revenue_rollup
Fix now
Schedule in UTC; translate to local time only for display.
Schedule Options Compared
OptionSemanticsBest for
schedule=NoneNo automated runs; manual/API/external triggers onlyOn-demand jobs and event-driven pipelines
Presets (@daily, @hourly)Sugar over fixed cron expressionsMost daily/hourly batch pipelines
Cron expression ("0 6 *")Exact five-field minute-level controlSchedules needing specific times
@continuousBack-to-back intervals with no gapNear-real-time processing streams
Custom TimetableProgrammatic interval logic, fully testableMarket hours, fiscal calendars, holidays
Dataset/asset scheduleRuns when upstream data updates, not on a clockEvent-driven downstream pipelines
⚙ Quick Reference
3 commands from this guide
FileCommand / CodePurpose
daily_revenue_rollup.pyfrom datetime import datetime2. Cron Syntax and Presets
nightly_orders.pyfrom airflow.decorators import dag, task5. Data Intervals
market_hours_timetable.pyfrom airflow.plugins_manager import AirflowPlugin7. Timetables for Custom Schedules

Key takeaways

1
A schedule generates data intervals; every run processes one interval that just ended.
2
start_date anchors the first interval
the first run fires after start_date plus one interval.
3
catchup=True with a past start_date backfills every missed interval at once; keep it False in production.
4
Run historical work explicitly with airflow dags backfill and bounded date ranges.
5
Schedule in UTC to keep DST and timezone drift out of your pipeline.

Common mistakes to avoid

4 patterns
×

Leaving catchup=True on after an initial backfill

Symptom
Every deploy or DAG re-enable floods the queue with months of old intervals
Fix
Set catchup=False as the permanent default; enable it only during deliberate backfills.
×

Setting start_date in the past to "be safe"

Symptom
The DAG fires a cascade of backfilled runs, or silently skips data you assumed would run
Fix
Set start_date to one interval before the first data you actually want processed.
×

Scheduling in local wall-clock time

Symptom
Runs drift by an hour twice a year and occasionally double-fire across DST
Fix
Keep default_timezone=utc and express schedules in UTC wall-clock time.
×

Misreading cron field order (minute vs hour)

Symptom
The DAG runs at 6 minutes past midnight instead of 06:00, or twice as often as intended
Fix
Use presets until you need precision, and sanity-check with airflow dags next-execution.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What does start_date do in Airflow?
Q02SENIOR
Explain catchup and data intervals. A @daily DAG with start_date two day...
Q03SENIOR
Design a backfill policy for a production DAG whose start_date is 60 day...
Q01 of 03JUNIOR

What does start_date do in Airflow?

ANSWER
start_date anchors the first data interval of a DAG. It is not when the DAG first runs — the first run fires after start_date plus one full interval. With catchup=False, older intervals are skipped rather than executed.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Why did my DAG run 30 times right after I deployed it?
02
What is the difference between start_date and data_interval_start?
03
Should catchup be True or False?
04
Does Airflow schedule in UTC or local time?
05
How do I backfill a specific date range?
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
August 01, 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 Operators Basics
5 / 37 · Airflow
Next
Airflow Task Lifecycle