Advanced Airflow: Dynamic Workflows and Production Patterns
Moving beyond basic DAG construction, advanced Airflow patterns enable dynamic pipeline generation, complex conditional logic, cross-task data exchange, and custom execution environments.
XCom: Cross-Communication Between Tasks
What is XCom?
- Airflow's mechanism for passing small amounts of data between tasks
- Essential for dynamic workflows
- Recommended maximum payload: ~48KB
Best Practices:
- Don't store large datasets â degrades metadata database performance
- Pass references â file paths, S3 URIs instead of values
- Keep payloads small â avoid OOM errors
Key Insight: For larger data, pass references (file paths, S3 URIs) instead of values.
Architecture Diagram
XCom Usage Patterns
| Pattern | Description | Data Size | Backend |
|---|---|---|---|
| Pass reference | Store file path, not data | < 100 bytes | Default DB |
| Push metadata | Row counts, status codes | < 1 KB | Default DB |
| Push DataFrame | Small DataFrames (< 1000 rows) | 1-10 KB | S3/GCS backend |
| Push large data | Large datasets (> 10KB) | > 10 KB | S3/GCS backend |
| Push dict | Configuration, parameters | 1-5 KB | Default DB |
| Push JSON | API response payloads | 1-50 KB | S3/GCS backend |
| Push file path | Reference to output file | < 200 bytes | Default DB |
| Push artifact | Model weights, embeddings | > 100 KB | S3/GCS backend |
| Cross-DAG | Pass data between DAGs | Any | S3/GCS backend |
| Template | Use Jinja templates for XCom | < 1 KB | N/A (templated) |
Production Code: Advanced Airflow Patterns
Dynamic DAG Factory
python
from datetime import datetime, timedelta
from typing import List, Dict
from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.operators.empty import EmptyOperator
from airflow.utils.task_group import TaskGroup
import yaml
import logging
logger = logging.getLogger(__name__)
def create_etl_dag(
dag_id: str,
source_config: Dict,
target_config: Dict,
schedule: str = "@daily",
) -> DAG:
"""Factory function to create an ETL DAG from configuration."""
default_args = {
"owner": "data-engineering",
"retries": 3,
"retry_delay": timedelta(minutes=5),
"execution_timeout": timedelta(hours=1),
}
def extract(**context):
import pandas as pd
import sqlalchemy
engine = sqlalchemy.create_engine(source_config["connection_string"])
query = source_config["query"].replace(
"{{ds}}", context["ds"]
)
df = pd.read_sql(query, engine)
output_path = f"s3://data-lake/raw/{dag_id}/{context['ds_nodash']}/data.parquet"
df.to_parquet(output_path, index=False)
context["ti"].xcom_push(key="input_path", value=output_path)
context["ti"].xcom_push(key="row_count", value=len(df))
logger.info(f"Extracted {len(df)} rows to {output_path}")
def transform(**context):
import pandas as pd
input_path = context["ti"].xcom_pull(
task_ids=f"{dag_id}.etl.extract", key="input_path"
)
df = pd.read_parquet(input_path)
for step in source_config.get("transform_steps", []):
if step["type"] == "filter":
df = df.query(step["condition"])
elif step["type"] == "rename":
df = df.rename(columns=step["mapping"])
elif step["type"] == "drop_nulls":
df = df.dropna(subset=step["columns"])
output_path = f"s3://data-lake/staging/{dag_id}/{context['ds_nodash']}/data.parquet"
df.to_parquet(output_path, index=False)
context["ti"].xcom_push(key="output_path", value=output_path)
context["ti"].xcom_push(key="transformed_count", value=len(df))
def load(**context):
import pandas as pd
import sqlalchemy
output_path = context["ti"].xcom_pull(
task_ids=f"{dag_id}.etl.transform", key="output_path"
)
df = pd.read_parquet(output_path)
engine = sqlalchemy.create_engine(target_config["connection_string"])
df.to_sql(
target_config["table"],
engine,
if_exists="append",
index=False,
chunksize=1000,
)
logger.info(f"Loaded {len(df)} rows to {target_config['table']}")
with DAG(
dag_id=dag_id,
default_args=default_args,
schedule_interval=schedule,
start_date=datetime(2024, 1, 1),
catchup=False,
max_active_runs=1,
tags=["dynamic", "etl"],
) as dag:
start = EmptyOperator(task_id="start")
with TaskGroup("etl") as etl_group:
extract_task = PythonOperator(
task_id="extract", python_callable=extract
)
transform_task = PythonOperator(
task_id="transform", python_callable=transform
)
load_task = PythonOperator(
task_id="load", python_callable=load
)
extract_task >> transform_task >> load_task
end = EmptyOperator(task_id="end", trigger_rule="none_failed")
start >> etl_group >> end
return dag
# Dynamic DAG generation from YAML config
with open("/opt/airflow/config/pipeline_configs.yaml") as f:
configs = yaml.safe_load(f)
for pipeline in configs["pipelines"]:
dag = create_etl_dag(
dag_id=f"dynamic_{pipeline['name']}",
source_config=pipeline["source"],
target_config=pipeline["target"],
schedule=pipeline.get("schedule", "@daily"),
)
globals()[dag.dag_id] = dag
TaskFlow API with Dynamic Mapping
python
from airflow import DAG
from airflow.decorators import task, dag
from datetime import datetime
from typing import List
default_args = {
"owner": "data-engineering",
"retries": 2,
"retry_delay": 300,
}
@dag(
schedule="@daily",
start_date=datetime(2024, 1, 1),
catchup=False,
default_args=default_args,
tags=["taskflow", "dynamic-mapping"],
)
def dynamic_mapping_etl():
@task
def get_table_list() -> List[str]:
"""Dynamically discover tables to process."""
import sqlalchemy
engine = sqlalchemy.create_engine("postgresql://...")
with engine.connect() as conn:
result = conn.execute(
"SELECT table_name FROM information_schema.tables "
"WHERE table_schema = 'public' AND table_name LIKE 'events_%'"
)
return [row[0] for row in result]
@task
def extract_table(table_name: str) -> str:
"""Extract a single table to a parquet file."""
import pandas as pd
import sqlalchemy
engine = sqlalchemy.create_engine("postgresql://...")
df = pd.read_sql(f"SELECT * FROM {table_name}", engine)
output_path = f"s3://raw/{table_name}/latest.parquet"
df.to_parquet(output_path, index=False)
return output_path
@task
def validate_table(table_name: str, file_path: str) -> dict:
"""Validate extracted data quality."""
import pandas as pd
df = pd.read_parquet(file_path)
null_rate = df.isnull().mean().mean()
duplicate_rate = 1 - (len(df.drop_duplicates()) / len(df))
return {
"table": table_name,
"row_count": len(df),
"null_rate": float(null_rate),
"duplicate_rate": float(duplicate_rate),
"is_valid": null_rate < 0.1 and duplicate_rate < 0.05,
}
@task(trigger_rule="none_failed")
def aggregate_results(validations: List[dict]) -> dict:
"""Aggregate validation results across all tables."""
total_tables = len(validations)
valid_tables = sum(1 for v in validations if v["is_valid"])
return {
"total_tables": total_tables,
"valid_tables": valid_tables,
"validity_rate": valid_tables / total_tables if total_tables > 0 else 0,
}
# Dynamic task mapping
tables = get_table_list()
raw_files = extract_table.expand(table_name=tables)
validations = validate_table.expand(
table_name=tables, file_path=raw_files
)
aggregate_results(validations)
dynamic_mapping_etl()
Performance Comparison
| Pattern | DAG Count | Scheduler Load | Execution Time | Developer Velocity |
|---|---|---|---|---|
| Static DAGs | 1 per pipeline | Low | Optimal | Low (boilerplate) |
| Dynamic DAGs | Variable | Medium | Optimal | High (config-driven) |
| TaskFlow API | Same as static | Low | Optimal | High (decorator-based) |
| Dynamic Mapping | Same as static | Low | Optimal (parallel) | High (runtime scaling) |
| SubDAGs | 1 parent + N children | High | Suboptimal | Medium |
| TaskGroups | Same as static | Low | Optimal | High (visual grouping) |
Best Practices
- Use S3/GCS XCom backend in production to avoid metadata database bloat from large payloads.
- Store references, not data in XComs. Pass file paths, S3 URIs, or database query results instead of raw data.
- Limit dynamic DAG count to < 10,000 active DAGs per Airflow instance for scheduler performance.
- Tag all dynamically generated DAGs with version identifiers for rollback and debugging.
- Use TaskFlow API (
@taskdecorator) instead ofPythonOperatorfor new Python-heavy workflows. - Implement
trigger_rulecorrectly on join tasks to handle branching failure scenarios. - Set
execution_timeouton all tasks to prevent hung tasks from consuming worker slots indefinitely. - Test dynamic DAGs by running
airflow dags testfor each generated DAG ID. - Monitor XCom usage â alert when XCom payload sizes exceed 48KB threshold.
- Use
@task.virtualenvto run tasks in isolated virtual environments when dependency conflicts exist between tasks.
Dynamic DAG Performance Characteristics
| Pattern | Max DAGs | Scheduler Overhead | Memory Usage | Startup Time |
|---|---|---|---|---|
| Static DAGs | ~1,000 | Low | Low | Fast |
| Dynamic DAGs | ~10,000 | Medium | Medium | Medium |
| TaskFlow API | ~5,000 | Low | Low | Fast |
| Dynamic Mapping | ~5,000 | Low | Medium | Fast |
| SubDAGs | ~500 | High | High | Slow |
| TaskGroups | ~1,000 | Low | Low | Fast |
See Also
- 017 - Apache Airflow: DAGs, Operators, and Scheduling - Airflow fundamentals and core concepts
- 029 - Prefect: Modern Workflow Orchestration - Python-native alternative to Airflow
- 028 - Error Handling, Retries, and Dead Letter Queues - Error handling patterns for Airflow tasks
- 027 - Pipeline Monitoring and Observability - Monitoring DAG execution and performance
- 026 - Data Pipeline Testing - Testing dynamic DAGs and TaskFlow functions