Apache Airflow 2.10 Data Pipeline: 5 DAG Orchestration Patterns for Production ETL
ETL Scripts Out of Control, Task Dependencies in Chaos, Silent Pipeline Failures
At 2 AM, the data warehouse daily increment drops from 500K to 30K, yet Airflow DAG shows all green — no one knows the upstream API changed its pagination logic. ETL scripts are scattered across 20 Cron Jobs, upstream failures go unnoticed downstream, and dirty data flows straight into reports. A new colleague writes a DAG with circular dependencies that freezes the Airflow Scheduler. In 2026, Apache Airflow 2.10 brings enhanced Dynamic Task Mapping, improved TaskFlow API, and more powerful data-aware scheduling — from DAG design to production monitoring, one system handles it all.
This article covers 5 DAG orchestration patterns, guiding you through TaskFlow API design → Dynamic Task Mapping for parallel ETL → Custom Operators and Sensors → XCom data passing and branching → Error handling, retry, and SLA monitoring with complete runnable Python code at every step.
Airflow Core Concepts
| Concept | Description |
|---|---|
| DAG | Directed Acyclic Graph, defines task dependencies and execution order |
| Task | Single execution unit in a DAG, corresponds to an Operator instance |
| Operator | Task execution logic encapsulation (PythonOperator, BashOperator, custom) |
| TaskFlow API | Python functional DAG definition (Airflow 2.0+), uses @task decorator |
| XCom | Cross-task data passing, default 48KB limit, 2.10 supports custom Backend |
| Dynamic Task Mapping | Dynamic task expansion (Airflow 2.3+), generates parallel tasks from runtime data |
| Sensor | Special Operator that checks conditions before downstream tasks start |
| Connection | Airflow-managed connection info, avoids hardcoding passwords in DAGs |
| Pool | Resource pool, limits concurrent task count |
| Trigger | Async trigger (Airflow 2.2+), frees Worker Slots from Sensors |
| SLA | Service Level Agreement, timeout triggers alerts |
| Dataset | Data-aware scheduling (Airflow 2.4+), auto-triggers consumers |
Problem Analysis: 5 Challenges in Data Pipeline Orchestration
- DAG design complexity out of control: ETL tasks grow from 3 to 30, DAG files become spaghetti code
- Manual parallel ETL orchestration: Processing 100 data sources daily, need Dynamic Task Mapping
- Custom logic difficult to reuse: Internal API calls and validation logic repeated in every DAG
- Cross-task data passing limited: XCom 48KB limit, branching logic is chaotic
- Insufficient production reliability: No retry, no SLA alerts, data quality issues masked
Step-by-Step: 5 Airflow DAG Orchestration Patterns
Pattern 1: DAG Design with TaskFlow API
Traditional Operator syntax is verbose and unintuitive. TaskFlow API uses Python functional style to dramatically simplify DAG definitions.
# dags/taskflow_etl_dag.py
from datetime import datetime, timedelta
from airflow.decorators import dag, task
default_args = {
"owner": "data-team",
"retries": 3,
"retry_delay": timedelta(minutes=5),
}
@dag(
dag_id="taskflow_etl_pipeline",
default_args=default_args,
description="TaskFlow API ETL pipeline",
schedule="0 2 * * *",
start_date=datetime(2026, 1, 1),
catchup=False,
tags=["etl", "taskflow"],
)
def taskflow_etl_pipeline():
@task
def extract_user_events(execution_date: str) -> dict:
"""从PostgreSQL抽取用户事件数据"""
import psycopg2
from airflow.hooks.base import BaseHook
conn = BaseHook.get_connection("postgres_source")
pg_conn = psycopg2.connect(
host=conn.host, port=conn.port, database=conn.schema,
user=conn.login, password=conn.password,
)
cur = pg_conn.cursor()
cur.execute(
"SELECT user_id, event_type, amount, created_at "
"FROM user_events "
"WHERE created_at >= %s AND created_at < %s",
(execution_date, execution_date + timedelta(days=1)),
)
rows = cur.fetchall()
pg_conn.close()
return {"row_count": len(rows), "data": [dict(zip(["user_id", "event_type", "amount", "created_at"], r)) for r in rows[:1000]]}
@task
def extract_api_data(execution_date: str) -> dict:
"""从REST API抽取数据"""
import requests
from airflow.hooks.base import BaseHook
conn = BaseHook.get_connection("api_source")
resp = requests.get(
f"{conn.host}/v2/data",
headers={"Authorization": f"Bearer {conn.password}"},
params={"date": execution_date},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
return {"row_count": len(data.get("records", [])), "data": data.get("records", [])[:1000]}
@task
def transform(user_events: dict, api_data: dict) -> dict:
"""合并、去重、清洗数据"""
import pandas as pd
df_events = pd.DataFrame(user_events["data"])
df_api = pd.DataFrame(api_data["data"])
if df_events.empty and df_api.empty:
return {"row_count": 0, "data": []}
df_all = pd.concat([df_events, df_api], ignore_index=True)
df_all = df_all.drop_duplicates(subset=["user_id", "event_type"])
df_all = df_all.dropna(subset=["user_id"])
df_all["amount"] = df_all["amount"].fillna(0).astype(float)
return {"row_count": len(df_all), "data": df_all.to_dict("records")[:500]}
@task
def load(transformed: dict) -> int:
"""加载数据到数据仓库"""
import psycopg2
from airflow.hooks.base import BaseHook
if transformed["row_count"] == 0:
return 0
conn = BaseHook.get_connection("postgres_warehouse")
pg_conn = psycopg2.connect(
host=conn.host, port=conn.port, database=conn.schema,
user=conn.login, password=conn.password,
)
cur = pg_conn.cursor()
insert_sql = """
INSERT INTO analytics.user_events_daily
(user_id, event_type, amount, event_date)
VALUES (%s, %s, %s, %s)
ON CONFLICT (user_id, event_type, event_date) DO UPDATE
SET amount = EXCLUDED.amount
"""
batch = [(r.get("user_id"), r.get("event_type"), r.get("amount", 0), r.get("created_at")) for r in transformed["data"]]
cur.executemany(insert_sql, batch)
pg_conn.commit()
pg_conn.close()
return len(batch)
@task
def quality_check(loaded_count: int, transformed_count: int) -> str:
if transformed_count > 0 and loaded_count / transformed_count < 0.95:
raise ValueError(f"Quality gate failed: loaded {loaded_count}/{transformed_count}")
return "PASSED"
user_events = extract_user_events("{{ ds }}")
api_data = extract_api_data("{{ ds }}")
transformed = transform(user_events, api_data)
loaded = load(transformed)
quality_check(loaded, transformed["row_count"])
taskflow_etl_pipeline()
pip install "apache-airflow[postgres,amazon]==2.10.3"
airflow db init
airflow dags list | grep taskflow
airflow dags test taskflow_etl_pipeline 2026-06-19
Pattern 2: Dynamic Task Mapping for Parallel ETL
When the number of data sources changes dynamically, Dynamic Task Mapping expands a single Task definition into N parallel instances.
# dags/dynamic_mapping_dag.py
from datetime import datetime
from airflow.decorators import dag, task
@dag(dag_id="dynamic_mapping_etl", schedule="0 3 * * *", start_date=datetime(2026, 1, 1), catchup=False, tags=["etl", "dynamic-mapping"])
def dynamic_mapping_etl():
@task
def discover_data_sources() -> list[dict]:
"""动态发现需要处理的数据源列表"""
import psycopg2
from airflow.hooks.base import BaseHook
conn = BaseHook.get_connection("postgres_source")
pg_conn = psycopg2.connect(host=conn.host, port=conn.port, database=conn.schema, user=conn.login, password=conn.password)
cur = pg_conn.cursor()
cur.execute("SELECT source_id, source_type, connection_id FROM data_sources WHERE active = true")
sources = [{"source_id": row[0], "source_type": row[1], "connection_id": row[2]} for row in cur.fetchall()]
pg_conn.close()
return sources
@task
def extract_from_source(source: dict) -> dict:
"""从单个数据源抽取数据"""
import psycopg2, requests
from airflow.hooks.base import BaseHook
source_type, source_id = source["source_type"], source["source_id"]
if source_type == "postgres":
conn = BaseHook.get_connection(source["connection_id"])
pg_conn = psycopg2.connect(host=conn.host, port=conn.port, database=conn.schema, user=conn.login, password=conn.password)
cur = pg_conn.cursor()
cur.execute(f"SELECT * FROM source_{source_id}_data WHERE processed = false LIMIT 5000")
rows = cur.fetchall()
pg_conn.close()
return {"source_id": source_id, "row_count": len(rows), "status": "success"}
elif source_type == "api":
conn = BaseHook.get_connection(source["connection_id"])
resp = requests.get(f"{conn.host}/v1/data/{source_id}", headers={"Authorization": f"Bearer {conn.password}"}, timeout=30)
resp.raise_for_status()
return {"source_id": source_id, "row_count": len(resp.json()), "status": "success"}
return {"source_id": source_id, "row_count": 0, "status": "skipped"}
@task
def validate_extracted(extract_result: dict) -> dict:
if extract_result["status"] != "success" or extract_result["row_count"] < 1:
return {**extract_result, "valid": False}
return {**extract_result, "valid": True}
@task
def aggregate_results(validation_results: list[dict]) -> dict:
total = len(validation_results)
valid = sum(1 for r in validation_results if r.get("valid"))
total_rows = sum(r.get("row_count", 0) for r in validation_results if r.get("valid"))
return {"total_sources": total, "valid_sources": valid, "total_rows": total_rows}
# Dynamic Task Mapping: expand()将列表展开为并行任务
sources = discover_data_sources()
extract_results = extract_from_source.expand(source=sources)
validation_results = validate_extracted.expand(extract_result=extract_results)
aggregate_results(validation_results)
dynamic_mapping_etl()
# plugins/operators/internal_api_operator.py
from airflow.models import BaseOperator
from airflow.exceptions import AirflowSkipException
from typing import Any
import requests
class InternalApiOperator(BaseOperator):
"""调用公司内部API的自定义Operator"""
template_fields = ("endpoint", "payload")
def __init__(self, endpoint: str, payload: dict | None = None, method: str = "POST",
connection_id: str = "internal_api", timeout: int = 60, skip_on_empty: bool = False, **kwargs):
super().__init__(**kwargs)
self.endpoint, self.payload, self.method = endpoint, payload or {}, method
self.connection_id, self.timeout, self.skip_on_empty = connection_id, timeout, skip_on_empty
def execute(self, context: Any) -> dict:
from airflow.hooks.base import BaseHook
conn = BaseHook.get_connection(self.connection_id)
url = f"{conn.host}{self.endpoint}"
headers = {"Authorization": f"Bearer {conn.password}", "Content-Type": "application/json"}
self.log.info(f"Calling {self.method} {url}")
try:
if self.method.upper() == "GET":
resp = requests.get(url, headers=headers, params=self.payload, timeout=self.timeout)
else:
resp = requests.post(url, headers=headers, json=self.payload, timeout=self.timeout)
resp.raise_for_status()
result = resp.json()
if self.skip_on_empty and not result:
raise AirflowSkipException("API returned empty result")
return result
except requests.exceptions.Timeout:
raise TimeoutError(f"API call timed out after {self.timeout}s")
except requests.exceptions.HTTPError as e:/n if e.response.status_code == 404 and self.skip_on_empty:
raise AirflowSkipException(f"Resource not found: {url}")
raise
# plugins/sensors/data_ready_sensor.py
from airflow.sensors.base import BaseSensorOperator
from typing import Any
class DataReadySensor(BaseSensorOperator):
"""检测数据源是否已就绪的Sensor"""
template_fields = ("source_table", "expected_date")
def __init__(self, source_table: str, expected_date: str, connection_id: str = "postgres_source",
min_row_count: int = 1, poke_interval: int = 300, timeout: int = 7200, **kwargs):
super().__init__(poke_interval=poke_interval, timeout=timeout, **kwargs)
self.source_table, self.expected_date = source_table, expected_date
self.connection_id, self.min_row_count = connection_id, min_row_count
def poke(self, context: Any) -> bool:
import psycopg2
from airflow.hooks.base import BaseHook
conn = BaseHook.get_connection(self.connection_id)
pg_conn = psycopg2.connect(host=conn.host, port=conn.port, database=conn.schema, user=conn.login, password=conn.password)
cur = pg_conn.cursor()
cur.execute(f"SELECT COUNT(*) FROM {self.source_table} WHERE partition_date = %s AND status = 'ready'", (self.expected_date,))
count = cur.fetchone()[0]
pg_conn.close()
self.log.info(f"Data ready check: {count} rows (min: {self.min_row_count})")
return count >= self.min_row_count
Pattern 3: Custom Operators and Sensors
Encapsulate frequently used internal system logic as custom Operators and Sensors for reuse.
# dags/xcom_branching_dag.py
from datetime import datetime
from airflow.decorators import dag, task
from airflow.operators.python import BranchPythonOperator
@dag(dag_id="xcom_branching_pipeline", schedule="0 5 * * *", start_date=datetime(2026, 1, 1), catchup=False, tags=["etl", "xcom", "branching"])
def xcom_branching_pipeline():
@task
def detect_data_type() -> dict:
import psycopg2
from airflow.hooks.base import BaseHook
conn = BaseHook.get_connection("postgres_source")
pg_conn = psycopg2.connect(host=conn.host, port=conn.port, database=conn.schema, user=conn.login, password=conn.password)
cur = pg_conn.cursor()
cur.execute("SELECT COUNT(*), SUM(amount) FROM user_events WHERE created_at >= CURRENT_DATE - INTERVAL '1 day'")
row_count, total_amount = cur.fetchone()
pg_conn.close()
return {"row_count": row_count, "total_amount": float(total_amount or 0),
"data_size": "large" if row_count > 100000 else "small"}
@task
def process_small_batch(metadata: dict) -> dict:
return {"path": "small_batch", "processed": metadata["row_count"], "status": "completed"}
@task
def process_large_batch(metadata: dict) -> dict:
chunk_size = 10000
chunks = (metadata["row_count"] + chunk_size - 1) // chunk_size
return {"path": "large_batch", "processed": metadata["row_count"], "chunks": chunks, "status": "completed"}
def choose_processing_branch(**context):
ti = context["ti"]
metadata = ti.xcom_pull(task_ids="detect_data_type")
return "process_large_batch" if metadata["data_size"] == "large" else "process_small_batch"
metadata = detect_data_type()
branch = BranchPythonOperator(task_id="choose_branch", python_callable=choose_processing_branch)
small_result = process_small_batch(metadata)
large_result = process_large_batch(metadata)
metadata >> branch >> [small_result, large_result]
xcom_branching_pipeline()
# custom_xcom_backend.py - 自定义XCom Backend突破48KB限制
import json, hashlib
from airflow.models.xcom import BaseXCom
from typing import Any
class RedisXComBackend(BaseXCom):
@staticmethod
def serialize_value(value: Any) -> bytes:
import redis
r = redis.Redis(host="redis-host", port=6379, db=2)
serialized = json.dumps(value, default=str).encode("utf-8")
if len(serialized) <= 48000:
return BaseXCom.serialize_value(value)
key = f"xcom:{hashlib.md5(serialized).hexdigest()}"
r.setex(key, 86400 * 7, serialized)
return BaseXCom.serialize_value({"__xcom_redis_key": key, "size": len(serialized)})
@staticmethod
def deserialize_value(result: Any) -> Any:
import redis
value = BaseXCom.deserialize_value(result)
if isinstance(value, dict) and "__xcom_redis_key" in value:
r = redis.Redis(host="redis-host", port=6379, db=2)
data = r.get(value["__xcom_redis_key"])
if data: return json.loads(data)
raise ValueError(f"XCom data expired in Redis")
return value
# airflow.cfg: xcom_backend = custom_xcom_backend.RedisXComBackend
Pattern 4: XCom Data Passing and Pipeline Branching
XCom is the core mechanism for cross-task communication in Airflow. Version 2.10 supports custom XCom Backend to break the 48KB limit.
# dags/error_handling_sla_dag.py
from datetime import datetime, timedelta
from airflow.decorators import dag, task
from airflow.operators.python import PythonOperator
from airflow.exceptions import AirflowSkipException, AirflowFailOperator
default_args = {
"owner": "data-team", "retries": 3, "retry_delay": timedelta(minutes=5),
"retry_exponential_backoff": True, "max_retry_delay": timedelta(minutes=30),
"execution_timeout": timedelta(minutes=60),
"email_on_failure": True, "email": ["data-team@company.com"],
}
@dag(dag_id="error_handling_sla_pipeline", default_args=default_args,
schedule="0 1 * * *", start_date=datetime(2026, 1, 1), catchup=False, tags=["etl", "production", "sla"])
def error_handling_sla_pipeline():
@task(retries=5, retry_delay=timedelta(minutes=2), retry_exponential_backoff=True, max_retry_delay=timedelta(minutes=20))
def extract_with_retry(**context) -> dict:
import requests
from airflow.hooks.base import BaseHook
conn = BaseHook.get_connection("api_source")
try:
resp = requests.get(f"{conn.host}/v2/data", headers={"Authorization": f"Bearer {conn.password}"}, params={"date": context["ds"]}, timeout=30)
resp.raise_for_status()
return {"row_count": len(resp.json().get("records", [])), "status": "success"}
except requests.exceptions.ConnectionError as e:/n raise ConnectionError(f"Connection failed: {e}")
except requests.exceptions.HTTPError as e:/n if e.response.status_code == 429: raise ConnectionError(f"Rate limited: {e}")
elif e.response.status_code >= 500: raise ConnectionError(f"Server error: {e}")
elif e.response.status_code == 404: raise AirflowSkipException(f"Data not found")
else: raise AirflowFailOperator(f"Client error: {e}")
@task
def transform_with_validation(raw_data: dict) -> dict:
import pandas as pd
if raw_data.get("status") != "success": raise ValueError("Upstream failed")
df = pd.DataFrame(raw_data.get("records", []))
if df.empty: raise AirflowSkipException("No data")
null_rate = df.isnull().sum().sum() / (df.shape[0] * df.shape[1])
if null_rate > 0.3: raise ValueError(f"Data quality too low: {null_rate:.1%}")
return {"row_count": len(df), "null_rate": null_rate, "status": "transformed"}
@task
def load_with_idempotency(transformed: dict) -> dict:
import psycopg2
from airflow.hooks.base import BaseHook
conn = BaseHook.get_connection("postgres_warehouse")
pg_conn = psycopg2.connect(host=conn.host, port=conn.port, database=conn.schema, user=conn.login, password=conn.password)
cur = pg_conn.cursor()
cur.execute("DELETE FROM analytics.daily_metrics WHERE metric_date = CURRENT_DATE - INTERVAL '1 day'")
cur.execute("INSERT INTO analytics.daily_metrics (metric_date, row_count, null_rate, status) VALUES (CURRENT_DATE - INTERVAL '1 day', %s, %s, %s)", (transformed["row_count"], transformed["null_rate"], transformed["status"]))
pg_conn.commit(); pg_conn.close()
return {"loaded": True, "inserted": 1}
def sla_check(**context):
import pendulum
execution_date = context["execution_date"]
if pendulum.now("UTC") > execution_date.add(hours=2):
raise ValueError("SLA violated")
return "SLA_OK"
sla_check_task = PythonOperator(task_id="sla_check", python_callable=sla_check)
sla_alert_task = PythonOperator(task_id="sla_alert", python_callable=lambda **c: print(f"SLA Alert: {c['dag'].dag_id}"), trigger_rule="one_failed")
raw = extract_with_retry()
transformed = transform_with_validation(raw)
loaded = load_with_idempotency(transformed)
loaded >> sla_check_task >> sla_alert_task
error_handling_sla_pipeline()
# Airflow 2.10数据感知调度
from airflow.datasets import Dataset
USER_EVENTS = Dataset("postgres://warehouse/analytics/user_events_daily")
ORDER_METRICS = Dataset("postgres://warehouse/analytics/order_metrics_daily")
@dag(schedule="0 2 * * *")
def producer():
@task(outlets=[USER_EVENTS])
def produce_user_events(): return "produced"
@task(outlets=[ORDER_METRICS])
def produce_order_metrics(): return "produced"
produce_user_events(); produce_order_metrics()
@dag(schedule=[USER_EVENTS, ORDER_METRICS]) # 两个Dataset都更新后触发
def consumer():
@task
def generate_daily_report(): return "report_generated"
generate_daily_report()
Pattern 5: Error Handling, Retry, and SLA Monitoring
Production environments must have robust error handling, automatic retries, and SLA monitoring.
# ❌ 每次Scheduler解析都会执行查询
sources = requests.get("https://api.example.com/sources").json()
@dag(...)
def my_dag():
@task
def process():
for source in sources: ...
# ✅ 动态逻辑放在Task内部
@dag(...)
def my_dag():
@task
def get_sources() -> list:
return requests.get("https://api.example.com/sources").json()
@task
def process(sources: list):
for source in sources: ...
5 Pitfall Guide
1. Top-level Code Execution in DAG Files
❌ Wrong: Execute database queries or API calls at DAG file top-level, runs every time Scheduler parses the DAG
✅ Right: Put dynamic logic inside Tasks, DAG file top-level only defines structure
# ❌ DataFrame通过XCom传递
@task
def extract():
df = pd.read_csv("huge_file.csv")
return df.to_dict() # XCom序列化失败
2. Passing Large Data Objects via XCom
❌ Wrong: Pass entire DataFrame through XCom, exceeds 48KB and fails
✅ Right: Pass file path references or use custom XCom Backend
# ✅ 传递文件路径引用
@task
def extract():
df = pd.read_csv("huge_file.csv")
path = f"/tmp/extract_{datetime.now().strftime('%Y%m%d')}.parquet"
df.to_parquet(path)
return {"file_path": path, "row_count": len(df)}
@task
def transform(metadata: dict):
df = pd.read_parquet(metadata["file_path"])
# 处理逻辑...
3. catchup=True Causing Historical Backfill Avalanche
❌ Wrong: DAG with catchup=True and start_date a year ago triggers 365 DAG Runs on startup
✅ Right: Production DAGs always use catchup=False, use airflow dags backfill for manual control
4. Sensor Occupying Worker Slots Causing Deadlock
❌ Wrong: Multiple Sensors running simultaneously, each occupying a Worker Slot
✅ Right: Use mode="reschedule" or deferrable mode
# ✅ reschedule模式
FileSensor(task_id="wait", filepath="/data/{{ ds }}.csv", mode="reschedule")
# ✅ deferrable模式(Airflow 2.2+推荐)
FileSensor(task_id="wait", filepath="/data/{{ ds }}.csv", deferrable=True)
5. Ignoring Data Quality Gates
❌ Wrong: DAG only checks if tasks ran successfully, empty data also shows green
✅ Right: Add quality gates at DAG end, block downstream if data volume or quality fails
@task
def quality_gate(loaded_count: int, expected_min: int = 1000) -> str:
if loaded_count < expected_min:
raise ValueError(f"Quality gate failed: {loaded_count} < {expected_min}")
return "PASSED"
10 Common Error Troubleshooting
| # | Error Message | Cause | Solution |
|---|---|---|---|
| 1 | DAG import error: No module named 'xxx' |
Missing Python dependency in Worker environment | Add to requirements.txt, restart Worker; or use Docker images |
| 2 | XCom value size exceeded limit of 48KB |
XCom data exceeds default 48KB limit | Pass file path references; or configure custom XCom Backend (Redis) |
| 3 | Task is not running - Pool has no free slots |
Resource pool full, tasks queuing | Increase Pool slots: airflow pool set default_pool 128 |
| 4 | DAG seems missing from DagBag |
DAG file has syntax errors or circular imports | Run airflow dags list-import-errors to check |
| 5 | Sensor timeout after 7200 seconds |
Sensor condition never met | Check data source; increase timeout; use deferrable mode |
| 6 | AirflowTaskTimeout: Execution timed out |
Task execution exceeds execution_timeout |
Increase timeout; split into sub-tasks; optimize code |
| 7 | Detected deadlock while scheduling |
Circular dependencies or resource contention | Check DAG dependency graph; check Pool config |
| 8 | Connection refused: could not connect to server |
Database or API connection failed | Check Connection: airflow connections get-uri conn_id |
| 9 | Dynamic task mapping produced too many tasks |
Expanded tasks exceed max_map_length limit |
Adjust max_map_length in airflow.cfg (default 1024) |
| 10 | SLA missed for task xxx |
Task not completed within SLA window | Check upstream delays; optimize performance; adjust SLA |
Advanced Optimization
1. DAG Parsing Performance
@dag(
dag_id="optimized_pipeline",
dagrun_timeout=timedelta(hours=3), # DAG Run最多3小时
max_active_runs=1, # 同时只允许1个DAG Run
max_active_tasks=16, # 最多16个并发Task
)
def optimized_pipeline(): ...
2. Connection Pool and Batch Operations
from airflow.hooks.base import BaseHook
import psycopg2
from contextlib import contextmanager
@contextmanager
def get_db_connection(connection_id: str):
conn_info = BaseHook.get_connection(connection_id)
conn = psycopg2.connect(host=conn_info.host, port=conn_info.port, database=conn_info.schema, user=conn_info.login, password=conn_info.password)
try: yield conn
finally: conn.close()
def batch_insert(conn, table: str, columns: list[str], rows: list[tuple], batch_size: int = 5000):
from psycopg2.extras import execute_values
with conn.cursor() as cur:
for i in range(0, len(rows), batch_size):
execute_values(cur, f"INSERT INTO {table} ({', '.join(columns)}) VALUES %s", rows[i:i + batch_size])
conn.commit()
3. Dataset-Aware Scheduling Replacing Cron
from airflow.datasets import Dataset
RAW_DATA = Dataset("s3://data-lake/raw/events/")
CLEANED_DATA = Dataset("s3://data-lake/cleaned/events/")
@dag(schedule="0 2 * * *")
def producer():
@task(outlets=[RAW_DATA])
def extract(): ...
@task(outlets=[CLEANED_DATA])
def clean(): ...
@dag(schedule=[CLEANED_DATA]) # 数据就绪后自动触发
def consumer():
@task
def train_model(): ...
4. Priority and Resource Isolation
# 关键DAG使用高优先级和专用Pool
@dag(dag_id="critical_pipeline", priority_weight=100, pool="revenue_pool")
def critical_pipeline(): ...
# 非关键DAG使用低优先级
@dag(dag_id="experimental_pipeline", priority_weight=1, pool="default_pool")
def experimental_pipeline(): ...
5. Monitoring Metrics and Alert Integration
# airflow.cfg 开启StatsD指标导出
# [metrics]
# statsd_on = True
# statsd_host = localhost
# statsd_port = 8125
def sla_callback(dag, task_list, blocking_task_list, dagrun, *args, **kwargs):
import requests
from airflow.models import Variable
webhook_url = Variable.get("slack_webhook_url")
message = {"text": f":warning: SLA Violation\nDAG: {dag.dag_id}\nRun: {dagrun.execution_date}\nBlocking: {[t.task_id for t in blocking_task_list]}"}
requests.post(webhook_url, json=message)
default_args = {"sla": timedelta(hours=2), "sla_miss_callback": sla_callback}
Comparison: Orchestration Tools
| Dimension | Apache Airflow | Prefect | Dagster | Luigi |
|---|---|---|---|---|
| Orchestration | DAG definition | Functional+decorators | Asset definition | Task dependency |
| Data-aware Scheduling | Dataset (2.4+) | Native | Native Asset | Not supported |
| Dynamic Tasks | Dynamic Task Mapping | Native dynamic | Native dynamic | Not supported |
| Custom Operator | Powerful plugin ecosystem | Decorator-based | Software-defined Asset | Simple Task class |
| XCom/Data Passing | 48KB limit, custom Backend | Native unlimited | Native Type system | Simple parameter passing |
| Error Handling | Retry+SLA+callbacks | Native retry+state | Native retry+Asset state | Simple retry |
| Monitoring | Web UI + StatsD | Cloud UI | Dagit UI | Basic Web |
| Learning Curve | Medium | Low | Medium-high | Low |
| Community | Most mature, most plugins | Fast growing | Growing | Declining |
| Kubernetes | KubernetesPodOperator | Native K8s Agent | Native K8s | Not supported |
| 2026 Recommendation | ★★★★★ | ★★★★ | ★★★★ | ★★ |
| Use Case | Enterprise ETL orchestration | Small-medium projects | Data asset intensive | Simple batch processing |
Related Tools
- JSON Formatter — Format JSON when debugging Airflow DAG configs and API responses
- Hash Encoder — Generate data fingerprints for XCom Redis Backend and verify data integrity
- cURL to Code — Convert API debugging cURL commands to Python code for ETL extraction tasks
Further Reading
- Python AI Data Pipeline — Complete data pipeline from ETL to feature store
- Python Celery Distributed Tasks — Alternative approach to distributed task orchestration
- Python Pydantic V2 Data Validation — Schema validation defense in data pipelines
- Apache Kafka Stream Processing — Stream processing for real-time data pipelines
Apache Airflow 2.10 remains the de facto standard for data pipeline orchestration in 2026. Remember five core principles: TaskFlow API is the first choice for DAG design — functional style is more concise and readable than Operator; Dynamic Task Mapping is the weapon for parallel ETL — one Task definition expands into N instances; Custom Operators are the cornerstone of reuse — encapsulate internal system logic once, the whole team benefits; XCom + branching is the key to flexible scheduling — data-driven decisions, different paths for different processing; Error handling and SLA are the baseline of production — DAGs without retries and monitoring are ticking time bombs when deployed. From DAG design to production operations, Airflow ecosystem is mature enough — the rest is your practice in business scenarios.
Try these browser-local tools — no sign-up required →