Wicked Smart Data
LearnInsightsAboutContact
Sign InLet's Build
LearnInsightsAboutContact
Sign InLet's Build
Wicked Smart Data

Intelligence, automation, and expert execution — plus an elite library of free knowledge. We turn complexity into competitive advantage.

Start a conversation

Platform

  • Learning Paths
  • Insights
  • RSS Feed

Company

  • About
  • Contact
  • Work With Us

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Wicked Smart Data. All rights reserved.

Intelligence · Automation · Advantage

All Insights
Data Engineering

Scheduling and Backfilling Historical Data Loads in Airflow: Catchup, DAG Runs, and Idempotent Pipeline Design

Learn how Airflow's interval-based scheduling model really works — and how to safely backfill months of historical data without duplicating records. This hands-on lesson covers catchup behavior, the backfill CLI, and the idempotent design patterns every data pipeline needs.

🌱 Foundation16 min readAug 9, 2026Updated Aug 29, 2026
Scheduling and Backfilling Historical Data Loads in Airflow: Catchup, DAG Runs, and Idempotent Pipeline Design
On this page
  • Introduction
  • Prerequisites
  • How Airflow Thinks About Time
  • DAG Runs: The Unit of Scheduling
  • Understanding `catchup`
  • The `max_active_runs` Safety Valve
  • Running a Backfill Deliberately
  • Useful Backfill Flags
  • Why Idempotency Is Non-Negotiable
  • The Non-Idempotent Trap
  • The Idempotent Fix: Delete-Then-Insert
  • The Even Better Fix: Upsert (MERGE)
  • Idempotency in File-Based Tasks
  • Using `ds` and Execution Date Macros in Your Tasks
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Summary & Next Steps
  • Scheduling and Backfilling Historical Data Loads in Airflow: Catchup, DAG Runs, and Idempotent Pipeline Design

    Introduction

    Imagine you're a data engineer at a retail company. Your team just finished building a pipeline that loads daily sales data from your point-of-sale system into a data warehouse. You flip the switch, Airflow starts running it every morning at 6 AM, and life is good — for about two weeks.

    Then your manager asks: "Can we also get the last 18 months of historical data in there? We need it for the annual review." Suddenly you're not just thinking about tomorrow's run. You need to load hundreds of past days of data, do it reliably, and do it without duplicating records you may have already loaded or accidentally skipping gaps. This is the moment when your understanding of how Airflow thinks about time and scheduling becomes the difference between a clean warehouse and a disaster.

    This lesson will give you a thorough, practical understanding of how Apache Airflow handles scheduling, historical backfills, and the design patterns that make pipelines safe to run more than once. By the end, you'll be able to confidently configure Airflow's scheduling behavior, trigger backfills on demand, and build pipelines that won't corrupt your data even when things go sideways.

    What you'll learn:

    • How Airflow's execution date model works and why it's different from what you might expect
    • What catchup does and when to enable or disable it
    • How to trigger a backfill from the command line and what happens under the hood
    • What idempotency means and why it's non-negotiable for reliable pipelines
    • How to write tasks that can safely run multiple times without duplicating or corrupting data

    Prerequisites

    • You have Apache Airflow installed (version 2.x) and can access its web UI
    • You understand what a DAG is and have written at least one basic pipeline
    • You're comfortable with Python syntax
    • Familiarity with a SQL database (even SQLite) will help with the examples

    How Airflow Thinks About Time

    Before we talk about backfilling, you need to understand something counterintuitive about how Airflow schedules work. Most people assume that when a DAG is scheduled to run at midnight, it runs at midnight for that day's data. Airflow does something different, and if you don't understand it, backfilling will confuse you completely.

    Airflow uses a concept called the execution date (called logical_date in Airflow 2.2+, though execution_date still works). Here's the key insight: Airflow runs a DAG at the end of the interval it covers, not the beginning.

    Let's make that concrete. You have a DAG with schedule_interval='@daily' and a start_date of 2024-01-01. The first DAG run fires on January 2nd at midnight — but its execution_date is January 1st. Airflow is saying: "This run is responsible for processing the data from the January 1st interval."

    Think of it like a night security guard. The guard who shows up Monday morning is reporting on what happened Sunday night. The execution date is Sunday night; the actual trigger time is Monday morning.

    from airflow import DAG
    from airflow.operators.python import PythonOperator
    from datetime import datetime
    
    def log_execution_context(**context):
        execution_date = context['execution_date']
        next_execution_date = context['next_execution_date']
        print(f"This run covers: {execution_date}")
        print(f"The next run will cover: {next_execution_date}")
        print(f"Actual time this task started: {datetime.utcnow()}")
    
    with DAG(
        dag_id='time_model_demo',
        start_date=datetime(2024, 1, 1),
        schedule_interval='@daily',
        catchup=False,
    ) as dag:
        explain_time = PythonOperator(
            task_id='explain_time',
            python_callable=log_execution_context,
        )
    

    When you run this DAG today and look at the logs, you'll see the execution date is yesterday — Airflow is processing yesterday's interval, even though it ran this morning. This interval-based thinking is exactly what makes backfilling possible and coherent.


    DAG Runs: The Unit of Scheduling

    A DAG Run is a single execution of your entire pipeline for a specific time interval. Every time Airflow triggers your DAG — whether on a schedule, manually, or through a backfill — it creates a DAG Run with a unique execution_date.

    You can see all DAG Runs for a given DAG in the Airflow UI by clicking on the DAG's name in the main dashboard, then navigating to the "Runs" tab. Each row represents one DAG Run, showing its status (running, success, failed), the execution date it covers, and when it actually started.

    DAG Runs have three possible origins:

    • Scheduled: Created automatically by the Airflow scheduler based on your schedule_interval
    • Manual: Triggered by you via the UI or CLI with a specific execution date
    • Backfill: Created programmatically to cover a range of past dates

    Understanding these origins matters because Airflow will not create a duplicate DAG Run for the same execution_date. That's a safety mechanism — it's how you avoid processing the same day's data twice by accident.


    Understanding `catchup`

    Now we get to catchup, which is the single most important setting for controlling what Airflow does when you deploy a new DAG or bring one back online after a pause.

    Here's the scenario: You deploy a DAG on March 15th with a start_date of January 1st. Airflow looks at that gap — January 1st through March 14th — and asks: "Should I create DAG Runs for all those missed intervals?"

    If catchup=True (which is Airflow's default), the answer is yes. Airflow will immediately queue up DAG Runs for every single day between January 1st and March 14th. That's 74 DAG Runs firing at once.

    If catchup=False, Airflow ignores the historical gap and starts scheduling from the current date forward. You get one run, not 74.

    from airflow import DAG
    from airflow.operators.python import PythonOperator
    from datetime import datetime
    
    # This DAG will NOT create runs for past intervals
    with DAG(
        dag_id='sales_daily_load',
        start_date=datetime(2024, 1, 1),
        schedule_interval='@daily',
        catchup=False,   # <-- Start fresh, don't backfill automatically
        max_active_runs=1,
    ) as dag:
        load_sales = PythonOperator(
            task_id='load_daily_sales',
            python_callable=lambda **ctx: print(f"Loading sales for {ctx['ds']}"),
        )
    

    Tip

    catchup=False is the right default for most production pipelines. Automatic catchup flooding your task queue with hundreds of simultaneous runs is a common source of Airflow cluster meltdowns. When you actually need to backfill, do it deliberately with the backfill command — you'll have much more control.

    When should you use catchup=True? When your pipeline is explicitly designed to process one interval at a time, your tasks are idempotent (we'll cover this shortly), your infrastructure can handle the parallelism, and you want Airflow to automatically self-heal any missed runs without manual intervention. Some teams use it for audit pipelines or data quality checks where gaps are simply not acceptable.

    The `max_active_runs` Safety Valve

    If you do use catchup=True, pair it with max_active_runs. This parameter limits how many DAG Runs from the same DAG can execute simultaneously.

    with DAG(
        dag_id='sales_daily_load',
        start_date=datetime(2024, 1, 1),
        schedule_interval='@daily',
        catchup=True,
        max_active_runs=3,  # Never more than 3 historical runs at once
    ) as dag:
        ...
    

    With max_active_runs=3, Airflow will process historical backlog runs three at a time, queuing the rest. Your workers won't get crushed.


    Running a Backfill Deliberately

    Here's the right way to handle that "we need 18 months of historical data" request from your manager: use the Airflow CLI's backfill command. This gives you explicit control over the date range, parallelism, and whether to re-run already-successful runs.

    The basic syntax is:

    airflow dags backfill \
        --start-date 2023-01-01 \
        --end-date 2023-12-31 \
        sales_daily_load
    

    This command tells Airflow to create and execute DAG Runs for every scheduled interval between January 1st and December 31st, 2023. For a daily DAG, that's 365 runs.

    What actually happens under the hood: Airflow iterates through every execution date in the range, checks if a successful DAG Run already exists for that date, and if not, creates one and runs it. If a successful run already exists for a date, Airflow skips it by default — another safety mechanism.

    Useful Backfill Flags

    # Run at most 4 parallel task instances at once
    airflow dags backfill \
        --start-date 2023-01-01 \
        --end-date 2023-12-31 \
        --max-jobs 4 \
        sales_daily_load
    
    # Force re-run even if successful runs exist (use with caution)
    airflow dags backfill \
        --start-date 2023-06-01 \
        --end-date 2023-06-30 \
        --reset-dagruns \
        sales_daily_load
    
    # Do a dry run - see what would execute without actually running anything
    airflow dags backfill \
        --start-date 2023-01-01 \
        --end-date 2023-01-07 \
        --dry-run \
        sales_daily_load
    

    Warning

    The --reset-dagruns flag will delete existing DAG Runs in that date range before recreating them. Use this carefully in production — it will re-process data that was already successfully loaded, which can create duplicates unless your tasks are idempotent.


    Why Idempotency Is Non-Negotiable

    At this point you've seen the word "idempotent" come up twice. Let's actually define it.

    A task is idempotent if running it multiple times with the same inputs produces the same result as running it once. If you run the same DAG Run for January 15th five times — maybe because of failures, retries, or accidental re-runs — your data warehouse should end up in exactly the same state as if you'd run it once.

    This is not optional. Here's why.

    Airflow retries failed tasks. Backfills occasionally need to be re-run due to upstream data fixes. Engineers accidentally trigger DAG Runs twice. Bugs happen. If your tasks aren't idempotent, every one of these situations corrupts your data.

    The Non-Idempotent Trap

    Here's a task that looks reasonable but will destroy you:

    def load_daily_sales(**context):
        ds = context['ds']  # execution date as string, e.g. '2024-01-15'
        
        # Fetch sales data for that day
        sales_records = fetch_from_api(date=ds)
        
        # Insert into the warehouse
        db.execute(
            "INSERT INTO sales_fact SELECT * FROM staging_sales WHERE sale_date = %s",
            (ds,)
        )
    

    Run this twice for January 15th and you'll have two copies of every January 15th sale in your warehouse. Your revenue totals will double. Your finance team will not be amused.

    The Idempotent Fix: Delete-Then-Insert

    The most common pattern for making SQL-based loads idempotent is to delete the data for that interval before inserting it:

    def load_daily_sales(**context):
        ds = context['ds']
        
        sales_records = fetch_from_api(date=ds)
        
        # Step 1: Remove any existing data for this date
        db.execute(
            "DELETE FROM sales_fact WHERE sale_date = %s",
            (ds,)
        )
        
        # Step 2: Insert fresh data
        db.execute(
            "INSERT INTO sales_fact SELECT * FROM staging_sales WHERE sale_date = %s",
            (ds,)
        )
    

    Now you can run this ten times for the same date. The final state is always: exactly one copy of that day's sales.

    The Even Better Fix: Upsert (MERGE)

    In modern data warehouses (Snowflake, BigQuery, Redshift, Postgres 9.5+), you often have access to upsert or merge operations, which update existing rows if they exist and insert them if they don't:

    def load_daily_sales(**context):
        ds = context['ds']
        
        # This MERGE handles both new and re-run scenarios cleanly
        db.execute("""
            MERGE INTO sales_fact AS target
            USING staging_sales AS source
                ON target.sale_id = source.sale_id
                   AND target.sale_date = %s
            WHEN MATCHED THEN
                UPDATE SET
                    amount = source.amount,
                    customer_id = source.customer_id
            WHEN NOT MATCHED THEN
                INSERT (sale_id, sale_date, amount, customer_id)
                VALUES (source.sale_id, source.sale_date, source.amount, source.customer_id)
        """, (ds,))
    

    This is elegant but requires your records to have a stable unique key (sale_id in this case). If you can guarantee that, MERGE is your best friend for idempotent loads.

    Idempotency in File-Based Tasks

    Not all tasks are SQL. If your task writes a file to S3 or a filesystem, idempotency means overwriting, not appending:

    def export_daily_report(**context):
        ds = context['ds']
        
        report_data = generate_report(date=ds)
        
        # Writing to a deterministic path means re-runs just overwrite
        s3_key = f"reports/daily/sales_report_{ds}.parquet"
        
        s3_client.put_object(
            Bucket='my-data-bucket',
            Key=s3_key,
            Body=report_data.to_parquet()
        )
        # Uploading twice to the same key? Same result. Idempotent.
    

    The key insight here is that the output path includes the execution date (ds), making it deterministic. If this task runs again for the same date, it writes to the same path and overwrites the existing file. No duplicates.


    Using `ds` and Execution Date Macros in Your Tasks

    Airflow provides a set of template variables (also called Jinja macros) that let you inject the execution date and related values directly into your task logic. This is how you make a task genuinely interval-aware.

    The most commonly used ones:

    Variable Description Example
    {{ ds }} Execution date as YYYY-MM-DD 2024-01-15
    {{ ds_nodash }} Execution date without dashes 20240115
    {{ execution_date }} Full datetime object 2024-01-15T00:00:00+00:00
    {{ next_ds }} Next execution date as YYYY-MM-DD 2024-01-16
    {{ prev_ds }} Previous execution date as YYYY-MM-DD 2024-01-14

    In Python operators, you access these through the context dictionary. In SQL-templated operators like PostgresOperator, you can use the Jinja syntax directly:

    from airflow.providers.postgres.operators.postgres import PostgresOperator
    
    load_sales_task = PostgresOperator(
        task_id='load_sales_to_warehouse',
        postgres_conn_id='warehouse_postgres',
        sql="""
            DELETE FROM sales_fact
            WHERE sale_date = '{{ ds }}';
            
            INSERT INTO sales_fact (sale_id, sale_date, amount, customer_id)
            SELECT sale_id, sale_date, amount, customer_id
            FROM sales_staging
            WHERE sale_date = '{{ ds }}';
        """,
    )
    

    The {{ ds }} gets substituted with the actual execution date at runtime. This single template makes the delete-then-insert pattern work across all backfill dates automatically — run it for January 1st, and {{ ds }} becomes 2024-01-01. Run it for March 15th, and it becomes 2024-03-15. One piece of code, all dates handled correctly.


    Hands-On Exercise

    Let's build a complete, backfill-ready pipeline from scratch. You'll create a DAG that simulates loading daily sales summaries and verify that it behaves idempotently.

    Setup: Create a SQLite database file at /tmp/sales_warehouse.db.

    # dags/sales_backfill_exercise.py
    
    from airflow import DAG
    from airflow.operators.python import PythonOperator
    from datetime import datetime
    import sqlite3
    import random
    
    DB_PATH = '/tmp/sales_warehouse.db'
    
    def initialize_database():
        """Create the target table if it doesn't exist."""
        conn = sqlite3.connect(DB_PATH)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS daily_sales_summary (
                sale_date TEXT PRIMARY KEY,
                total_revenue REAL,
                transaction_count INTEGER,
                loaded_at TEXT
            )
        """)
        conn.commit()
        conn.close()
    
    def load_daily_summary(**context):
        """Idempotent load of daily sales summary."""
        ds = context['ds']
        
        # Simulate fetching aggregated data from a source system
        # In reality, this would be an API call or a read from a source DB
        random.seed(ds)  # Same seed = same data every time for same date
        total_revenue = round(random.uniform(10000, 50000), 2)
        transaction_count = random.randint(100, 800)
        
        conn = sqlite3.connect(DB_PATH)
        
        # Idempotent: DELETE existing record for this date, then INSERT
        conn.execute(
            "DELETE FROM daily_sales_summary WHERE sale_date = ?",
            (ds,)
        )
        conn.execute(
            """INSERT INTO daily_sales_summary 
               (sale_date, total_revenue, transaction_count, loaded_at)
               VALUES (?, ?, ?, datetime('now'))""",
            (ds, total_revenue, transaction_count)
        )
        
        conn.commit()
        
        # Verify the insert
        result = conn.execute(
            "SELECT * FROM daily_sales_summary WHERE sale_date = ?", (ds,)
        ).fetchone()
        
        conn.close()
        print(f"Successfully loaded: {result}")
    
    def verify_no_duplicates(**context):
        """Audit task: fail if any date has duplicate records."""
        conn = sqlite3.connect(DB_PATH)
        
        duplicates = conn.execute("""
            SELECT sale_date, COUNT(*) as cnt
            FROM daily_sales_summary
            GROUP BY sale_date
            HAVING cnt > 1
        """).fetchall()
        
        conn.close()
        
        if duplicates:
            raise ValueError(f"Duplicate records found: {duplicates}")
        
        print("Audit passed: No duplicate records in daily_sales_summary")
    
    with DAG(
        dag_id='sales_backfill_exercise',
        start_date=datetime(2024, 1, 1),
        schedule_interval='@daily',
        catchup=False,
        max_active_runs=2,
        tags=['exercise', 'backfill'],
    ) as dag:
        
        init_db = PythonOperator(
            task_id='initialize_database',
            python_callable=initialize_database,
        )
        
        load_summary = PythonOperator(
            task_id='load_daily_summary',
            python_callable=load_daily_summary,
        )
        
        audit_duplicates = PythonOperator(
            task_id='verify_no_duplicates',
            python_callable=verify_no_duplicates,
        )
        
        init_db >> load_summary >> audit_duplicates
    

    Step 1: Drop this file in your Airflow dags/ folder and let it be detected (usually within 30 seconds).

    Step 2: Run a backfill for January 2024:

    airflow dags backfill \
        --start-date 2024-01-01 \
        --end-date 2024-01-31 \
        sales_backfill_exercise
    

    Step 3: Verify the data loaded correctly:

    sqlite3 /tmp/sales_warehouse.db \
        "SELECT COUNT(*), MIN(sale_date), MAX(sale_date) FROM daily_sales_summary;"
    

    You should see 31 rows, from 2024-01-01 to 2024-01-31.

    Step 4: Run the backfill again for the same range (simulating a re-run):

    airflow dags backfill \
        --start-date 2024-01-01 \
        --end-date 2024-01-31 \
        --reset-dagruns \
        sales_backfill_exercise
    

    Step 5: Check again. You should still see exactly 31 rows. The idempotent delete-then-insert protected you.


    Common Mistakes & Troubleshooting

    "My backfill created hundreds of runs and crashed my Airflow cluster."

    You ran backfill without --max-jobs on a large date range, or your DAG has catchup=True with no max_active_runs. Restart with --max-jobs 4 and add max_active_runs=3 to your DAG definition. Always test backfills on a short range first.

    "The backfill skipped some dates that actually need to be re-run."

    By default, backfill won't re-run dates that already have a successful DAG Run. If upstream data was corrected and you need to reload those dates, use --reset-dagruns. Be intentional — this deletes those run records before recreating them.

    "My DAG ran and loaded data, but the execution date is a day behind what I expected."

    This is the interval model at work. Airflow's execution date represents the start of the interval, and the run fires at the end. A daily DAG with execution date 2024-01-15 runs on January 16th. If you want to query data from "today," use {{ next_ds }} — it represents the end boundary of the current interval.

    "I set start_date=datetime.now() and the DAG never runs."

    This is a very common mistake. start_date should always be a fixed past date, not a dynamic one. When Airflow calculates when to start scheduling, it needs a stable anchor. datetime.now() changes every time the DAG file is parsed, and Airflow gets confused. Use datetime(2024, 1, 1) or a similar hardcoded date.

    "My backfill is running but tasks are failing with duplicate key errors."

    Your tasks aren't idempotent. You're inserting without deleting first, and the re-run is trying to insert records that already exist. Add the DELETE step before each INSERT, or switch to an UPSERT/MERGE pattern.


    Summary & Next Steps

    You now have a solid mental model for one of the trickier aspects of Airflow: the relationship between time, scheduling, and data correctness.

    Here's what you've learned:

    • Airflow's execution date represents the start of the interval a DAG Run covers, not the moment it actually runs — the run fires at the end of the interval
    • catchup=True automatically creates DAG Runs for all past intervals since start_date; catchup=False starts fresh from today
    • max_active_runs is the safety valve that prevents catchup or backfill from overwhelming your infrastructure
    • The airflow dags backfill command lets you deliberately load historical date ranges with full control over parallelism and re-run behavior
    • Idempotency — ensuring a task produces the same result whether run once or ten times — is the design principle that makes backfilling safe
    • The delete-then-insert pattern and MERGE/UPSERT are your primary tools for idempotent SQL loads
    • Airflow's {{ ds }} and related template variables are what bind your task logic to specific time intervals

    Next steps to deepen your skills:

    • Explore Airflow Datasets (introduced in Airflow 2.4) for data-aware scheduling — triggering DAGs when upstream data is ready rather than on a clock
    • Learn about SLAs and alerts in Airflow so you're automatically notified when scheduled DAGs run late
    • Investigate incremental loading patterns with {{ prev_ds }} and {{ ds }} to load only the changed data in each interval, rather than full refreshes
    • Study partitioned tables in your data warehouse and how execution-date-based partitioning aligns perfectly with Airflow's interval model
    Work With Us

    From insight to implementation

    Reading is the start. When you're ready to build the data, automation, or AI systems behind it, our team turns strategy into shipped results.

    Let's Build

    Modern Data Stack

    Previous

    Deploying a Self-Serve Analytics Platform with dbt Exposures, a Semantic Layer, and Governed Tableau or Looker Access

    Next

    Building and Managing dbt Packages: Reusable Macros, Models, and Tests Across Projects

    Related Insights

    Data EngineeringExpert

    Implementing Pipeline-Level Idempotent Upserts with MERGE Statements Across Snowflake, BigQuery, and Redshift: Patterns, Pitfalls, and Performance Tuning

    27 min
    Data EngineeringPractitioner

    Implementing Slowly Changing Dimensions Type 6 and Hybrid SCD Strategies in dbt: Combining Historical Accuracy with Current-State Query Performance

    23 min
    Data EngineeringPractitioner

    Implementing Watermark-Based Windowing in Streaming Pipelines: Tumbling, Sliding, and Session Windows

    22 min

    On this page

    • Introduction
    • Prerequisites
    • How Airflow Thinks About Time
    • DAG Runs: The Unit of Scheduling
    • Understanding `catchup`
    • The `max_active_runs` Safety Valve
    • Running a Backfill Deliberately
    • Useful Backfill Flags
    • Why Idempotency Is Non-Negotiable
    • The Non-Idempotent Trap
    • The Idempotent Fix: Delete-Then-Insert
    • The Even Better Fix: Upsert (MERGE)
    • Idempotency in File-Based Tasks
    • Using `ds` and Execution Date Macros in Your Tasks
    • Hands-On Exercise
    • Common Mistakes & Troubleshooting
    • Summary & Next Steps