Skip to content
Codeloom
Airflow

Real-World Airflow Patterns for Production Pipelines

Idempotent pipelines, backfilling, late data handling, error patterns, multi-environment setups, and common anti-patterns to avoid in Airflow.

·15 min read · By Codeloom
Advanced 22 min read

What you'll learn

  • Design idempotent pipelines that produce correct results on every retry
  • Backfill historical data safely without breaking production
  • Handle late-arriving data with grace periods and reconciliation
  • Implement error handling patterns: retries, callbacks, SLA monitoring
  • Set up multi-environment workflows (dev/staging/prod)
  • Recognize and avoid the most common Airflow anti-patterns

Prerequisites

  • Experience writing and deploying Airflow DAGs
  • Understanding of Airflow scheduling and execution
  • Familiarity with data warehousing concepts

Why Patterns Matter

The difference between an Airflow deployment that hums along reliably and one that produces constant firefighting is not the complexity of the DAGs — it is the patterns. Teams that follow proven patterns sleep through the night. Teams that wing it get paged at 3 AM because a retry created duplicate records, a backfill overwrote production data, or a late-arriving file triggered a cascade of failures.

This guide distills the patterns that experienced data engineering teams have developed through years of production Airflow operations. Each pattern addresses a specific failure mode, and each comes with the “why” behind the design — not just the code.

Pattern 1: Idempotent Pipelines

Idempotency is the single most important property of a production pipeline. An idempotent pipeline produces the same result whether you run it once, twice, or ten times with the same input. This sounds academic until you consider the reality of production: tasks fail and get retried, pipelines get re-run during debugging, backfills re-execute historical dates. If any of these produce incorrect results (duplicates, missing data, corrupted state), you have a data quality problem.

Why Tasks Get Re-Run

Understanding why idempotency matters requires understanding how often tasks actually re-run in production:

  • Automatic retries. A task fails due to a transient network error and Airflow retries it per the retries config. If the task partially completed (inserted some rows before the error), the retry runs the task again with the same input.
  • Manual re-runs. An engineer discovers a bug in the transformation logic, fixes it, and clears the task to re-run for the affected dates.
  • Backfills. You need to reprocess the last 30 days because a data source was corrected retroactively.
  • Scheduler quirks. In rare cases, scheduling edge cases can cause a task to execute more than once for the same logical date.

In all these scenarios, the task receives the same execution date and should produce the same output regardless of how many times it runs.

The Delete-Then-Insert Pattern

The simplest idempotent write pattern: delete all existing data for the partition, then insert the new data. If the task runs twice, the second run deletes the first run’s output before inserting, resulting in exactly one copy of the data.

@task
def load_orders(**context):
    hook = PostgresHook("warehouse")
    ds = context["ds"]

    # Step 1: Delete existing data for this partition
    hook.run(f"DELETE FROM orders WHERE partition_date = '{ds}'")

    # Step 2: Insert fresh data
    records = extract_orders(ds)
    hook.insert_rows("orders", records)

This pattern works because the delete and insert together are a complete replacement. Running it once produces the correct data. Running it again deletes and re-inserts the same data. The end state is identical.

The MERGE/UPSERT Pattern

For tables that cannot afford the brief window of missing data between delete and insert (customer-facing tables, for example), use MERGE (SQL Server), INSERT ... ON CONFLICT (PostgreSQL), or MERGE INTO (BigQuery):

@task
def upsert_user_profiles(**context):
    hook = PostgresHook("warehouse")
    ds = context["ds"]
    records = extract_profiles(ds)

    for record in records:
        hook.run(f"""
            INSERT INTO user_profiles (user_id, name, email, updated_at)
            VALUES ('{record['user_id']}', '{record['name']}',
                    '{record['email']}', '{ds}')
            ON CONFLICT (user_id)
            DO UPDATE SET
                name = EXCLUDED.name,
                email = EXCLUDED.email,
                updated_at = EXCLUDED.updated_at
        """)

The Staging Table Pattern

For large data volumes, the staging table pattern avoids expensive row-by-row upserts. Load data into a temporary staging table, then swap it into the target in a single atomic operation:

@task
def load_with_staging(**context):
    hook = PostgresHook("warehouse")
    ds = context["ds"]

    # Create staging table
    hook.run(f"""
        CREATE TEMP TABLE staging_orders AS
        SELECT * FROM orders WHERE 1=0
    """)

    # Bulk load into staging
    records = extract_orders(ds)
    hook.insert_rows("staging_orders", records)

    # Atomic swap: delete target partition, insert from staging
    hook.run(f"""
        BEGIN;
        DELETE FROM orders WHERE partition_date = '{ds}';
        INSERT INTO orders SELECT * FROM staging_orders;
        COMMIT;
    """)

The transaction ensures that the delete and insert are atomic — if anything fails, both operations roll back, leaving the original data intact.

Pattern 2: Backfilling Strategies

Backfilling is the process of running a pipeline for historical dates. You need it when a data source is corrected retroactively, when you fix a bug and need to reprocess affected dates, or when you add a new pipeline that needs to process existing data.

Using catchup=True for Initial Backfills

Airflow’s built-in catchup mechanism is the simplest backfill strategy. Set catchup=True and Airflow creates a DAG run for every missed schedule interval between start_date and now:

@dag(
    start_date=datetime(2026, 6, 1),
    schedule="@daily",
    catchup=True,  # Will create runs for June 1 through today
    max_active_runs=3,  # Limit concurrency to avoid overwhelming resources
)
def backfill_pipeline():
    ...

The max_active_runs parameter is critical during backfills. Without it, Airflow tries to run all historical dates simultaneously, which can overwhelm your database, your API rate limits, and your cluster resources.

Using the CLI for Targeted Backfills

For backfilling a specific date range without modifying the DAG configuration:

# Backfill a specific date range
airflow dags backfill \
  --start-date 2026-06-01 \
  --end-date 2026-06-30 \
  --reset-dagruns \
  daily_revenue

# With parallelism control
airflow dags backfill \
  --start-date 2026-06-01 \
  --end-date 2026-06-30 \
  --max-active-runs 2 \
  daily_revenue

The --reset-dagruns flag clears existing DAG runs for the date range and creates fresh ones. Without it, Airflow skips dates that already have a completed DAG run.

Backfill Safety Practices

Always backfill to a staging environment first. Run the backfill against a staging database or a separate schema, verify the results, then promote to production. This catches bugs in the backfill logic before they affect production data.

Monitor resource usage during backfills. Backfills generate heavy load — many parallel DAG runs, many database queries, many API calls. Watch your database connection count, API rate limits, and worker utilization.

Use a dedicated pool for backfills. Prevent backfill runs from starving production pipelines by assigning backfill tasks to a pool with limited slots:

@task(pool="backfill_pool")  # Pool with 5 slots -- limits concurrent backfills
def extract(**context):
    ...

Pattern 3: Handling Late-Arriving Data

In an ideal world, all data for a given day arrives before midnight. In the real world, data sources are late. A partner sends yesterday’s transaction file at 10 AM instead of midnight. A mobile app’s analytics events trickle in for 48 hours as devices come back online. A third-party API reports data with a 24-hour delay.

The Grace Period Pattern

Run the pipeline with a delay to give late data time to arrive:

@dag(
    schedule="0 6 * * *",  # Run at 6 AM, processing yesterday's data
    ...
)
def with_grace_period():
    @task
    def extract(**context):
        # ds is "yesterday" because schedule runs at 6 AM for previous day
        ds = context["ds"]
        # By 6 AM, most late data has arrived
        return fetch_data(ds)

The 6-hour grace period (running at 6 AM for data from the previous day) gives late data time to arrive. Adjust the delay based on your data source’s latency characteristics.

The Reconciliation Pattern

For data that continues arriving for days, run a reconciliation DAG that re-processes recent dates:

@dag(
    dag_id="reconcile_orders",
    schedule="0 12 * * *",  # Run daily at noon
    ...
)
def reconcile():
    @task
    def reprocess_recent_days(**context):
        """Re-process the last 3 days to capture late-arriving data."""
        from datetime import timedelta
        today = context["logical_date"]

        for offset in range(1, 4):  # Yesterday, day before, 3 days ago
            target_date = (today - timedelta(days=offset)).strftime("%Y-%m-%d")
            records = fetch_all_data(target_date)
            idempotent_load(records, target_date)

    reprocess_recent_days()

This pattern relies on idempotent loading (Pattern 1). Each reconciliation run replaces the data for recent days with the most complete version available. Over time, the data converges to its final, correct state.

Combining Grace Period + Reconciliation

The most robust approach uses both:

  1. Primary pipeline runs with a grace period (e.g., 6 AM for yesterday’s data) and captures 95% of records
  2. Reconciliation pipeline runs later (e.g., noon) and re-processes the last N days to capture the remaining 5%

This gives you timely data for most consumers while ensuring completeness for those who need it.

Pattern 4: Error Handling

Retry Configuration

Not all tasks should retry the same way. A task that calls a rate-limited API needs exponential backoff. A task that reads a file needs to retry quickly. A task that sends a notification should not retry at all (you do not want to send the same alert five times).

from datetime import timedelta

default_args = {
    "retries": 3,
    "retry_delay": timedelta(minutes=5),
}

@dag(default_args=default_args, ...)
def pipeline():

    # API task: exponential backoff
    @task(
        retries=5,
        retry_delay=timedelta(minutes=1),
        retry_exponential_backoff=True,
        max_retry_delay=timedelta(minutes=30),
    )
    def call_api():
        ...

    # Notification task: no retries (avoid duplicate alerts)
    @task(retries=0)
    def send_alert():
        ...

    # Quick file operation: retry fast
    @task(retries=3, retry_delay=timedelta(seconds=30))
    def read_config_file():
        ...

Failure Callbacks

Callbacks give you programmatic control over what happens when a task fails, succeeds, or retries. The most common use case is alerting:

def notify_on_failure(context):
    """Send a Slack message when a task fails."""
    dag_id = context["dag"].dag_id
    task_id = context["task_instance"].task_id
    log_url = context["task_instance"].log_url
    exception = context.get("exception", "Unknown")

    message = (
        f"*Task Failed*\n"
        f"DAG: `{dag_id}`\n"
        f"Task: `{task_id}`\n"
        f"Error: `{exception}`\n"
        f"<{log_url}|View Log>"
    )

    from airflow.providers.slack.hooks.slack_webhook import SlackWebhookHook
    hook = SlackWebhookHook(slack_webhook_conn_id="slack_alerts")
    hook.send(text=message)


def notify_on_sla_miss(dag, task_list, blocking_task_list, slas, blocking_tis):
    """Called when any task exceeds its SLA."""
    message = (
        f"*SLA Miss*\n"
        f"DAG: `{dag.dag_id}`\n"
        f"Tasks: {', '.join(str(t) for t in task_list)}"
    )
    # Send to PagerDuty for critical pipelines
    ...


@dag(
    default_args={"on_failure_callback": notify_on_failure},
    sla_miss_callback=notify_on_sla_miss,
    ...
)
def critical_pipeline():
    ...

The Circuit Breaker Pattern

When an upstream system is down, retrying indefinitely wastes resources and fills logs with noise. A circuit breaker stops retrying after detecting a systemic failure:

from airflow.exceptions import AirflowFailException

@task(retries=3, retry_delay=timedelta(minutes=5))
def extract_from_api(**context):
    """Fail permanently if the API is returning server errors."""
    import requests

    response = requests.get("https://api.vendor.com/data")

    if response.status_code == 503:
        # Service unavailable -- do not retry, fail permanently
        raise AirflowFailException(
            "API is returning 503 -- likely a systemic outage. "
            "Do not retry, alert the team."
        )

    response.raise_for_status()
    return response.json()

AirflowFailException immediately marks the task as failed without exhausting retries. Use it when retrying would not help — the upstream system is down, the credentials are expired, the input data is fundamentally invalid.

Pattern 5: Multi-Environment Setup

Production Airflow needs at least two environments: a development/staging environment where you test changes, and a production environment where DAGs run against real data. Most mature teams add a third (staging) for final validation.

Environment-Specific Configuration

Use Airflow Variables or environment variables to parameterize DAGs based on the environment:

import os

ENV = os.environ.get("AIRFLOW_ENV", "dev")

# Environment-specific configuration
CONFIG = {
    "dev": {
        "warehouse_conn": "warehouse_dev",
        "schedule": None,  # Manual trigger only in dev
        "retries": 0,
    },
    "staging": {
        "warehouse_conn": "warehouse_staging",
        "schedule": "@daily",
        "retries": 1,
    },
    "prod": {
        "warehouse_conn": "warehouse_prod",
        "schedule": "@daily",
        "retries": 3,
    },
}[ENV]


@dag(
    schedule=CONFIG["schedule"],
    default_args={"retries": CONFIG["retries"]},
    ...
)
def revenue_pipeline():
    @task
    def load(**context):
        hook = PostgresHook(CONFIG["warehouse_conn"])
        ...

Environment Promotion Workflow

Developer writes/modifies DAG

CI runs DAG validation tests + unit tests

Merge to `develop` branch → deploys to dev environment

QA validates in dev → merge to `staging` branch → deploys to staging

Staging runs against realistic data for 1-2 days

Merge to `main` branch → deploys to production

Each environment should have its own:

  • Airflow instance (separate scheduler, webserver, workers)
  • Metadata database
  • Connections (pointing to environment-specific databases and APIs)
  • Variables (environment-specific configuration values)

DAG Versioning

When deploying DAG changes, consider what happens to currently running DAG runs. If a DAG run started with version A of your code and you deploy version B mid-run, tasks that have not yet executed will run version B’s code. This can cause subtle inconsistencies.

For critical pipelines, use a deployment strategy that avoids this:

  1. Pause the DAG before deploying changes
  2. Wait for running DAG runs to complete
  3. Deploy the new version
  4. Unpause the DAG

Or use the Docker image bake approach where each deployment is an immutable image, and rolling back is as simple as deploying the previous image version.

Common Anti-Patterns (And How to Fix Them)

Anti-Pattern 1: The God DAG

A single DAG with 200 tasks that does everything — extraction, transformation, loading, quality checks, notifications, and reporting. It takes 4 hours to run, and a failure in task 150 means re-running 149 successful tasks to get back to the failure point.

Fix: Break the God DAG into focused DAGs connected by datasets or TriggerDagRunOperator. Each DAG should represent a single logical pipeline stage.

Anti-Pattern 2: Variable.get() at Parse Time

# BAD: Runs on every parse cycle (every 30 seconds)
api_key = Variable.get("vendor_api_key")

@task
def call_api():
    requests.get(url, headers={"Authorization": api_key})

Fix: Move variable lookups inside task functions or use Jinja templates:

# GOOD: Lookup happens at execution time, not parse time
@task
def call_api():
    api_key = Variable.get("vendor_api_key")
    requests.get(url, headers={"Authorization": api_key})

Anti-Pattern 3: Using XCom for Large Data

XCom is designed for small metadata — task IDs, file paths, row counts, status flags. Pushing a 500 MB DataFrame through XCom stores it in the metadata database, bloating the database and slowing down the scheduler.

Fix: Write large data to external storage (S3, GCS, a staging table) and pass only the reference (file path, table name) through XCom:

# BAD: Pushing large data through XCom
@task
def extract():
    return huge_dataframe.to_dict()  # Stored in metadata DB!

# GOOD: Store data externally, pass reference via XCom
@task
def extract():
    path = "s3://staging/orders/2026-07-12.parquet"
    huge_dataframe.to_parquet(path)
    return path  # Only the path goes through XCom

Anti-Pattern 4: Hardcoded Schedules Without catchup=False

# BAD: Deploy this DAG, and it immediately backfills from Jan 1
@dag(
    start_date=datetime(2026, 1, 1),
    schedule="@daily",
    # catchup defaults to True!
)

Fix: Always set catchup=False unless you specifically intend to backfill:

@dag(
    start_date=datetime(2026, 1, 1),
    schedule="@daily",
    catchup=False,  # Only run for current/future dates
)

Anti-Pattern 5: No Alerting

“We check the Airflow UI every morning.” This works until it does not — a Friday night failure goes unnoticed until Monday, by which time three days of data are missing.

Fix: Configure failure callbacks on every DAG. At minimum, send alerts to a Slack channel. For critical pipelines, integrate with PagerDuty or Opsgenie.

Example: A Complete Production ETL Pipeline

Let us tie all patterns together into a realistic production pipeline that extracts sales data from a PostgreSQL source, transforms it, loads it into a data warehouse, and sends a completion notification.

import os
from datetime import datetime, timedelta
from airflow.decorators import dag, task
from airflow.datasets import Dataset
from airflow.providers.postgres.hooks.postgres import PostgresHook

ENV = os.environ.get("AIRFLOW_ENV", "dev")
SALES_DATASET = Dataset("postgresql://warehouse/analytics.daily_sales")

def alert_on_failure(context):
    if ENV != "prod":
        return  # Only alert in production
    # Send Slack notification (see error handling pattern above)
    ...

@dag(
    dag_id="daily_sales_etl",
    start_date=datetime(2026, 1, 1),
    schedule="0 6 * * *",
    catchup=False,
    max_active_runs=1,
    default_args={
        "owner": "data-team",
        "retries": 3,
        "retry_delay": timedelta(minutes=5),
        "retry_exponential_backoff": True,
        "max_retry_delay": timedelta(minutes=30),
        "on_failure_callback": alert_on_failure,
        "sla": timedelta(hours=2),
    },
    tags=["etl", "sales", "production"],
)
def daily_sales_etl():

    @task
    def extract(**context):
        """Extract yesterday's sales from the source database."""
        source = PostgresHook(f"source_db_{ENV}")
        ds = context["ds"]

        records = source.get_records(f"""
            SELECT order_id, customer_id, product_id,
                   quantity, unit_price, order_date
            FROM sales
            WHERE order_date = '{ds}'
        """)

        if not records:
            print(f"No sales data for {ds} -- this may be expected (holiday)")

        return records

    @task
    def transform(records: list, **context):
        """Apply business rules and calculate derived fields."""
        from dags.utils.transforms import validate_record

        transformed = []
        skipped = 0

        for r in records:
            record = {
                "order_id": r[0],
                "customer_id": r[1],
                "product_id": r[2],
                "quantity": r[3],
                "unit_price": r[4],
                "order_date": r[5],
                "total_amount": r[3] * r[4],  # quantity * unit_price
                "processed_at": datetime.utcnow().isoformat(),
            }

            validated = validate_record(record)
            if validated:
                transformed.append(validated)
            else:
                skipped += 1

        print(
            f"Transformed {len(transformed)} records, "
            f"skipped {skipped} invalid records"
        )
        return transformed

    @task(outlets=[SALES_DATASET])
    def load(records: list, **context):
        """Idempotent load into the warehouse using delete-then-insert."""
        wh = PostgresHook(f"warehouse_{ENV}")
        ds = context["ds"]

        wh.run(f"""
            DELETE FROM analytics.daily_sales
            WHERE order_date = '{ds}'
        """)

        if records:
            wh.insert_rows(
                "analytics.daily_sales",
                [
                    (
                        r["order_id"], r["customer_id"], r["product_id"],
                        r["quantity"], r["unit_price"], r["total_amount"],
                        r["order_date"], r["processed_at"],
                    )
                    for r in records
                ],
            )

        return len(records)

    @task
    def verify(row_count: int, **context):
        """Basic data quality check."""
        ds = context["ds"]
        if row_count == 0:
            print(f"Warning: Zero rows loaded for {ds}")
            # Could trigger a separate alert here

    raw = extract()
    cleaned = transform(raw)
    count = load(cleaned)
    verify(count)

daily_sales_etl()

This pipeline incorporates:

  • Idempotency via delete-then-insert in the load step
  • Error handling with retries, exponential backoff, and failure callbacks
  • SLA monitoring to catch performance degradation
  • Multi-environment support via environment-specific connections
  • Dataset publishing so downstream DAGs trigger automatically
  • Data validation in the transform step
  • Observability with print statements and row count verification

Next Steps

Patterns are tools, not rules. Apply them based on your specific requirements — a simple daily report DAG does not need the full reconciliation pattern, and a low-stakes internal dashboard does not need PagerDuty integration. Start with idempotency (it prevents the most damage), add alerting (it catches problems before stakeholders do), and layer in sophistication as your pipelines grow in importance.

  • Testing DAGs — Test the patterns you implement, especially idempotency assertions.
  • Data-Aware Scheduling — Use datasets to connect your pipeline stages without tight coupling.
  • Production Deployment — Deploy these patterns on production-grade infrastructure.
  • Best Practices — The pre-deployment checklist that complements these patterns.