Day 3 · Apache Airflow

Production,
diagnostics,
and monitoring

Testing, deployment, observability, security, and scale
S2
Overview

From working DAGs to production systems

S3
Production best practices

The first gate: can every DAG import?

from airflow.models import DagBag

def test_no_import_errors():
    dag_bag = DagBag()
    assert len(dag_bag.import_errors) == 0
S4
Import-test exercise

Break a DAG, observe the gate, fix it

from airflow.decorators import dag, task
import nonexistent_module  # Intentionally missing.

@dag(schedule="@daily", start_date=datetime(2026, 1, 1), catchup=False)
def broken_import_dag():
    @task
    def run():
        print("This task will never run")
    run()
pytest tests/test_dag_integrity.py -v reports the missing module and broken file path.
S5
Import-test boundaries

A green test can still be a false positive

Assert the expected DAG IDs or minimum DAG count as well as an empty import_errors dictionary.
S6
CI/CD

Only validated DAGs reach production

git pushPull Requestruff checkpytestDagBagDeployDAG Processordiscovers file Any failed gate stops delivery
Pin compatible Airflow and provider versions with requirements and constraints in every environment.
S7
CI/CD

A minimal deployment workflow

- name: Lint
  run: ruff check dags/

- name: Import test
  run: pytest tests/test_dag_integrity.py -v

- name: Deploy DAGs
  if: success()
  run: gsutil -m rsync -r dags/ gs://${{ secrets.COMPOSER_BUCKET }}/dags/
S8
Airflow 3 DAG Bundles

Let Airflow retrieve DAGs from Git

[dag_processor]
dag_bundle_config_list = [
  {
    "name": "my_git_bundle",
    "classpath": "airflow.providers.git.bundles.git.GitDagBundle",
    "kwargs": {
      "tracking_ref": "main",
      "git_conn_id": "my_git_conn"
    }
  }
]
Track main for automatic updates or a commit SHA for deterministic deployment.
S9
DAG versioning

Bundle versions and DAG Versions are different

Git commitGitDagBundleversionParsed DAGDAG VersionDagRun
S10
Rollback

Fix production through the same pipeline

  1. Revert the offending commit in Git.
  2. Run lint, import tests, and deployment again.
  3. Inspect DagRuns created from the broken DAG Version.
  4. Decide explicitly whether those runs should finish or stop.
A running DagRun does not switch to a corrected graph halfway through execution.
S11
Testing strategy

Four levels catch different failures

LevelProvesTypical tool
ImportDAG files loadDagBag
StructureTasks and edges existpytest + DAG API
UnitBusiness logic is correctplain pytest
Task executionOne task runs in Airflow contextairflow tasks test
S12
Structure tests

Verify the graph, not only the import

def test_dag_structure():
    dag = DagBag().get_dag("order_report")
    assert dag is not None
    assert len(dag.tasks) == 4

    extract = dag.get_task("extract_orders")
    downstream = {task.task_id for task in extract.downstream_list}
    assert "clean_orders" in downstream
This test catches an accidentally removed dependency even when the DAG still imports.
S13
Unit tests

Extract business logic from Airflow

def clean_orders_logic(orders: list) -> list:
    cleaned = [order for order in orders if order > 0]
    if not cleaned:
        raise ValueError("No valid orders remain")
    return cleaned

@task
def clean_orders(orders: list):
    return clean_orders_logic(orders)
Plain functions can be tested without Airflow, a Scheduler, or metadata database.

Diagnostics and optimization

S15
Task-failure checklist

Start with the cheapest evidence

  1. Open the failed TaskInstance log.
  2. Inspect Rendered Template for actual Jinja substitutions.
  3. Check upstream states and distinguish failed from upstream_failed.
  4. Verify Connections and Variables.
  5. Check worker memory, CPU, and pod limits.
  6. Reproduce with airflow tasks test.
S16
Template exercise

Valid Python can still fail at runtime

BashOperator(
    task_id="print_date",
    bash_command="echo 'Processing {{ dss }}'",  # Typo.
)
Fix: {{ ds }}. Reproduce with airflow tasks test broken_template_dag print_date 2026-01-01.
S17
Performance

Keep parsing cheap and deterministic

S18
TaskGroup

Organize one DAG without creating a sub-DAG

with TaskGroup(group_id="transform_group") as transform_group:
    cleaned = clean(extract())
    enrich(cleaned)

transform_group >> load()
S19
Pools

Protect a shared resource from concurrency

airflow pools set postgres_pool 5 \
  "At most five production Postgres connections"

@task(pool="postgres_pool")
def query_postgres():
    hook = PostgresHook(postgres_conn_id="my_postgres")
    return hook.get_first("SELECT COUNT(*) FROM my_table")
TaskGroup changes presentation. A Pool changes actual concurrency for tasks assigned with pool=.
S20
Pool exercise

Fifty tasks, five database slots

Without a Pool

  • .expand() creates 50 tasks.
  • All try to query Postgres.
  • The database becomes the bottleneck.

Five-slot Pool

  • Five tasks run.
  • Forty-five remain queued.
  • The next task starts when a slot is released.
Set the Pool to one slot and observe mapped tasks execute sequentially.
S21
Idempotency

Retries must not duplicate data

-- Anti-pattern: every retry inserts another row.
INSERT INTO daily_totals (report_date, total) VALUES (%s, %s);

-- Idempotent: one result per logical date.
INSERT INTO daily_totals (report_date, total) VALUES (%s, %s)
ON CONFLICT (report_date)
DO UPDATE SET total = EXCLUDED.total;
Airflow provides retries and backfills; the DAG author owns idempotent side effects.
S22
Determinism

Use logical time for logical work

# Changes on every physical retry.
report["processed_at"] = datetime.now().isoformat()

# Stable for this logical run and its retries.
report["processed_at"] = context["ds"]

Monitoring, security, and scaling

S24
Observability

One signal is never enough

S25
Callbacks

Send context with every failure

def notify_on_failure(context):
    ti = context["task_instance"]
    print(
        f"Task {ti.dag_id}.{ti.task_id} failed. "
        f"Log: {ti.log_url}"
    )

@dag(default_args={"on_failure_callback": notify_on_failure}, ...)
def monitored_pipeline():
    ...
A failure callback does not detect a task that succeeds too slowly.
S26
Deadlines

Failure and lateness are different signals

QuestionSignal
Did the task fail?on_failure_callback
Did the work finish on time?Deadline or duration alert
Is downstream data current?Freshness alert
Airflow 3 removed legacy task-level SLAs. Use Deadline Alerts where supported or external metric-based alerts.
S27
Security · RBAC

Apply least privilege per DAG

@dag(
    schedule="@daily",
    access_control={
        "data_team": {"can_read", "can_edit", "can_delete"},
        "analytics_viewers": {"can_read"},
    },
)
def restricted_dag():
    ...
Built-in roles and permission names depend on the Airflow version and authentication manager.
S28
Security · Secrets

Externalize secrets without changing DAG code

[secrets]
backend = airflow.providers.hashicorp.secrets.vault.VaultBackend
backend_kwargs = {
  "connections_path": "airflow/connections",
  "variables_path": "airflow/variables",
  "url": "https://vault.example.com:8200"
}
S29
Security

Fernet and Secrets Backends solve different problems

Fernet key

  • Encrypts sensitive fields.
  • Values remain in metadata DB.
  • Requires careful key rotation.

Secrets Backend

  • Keeps secrets outside Airflow DB.
  • Centralizes access policy and rotation.
  • Preserves existing connection IDs.
S30
Scaling

Concurrency has several independent controls

ControlScope
parallelismTask concurrency across the installation
max_active_runsConcurrent DagRuns for one DAG
worker_concurrencyTasks on one Celery worker
PoolAssigned tasks using one named resource
A Pool is not a global limit; unassigned tasks are unaffected.
S31
Scaling exercise

From 50 models to 500 plus ML

S32
Three-day wrap-up

Production readiness checklist

Next step: apply the checklist to one real pipeline and document its operational limits.

Day 3 complete

1 / 33