Pipeline Orchestration — Airflow, DAGs, Scheduling, and Dependency Management
What orchestration actually does, Airflow architecture, DAG design, scheduling, backfills, Sensors, and when to use alternatives.
Orchestration Is Not Scheduling — It Is Coordination
A common misconception is that an orchestrator is just a fancy cron job. Cron runs a script at a time. An orchestrator does far more: it manages dependencies between tasks, retries failed tasks with the right policy, records the history of every run, provides visibility into current execution state, handles backfills when pipelines are deployed late, routes failures to the right alert channels, and scales workers to handle parallel execution across dozens of simultaneous pipeline runs.
The distinction matters because the question “why do I need Airflow when I have cron?” has a precise answer: cron tells you when to run. Airflow tells you what to run, in what order, on what conditions, with what resource limits, and what to do when it fails. This module builds up FreshCart’s actual morning DAG — the pipeline that turns raw orders into Gold-layer revenue tables every night — one Airflow concept at a time.
Airflow Architecture — How It Actually Works Inside
Apache Airflow is the dominant orchestration tool for data engineering. Understanding its internal architecture — not just how to write DAGs — lets you tune it, scale it, and diagnose failures that are architectural rather than code bugs.
Five components, one job each
WEBSERVER
• Flask app serving the UI — graph view, Gantt chart, task logs, run history
• Reads state from the metadata database (does not execute tasks)
SCHEDULER
• The brain — runs continuously, parses DAG files every heartbeat (30s default)
• Creates DagRuns when schedule intervals trigger, queues eligible tasks
• Airflow 2.x supports multiple scheduler instances for HA
EXECUTOR
• Receives queued task instances from the scheduler and runs them
• SequentialExecutor: one task at a time, dev/testing only
• LocalExecutor: subprocesses on the scheduler machine, small teams
• CeleryExecutor: distributes to workers via Redis/RabbitMQ, horizontal scale
• KubernetesExecutor: one pod per task, fully isolated, scales to zero — most common in 2026
METADATA DATABASE (PostgreSQL or MySQL)
• Stores all state: DAG definitions, DagRuns, TaskInstances, XCom, pools
• Source of truth — if the DB is down, Airflow stops
WORKERS
• Actually execute the task code, write logs, report success/failure back to the DBTask execution, start to finish:
1. Scheduler parses DAG file → creates DagRun at schedule time
2. Scheduler evaluates dependencies → marks eligible tasks QUEUED
3. Scheduler sends the TaskInstance to the Executor
4. Executor assigns the task to a Worker
5. Worker runs the task code, writes logs, reports SUCCESS/FAILURE to metadata DB
6. Scheduler sees SUCCESS → queues downstream tasks
7. UI reads state from metadata DB → task shows greenLogical date vs execution time — the most confusing concept in Airflow
The logical_date (called execution_date before Airflow 2.2) is not when the DAG run executes — it is the start of the data interval the run is responsible for. Airflow always runs one interval behind, because it waits for the interval to fully close before processing it.
# DAG schedule: '0 6 * * *' (daily at 06:00 UTC)
# The run that executes at 2026-03-17 06:00 UTC has:
# logical_date: 2026-03-16 06:00:00 UTC
# data_interval_start: 2026-03-16 06:00:00 UTC
# data_interval_end: 2026-03-17 06:00:00 UTC
# → at 06:00 on the 17th, all of the 16th's data is complete and safe to process
def process_orders(**context):
run_date_wrong = datetime.now().strftime('%Y-%m-%d') # NOT reproducible on backfill
run_date_right = context['data_interval_start'].strftime('%Y-%m-%d') # reproducible on backfill
# Jinja templates for the same value:
# {{ ds }} → '2026-03-16'
# {{ ds_nodash }} → '20260316'
# {{ data_interval_start }} → '2026-03-16T06:00:00+00:00'# Backfilling 2026-02-15 with the WRONG version:
run_date_wrong → today's actual date, no matter what's being backfilled — broken
# Backfilling 2026-02-15 with the RIGHT version:
run_date_right → '2026-02-14' (logical_date for the Feb 15 run is Feb 14) — correct'0 2 * * *' and today is March 18. Work out, on paper, the logical_date and data_interval_end of the run that just fired — then check your answer against the pattern above.DAG Design — Building FreshCart’s Morning Pipeline
A DAG file is Python code — which means it can be a beautifully simple dependency declaration or a 600-line mess of business logic embedded directly in the DAG. The rule: DAG files are configuration, not logic. All business logic lives in the pipeline package; the DAG wires tasks together and sets schedules, retries, and dependencies.
DAG-level configuration
from datetime import datetime, timedelta
from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.operators.bash import BashOperator
from airflow.operators.empty import EmptyOperator
from airflow.utils.task_group import TaskGroup
from airflow.models import Variable
default_args = {
'owner': 'data-team', 'depends_on_past': False,
'retries': 2, 'retry_delay': timedelta(minutes=3),
'retry_exponential_backoff': True,
'execution_timeout': timedelta(minutes=30),
'email_on_failure': True, 'email': ['data-team@freshcart.com'],
}
with DAG(
dag_id = 'freshcart_morning_pipeline',
default_args = default_args,
description = 'FreshCart daily data platform — Bronze → Silver → Gold',
schedule = '0 2 * * *', # 02:00 UTC daily
start_date = datetime(2026, 1, 1),
catchup = False, # do not backfill on deploy
max_active_runs = 1, # no concurrent runs
tags = ['daily', 'production', 'silver', 'gold'],
) as dag:
start = EmptyOperator(task_id='start')
end = EmptyOperator(task_id='end')Extraction — a parallel task group
with TaskGroup('extract', tooltip='Extract from all source systems') as extract_group:
def make_extract_task(source: str, pool_slots: int = 1):
"""Factory for extraction tasks — avoids repetition."""
def extract_fn(**context):
from pipelines.extract import run_extraction
run_date = context['data_interval_start'].strftime('%Y-%m-%d')
run_extraction(source=source, run_date=run_date)
return PythonOperator(
task_id=f'extract_{source}', python_callable=extract_fn,
pool='source_db_pool', pool_slots=pool_slots,
sla=timedelta(minutes=15),
)
extract_orders = make_extract_task('orders')
extract_customers = make_extract_task('customers')
extract_payments = make_extract_task('payments')
extract_deliveries = make_extract_task('deliveries')
# these four run in PARALLEL — no dependency between them within the groupTransformation — dbt Silver, then dbt Gold
dbt_silver = BashOperator(
task_id='dbt_silver',
bash_command=(
'dbt run --target prod --select staging.* silver.* '
'--vars \'{"run_date": "{{ ds }}"}\' '
'&& dbt test --target prod --select staging.* silver.*'
),
env={'DBT_PROFILES_DIR': '/etc/dbt', 'SNOWFLAKE_ACCOUNT': Variable.get('snowflake_account')},
execution_timeout=timedelta(minutes=45), sla=timedelta(minutes=40),
)
dbt_gold = BashOperator(
task_id='dbt_gold',
bash_command=(
'dbt run --target prod --select gold.* --vars \'{"run_date": "{{ ds }}"}\' '
'&& dbt test --target prod --select gold.*'
),
env={'DBT_PROFILES_DIR': '/etc/dbt'},
execution_timeout=timedelta(minutes=20), sla=timedelta(minutes=15),
)Quality checks and the finance notification
def run_quality_checks(**context):
from pipelines.quality import check_all_gold_tables
run_date = context['data_interval_start'].strftime('%Y-%m-%d')
result = check_all_gold_tables(run_date=run_date)
if result.has_anomalies:
raise ValueError(f'Quality checks failed: {result.summary}')
context['ti'].xcom_push(key='quality_result', value=result.to_dict())
quality_checks = PythonOperator(task_id='quality_checks', python_callable=run_quality_checks,
execution_timeout=timedelta(minutes=5))
def notify_finance(**context):
from pipelines.notifications import send_daily_summary
run_date = context['data_interval_start'].strftime('%Y-%m-%d')
quality_result = context['ti'].xcom_pull(task_ids='quality_checks', key='quality_result')
send_daily_summary(run_date=run_date, quality=quality_result)
notify = PythonOperator(task_id='notify_finance', python_callable=notify_finance)
# ── the whole graph in one line ───────────────────────────────────────────────
start >> extract_group >> dbt_silver >> dbt_gold >> quality_checks >> notify >> endGraph view — freshcart_morning_pipeline, 2026-03-17 02:00 run
start → [extract_orders, extract_customers, extract_payments, extract_deliveries]
→ dbt_silver → dbt_gold → quality_checks → notify_finance → end
Total duration: 38 min 12 s Status: successPools — controlling resource consumption
Without pools, 20 tasks all connecting to the same source replica simultaneously exhaust its connection limit — some fail, others slow down. A pool caps concurrent usage of one shared resource without limiting overall task parallelism.
# airflow pools set source_db_pool 5 "Max 5 concurrent source DB connections"
# airflow pools set snowflake_pool 8 "Max 8 concurrent Snowflake queries"
# airflow pools set api_pool 3 "Max 3 concurrent API calls"
extract_orders = PythonOperator(
task_id='extract_orders', python_callable=extract_orders_fn,
pool='source_db_pool', pool_slots=1, # heavy tasks can consume 2+ slots
)
# A REAL POOL STRATEGY FOR A MEDIUM PLATFORM:
# source_db_pool: 5 snowflake_pool: 8 api_pool: 3 dbt_pool: 2 default_pool: 16Scheduling — Cron, Datasets, and Waiting on External Conditions
Airflow supports three scheduling styles: cron-based (fixed time), dataset-driven (run when upstream data changes), and manual (human- or system-triggered). Knowing all three — and when each is appropriate — is the foundation of a well-designed strategy.
Cron-based scheduling — the baseline
schedule = '0 2 * * *' # daily at 02:00 UTC
schedule = '*/15 * * * *' # every 15 minutes
schedule = '@daily' # Airflow shorthand for '0 0 * * *'
schedule = None # manual trigger only
# ALWAYS use UTC for schedules — a schedule that silently shifts 30 minutes
# with another country's DST change is very hard to debug.
# catchup=True (default in some versions): a DAG paused 3 days creates
# 3 DagRuns for the missed intervals on resume — useful for date-range
# pipelines, dangerous for high-frequency ones (hundreds of runs).
# catchup=False: only the latest interval runs on resume — set this
# explicitly on production DAGs, never rely on the default.
with DAG(catchup=False, max_active_runs=1, ...): ...Dataset-driven scheduling — Airflow 2.4+
Dataset scheduling replaces complex ExternalTaskSensor polling with a declarative dependency: a consumer DAG waits for the datasets its upstream producers declare, with no time-based polling at all.
from airflow import Dataset
ORDERS_SILVER = Dataset('snowflake://freshcart/silver/orders')
CUSTOMERS_SILVER = Dataset('snowflake://freshcart/silver/customers')
PAYMENTS_SILVER = Dataset('snowflake://freshcart/silver/payments')
# Producer DAG: declares which dataset a task produces
with DAG('orders_silver_pipeline', schedule='0 2 * * *') as dag:
load_orders = PythonOperator(task_id='load_orders', python_callable=run_orders_pipeline,
outlets=[ORDERS_SILVER])
# Consumer DAG: triggers when ALL three listed datasets have been updated
with DAG(dag_id='gold_daily_revenue', schedule=[ORDERS_SILVER, CUSTOMERS_SILVER, PAYMENTS_SILVER]) as dag:
build_gold = PythonOperator(task_id='build_gold_revenue', python_callable=run_gold_pipeline)Sensors — waiting for a file, another DAG, or a custom condition
from airflow.providers.amazon.aws.sensors.s3 import S3KeySensor
from airflow.sensors.external_task import ExternalTaskSensor
wait_for_vendor_file = S3KeySensor(
task_id='wait_for_shipfast_weekly_file',
bucket_key='s3://freshcart-landing/shipfast/weekly_deliveries_{{ ds_nodash }}.csv',
poke_interval=300, timeout=7200,
mode='reschedule', # releases the worker slot between polls — never 'poke'
soft_fail=True, # SKIPPED (not FAILED) on timeout — DAG continues
)
wait_for_upstream = ExternalTaskSensor(
task_id='wait_for_orders_silver', external_dag_id='orders_silver_pipeline',
external_task_id='load_orders', allowed_states=['success'],
mode='reschedule', poke_interval=60, timeout=3600,
)def check_source_row_count(**context) -> bool:
"""Return True once source has >= 1000 rows for today's date."""
run_date = context['data_interval_start'].strftime('%Y-%m-%d')
count = get_source_row_count(run_date)
if count >= 1000:
return True
print(f'Source has {count} rows — waiting for at least 1000')
return False
wait_for_data = PythonSensor(
task_id='wait_for_source_data', python_callable=check_source_row_count,
poke_interval=180, timeout=7200, mode='reschedule',
)Task log — wait_for_source_data, poll 4 of ~24:
Source has 640 rows — waiting for at least 1000
[reschedule] releasing worker slot, will check again in 180s
...
Source has 1120 rows — condition met, proceedingmode='poke' or mode='reschedule' be correct for it, and why does the expected wait time change your answer?Backfills — Processing Historical Data Correctly
A backfill runs a pipeline for historical date ranges — either because it was just deployed and needs to process existing data, or because historical runs failed and need re-execution. Backfills are a routine, first-class operation, not an emergency procedure.
Running a backfill from the CLI
airflow dags backfill --dag-id freshcart_morning_pipeline \
--start-date 2026-01-01 --end-date 2026-03-16 \
--max-active-runs 3 # run 3 days in parallel
airflow dags backfill --dag-id freshcart_morning_pipeline \
--start-date 2026-03-15 --end-date 2026-03-15 # single date
airflow dags backfill --dag-id freshcart_morning_pipeline \
--start-date 2026-01-01 --end-date 2026-03-16 --dry-run # shows what would runBackfill: freshcart_morning_pipeline, 2026-01-01 → 2026-03-16 (75 days)
Running with max_active_runs=3: 25 batches
[1/25] 2026-01-01 ... 2026-01-03: RUNNING
[2/25] 2026-01-04 ... 2026-01-06: RUNNING
...
Backfill complete: 75 succeeded, 0 failedClearing tasks and triggering manual runs
# Clear a specific task and everything downstream of it — re-runs on next heartbeat
airflow tasks clear --dag-id freshcart_morning_pipeline \
--task-id dbt_gold --start-date 2026-03-17 --downstream
# Manual trigger with custom configuration:
airflow dags trigger --dag-id freshcart_morning_pipeline \
--conf '{"run_date": "2026-03-17", "force_full_reload": true}'
# Reading the conf in the DAG:
def run_fn(**context):
conf = context['dag_run'].conf or {}
run_date = conf.get('run_date', context['ds'])
force_reload = conf.get('force_full_reload', False)What a pipeline must do to support backfills correctly
1. IDEMPOTENCY IS ESSENTIAL
Backfills re-run pipelines for dates that may already be processed.
A non-idempotent pipeline (plain INSERT) creates duplicates. Use upserts.
2. BACKFILL RATE
90 days at max_active_runs=3, 10 min/run → 30 batches × 10 min = 5 hours.
Plan backfills during low-traffic hours.
3. DEPENDENCY ORDERING
Backfills respect task dependencies WITHIN a DAG, not ACROSS DAGs.
If Gold depends on Silver, backfill Silver first, then Gold.
4. SOURCE AVAILABILITY
Historical data must still exist in the source — a CDC pipeline
backfilling 90 days needs 90 days of WAL, or a separate bulk extract.Dynamic Task Mapping — Generating Tasks at Runtime
Dynamic task mapping (Airflow 2.3+) generates one task per entity at runtime — instead of hardcoding a task per store, FreshCart reads the active store list from the database and gets one independent task instance per store, each with its own logs, retries, and status.
Mapping over a runtime-fetched list
from airflow.decorators import task, dag
@dag(dag_id='process_all_stores', schedule='0 6 * * *', start_date=datetime(2026, 1, 1))
def process_all_stores_dag():
@task
def get_active_stores() -> list[str]:
conn = get_db_connection()
rows = conn.execute("SELECT store_id FROM reference.stores WHERE is_active = TRUE").fetchall()
return [row[0] for row in rows] # ['ST001', ..., 'ST010']
@task
def process_store_data(store_id: str, **context) -> dict:
run_date = context['ds']
result = run_store_pipeline(store_id=store_id, run_date=run_date)
return {'store_id': store_id, 'rows_written': result.rows_written}
@task
def aggregate_results(store_results: list[dict]) -> None:
total = sum(r['rows_written'] for r in store_results)
print(f'All stores complete: {len(store_results)} stores, {total} total rows')
stores = get_active_stores()
store_results = process_store_data.expand(store_id=stores) # one task per store, in parallel
aggregate_results(store_results)
dag = process_all_stores_dag()Graph view: process_all_stores, 2026-03-17
get_active_stores → process_store_data[0..9] (parallel, per store) → aggregate_results
All stores complete: 10 stores, 812,400 total rowsMapping over multiple parameters at once
@task
def process_store_category(store_id: str, category: str) -> dict:
return run_pipeline(store_id=store_id, category=category)
combinations = [
{'store_id': 'ST001', 'category': 'grocery'},
{'store_id': 'ST001', 'category': 'beverages'},
{'store_id': 'ST002', 'category': 'grocery'},
]
results = process_store_category.expand_kwargs(combinations) # 3 task instancesXCom — Passing Data Between Tasks
XCom (cross-communication) lets tasks pass small pieces of data to downstream tasks. The emphasis is on small — XCom lives in the metadata database and is for run statistics, status flags, and file paths, never for DataFrames or large result sets.
Push and pull — the manual API
def extraction_task(**context):
result = run_extraction(run_date=context['ds'])
context['ti'].xcom_push(key='rows_extracted', value=result.rows_extracted)
context['ti'].xcom_push(key='rows_rejected', value=result.rows_rejected)
# XCom value limit: ~48 KB default in PostgreSQL VARCHAR — keep it small
def quality_check_task(**context):
ti = context['ti']
rows_extracted = ti.xcom_pull(task_ids='extract_orders', key='rows_extracted')
rows_rejected = ti.xcom_pull(task_ids='extract_orders', key='rows_rejected')
if rows_extracted == 0:
raise ValueError('No rows extracted — possible source outage')
rejection_rate = rows_rejected / rows_extracted
if rejection_rate > 0.05:
raise ValueError(f'Rejection rate {rejection_rate:.1%} exceeds 5% threshold')The TaskFlow API — return values are XCom automatically
from airflow.decorators import task
@task
def extract_orders(run_date: str) -> dict:
result = run_extraction(run_date=run_date)
return {'rows_extracted': result.rows_extracted, 'rows_rejected': result.rows_rejected}
@task
def quality_check(extraction_result: dict) -> None:
if extraction_result['rows_extracted'] == 0:
raise ValueError('No rows extracted')
# In the DAG:
result = extract_orders(run_date='{{ ds }}')
quality_check(result) # result is passed as XCom automaticallyThe one XCom anti-pattern that actually breaks Airflow
# BAD — passing 500 MB through the metadata database
@task
def load_data_bad(**context):
df = pd.read_csv('s3://bucket/orders.csv')
context['ti'].xcom_push(key='dataframe', value=df.to_dict()) # crashes Airflow
# GOOD — write the data, push only the path
@task
def load_data_good(**context):
df = pd.read_csv('s3://bucket/orders.csv')
output_path = f's3://bucket/tmp/run-{context["run_id"]}/orders.parquet'
df.to_parquet(output_path)
context['ti'].xcom_push(key='output_path', value=output_path)ValueError: XCOM value exceeds maximum size (48 KB)
# this is exactly what load_data_bad above triggers — the fix is load_data_good's
# pattern: push the S3 path (a few dozen bytes), not the data itselfAirflow vs Prefect vs Dagster — Choosing the Right Tool
Airflow is dominant but not the only option. Prefect and Dagster have both gained significant adoption in the past three years, each addressing specific pain points of Airflow. Understanding the trade-offs helps you both choose the right tool and speak intelligently about the ecosystem in interviews.
| Dimension | Airflow | Prefect | Dagster |
|---|---|---|---|
| Market share | Dominant — used everywhere | Growing — popular for Python-native teams | Growing — popular for software-engineering-focused teams |
| Core concept | DAG of tasks with dependencies and schedule | Flow of tasks — more Pythonic, less configuration | Software-defined assets — data as first-class objects |
| Local development | Complex — needs metadata DB, scheduler, webserver | Simple — runs locally with no infrastructure | Simple — runs locally, good DX |
| Dynamic workflows | Dynamic Task Mapping (2.3+) — improved but still complex | Native — Python loops and conditions work naturally | Native — partitions and dynamic jobs built-in |
| Data lineage | Limited — tasks know nothing about data assets | Limited — similar to Airflow | First-class — assets track upstream/downstream data |
| Testing | Hard — requires Airflow infrastructure to test DAGs | Easy — flows are regular Python functions | Easy — well-designed for unit testing |
| When to choose | Large teams, complex multi-team platforms, broad ecosystem | Python-native teams, simpler pipelines, easier local dev | Teams that want strong data asset lineage, modern DX |
Five Misconceptions About Orchestration
Debugging a DAG That Runs Slower Every Week
The SLA for the morning pipeline is 10:30 PM ET (previous day). It used to complete by 10:10 PM ET. Over the last four weeks it has been completing later: 10:14, 10:21, 10:28, and this week it missed the SLA at 10:34 PM ET. No code was changed. You are asked to investigate.
-- Historical run durations from the Airflow metadata DB:
SELECT DATE(execution_date) run_date, ROUND(duration / 60.0, 1) total_minutes
FROM dag_run WHERE dag_id = 'freshcart_morning_pipeline' AND state = 'success'
AND execution_date > NOW() - INTERVAL '30 days'
ORDER BY execution_date DESC;
-- 2026-03-17: 64 min ← SLA BREACH 2026-03-10: 51 min 2026-02-24: 38 min ← was fine
-- total duration grew 68% in 3 weeks — something is scaling linearly
-- Break duration down by task:
SELECT task_id, DATE(execution_date) run_date, ROUND(duration / 60.0, 1) minutes
FROM task_instance WHERE dag_id = 'freshcart_morning_pipeline' AND state = 'success'
AND task_id IN ('extract_orders', 'dbt_silver', 'dbt_gold');
-- extract_orders: 8 → 8 → 8 → 8 min (stable)
-- dbt_silver: 12 → 15 → 18 → 22 min ← growing linearly
-- dbt_gold: 4 → 4 → 4 → 4 min (stable)# dbt_silver's own log shows which model inside it is slow:
# Model staging.stg_orders completed in 42s
# Model silver.orders completed in 1280s ← THIS ONE
-- Source table growth:
SELECT DATE(created_at) date, COUNT(*) daily_new_orders FROM raw.orders
GROUP BY 1 ORDER BY 1 DESC LIMIT 30;
-- FreshCart is growing: 48k/day → 52k → 56k → 60k
-- silver.orders model SQL:
-- SUM(order_amount) OVER (PARTITION BY store_id, month ORDER BY created_at)
-- This window function reads ALL historical orders on every run —
-- as the table grows, the model gets slower even though only today's rows are new.Fix: materialise the monthly running total as a separate Gold model.
silver.orders: just cleans and validates (fast — only new rows)
gold.monthly_store_revenue: the full window-function aggregate (slow, but runs once, persisted)
After fix:
2026-03-18 freshcart_morning_pipeline 2340s 39 min ← back to baselineThe investigation used Airflow’s metadata database to isolate the slow task, dbt logs to isolate the slow model, and SQL analysis to understand the growth pattern. The fix was architectural — moving the expensive computation from an incremental Silver model (runs daily on all data) to a Gold model (runs once, result persisted).
5 Interview Questions — With Complete Answers
Mistakes Beginners Make Constantly
Errors You Will Hit — And Exactly Why They Happen
🎯 Key Takeaways
- ✓An orchestrator is not a fancy cron job. Cron tells you when to run. Airflow tells you what to run, in what order, on what conditions, with what resource limits, with what retry policy, and what to do when it fails. For multiple interdependent pipelines with shared resources and SLAs, an orchestrator is necessary.
- ✓Airflow has five components: Webserver (UI, reads from metadata DB), Scheduler (creates DagRuns, queues tasks, continuously runs), Executor (dispatches tasks to workers), Metadata Database (single source of truth — PostgreSQL), Workers (actually run task code). The scheduler and workers must both be able to read DAG files.
- ✓The Airflow logical_date (execution_date) is the start of the data interval being processed, not when the run actually executed. A daily DAG at 06:00 UTC on March 17 has a logical_date of March 16 — it processes March 16 data. Always use context["data_interval_start"] in pipeline code, never datetime.now(). This makes every pipeline correctly backfillable.
- ✓Always set catchup=False on production DAGs unless backfill is explicitly needed. catchup=True can create hundreds or thousands of DagRuns when a DAG is unpaused after a pause. Use max_active_runs=1 to prevent concurrent runs of the same DAG.
- ✓Pools limit concurrent resource usage per resource type. Create pools for: source database connections (limit 5), Snowflake warehouse queries (limit 8), external API calls (limit 3). Assign tasks to pools with pool="pool_name". Without pools, parallel tasks can exhaust shared resources and all fail together.
- ✓Sensors must use mode="reschedule" for any wait longer than a few seconds. mode="poke" holds a worker slot continuously — 100 poke sensors = 100 workers blocked sleeping. mode="reschedule" releases the slot between polls. This is one of the most common Airflow performance mistakes in production.
- ✓Dataset scheduling (Airflow 2.4+) is the modern way to express cross-DAG dependencies declaratively. Producer tasks declare outlets=[Dataset("s3://bucket/table")]. Consumer DAGs declare schedule=[Dataset(...)]. Airflow triggers the consumer when producers update the dataset. Prefer this over ExternalTaskSensor for data-driven dependencies.
- ✓Dynamic task mapping generates tasks at runtime from a list. @task.process_store.expand(store_id=stores) creates one task instance per store with independent logs, retries, and status. Use for processing N entities in parallel when N is data-driven. Avoid for N > 1,000 (scheduler performance impact).
- ✓XCom is for small values only (< 48 KB) — run IDs, row counts, file paths, status flags. Never push DataFrames, query results, or large JSON to XCom. Push an S3 path and have the downstream task load the data from that path. Monitor the xcom table size for high-frequency pipelines.
- ✓Airflow is dominant and must be known deeply. Prefect is Pythonic and easier for local development. Dagster is asset-centric and has strong data lineage — aligns well with the dbt+ELT pattern. For interviews: know Airflow thoroughly, know Prefect/Dagster conceptually, have an opinion on trade-offs.
What comes next
Module 29 covers data lake architecture — how to design zones that stay useful for years, the raw and processed zone patterns, and the five anti-patterns that turn a data lake into an unmaintainable swamp.
Module 29 → Data Lake Architecture — Design, Zones and Anti-PatternsDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.