Jaco van der Laan

Multi-Tenant Airflow DAG Generation: Which DAGs to Generate and When

24/03/2026  ·  Automation  ·  Data Engineering

metadata sql
‹

Practical patterns for metadata-driven orchestration in multi-entity data platforms


Multi-Tenant Airflow DAG Generation: Which DAGs to Generate and When

Practical patterns for metadata-driven orchestration in multi-entity data platforms


The Scalability Problem

Your architecture is elegant: SQL dependencies derived from transformation code, Airflow calling Python loaders that check metadata conditions, a generic data model serving multiple entities. But you’re hitting a wall.

For each entity, you generate all Airflow DAGs. Then the Python loader checks at runtime if the SQL statement should execute. This works, but it doesn’t scale:

  • DAG explosion : 50 entities × 100 transformations = 5,000 DAGs
  • Scheduler overhead : Airflow parses all DAGs, even when most won’t run
  • False positives : Dashboards show failures for transformations that were never relevant
  • Resource waste : Workers spin up just to check metadata and exit

The question isn’t “how do we make Airflow faster?” It’s “which DAGs should we generate in the first place?”


The Core Insight: Three Layers of DAG Generation

Based on MDDE patterns and real-world multi-tenant implementations, there are three complementary strategies:

┌─────────────────────────────────────────────────────────────────────┐  
│                     DAG GENERATION STRATEGIES                       │  
├─────────────────────────────────────────────────────────────────────┤  
│                                                                     │  
│  Strategy 1: CONTRACT-BASED DAG GENERATION                          │  
│  ─────────────────────────────────────────────                      │  
│  Generate DAGs only for what each entity actually uses              │  
│  → Reduces DAG count by 60-80%                                      │  
│                                                                     │  
│  Strategy 2: HIERARCHICAL DAG STRUCTURE                             │  
│  ─────────────────────────────────────────                          │  
│  One coordination DAG triggers entity-specific sub-DAGs             │  
│  → Reduces scheduler parsing by 90%                                 │  
│                                                                     │  
│  Strategy 3: DYNAMIC TASK MAPPING                                   │  
│  ─────────────────────────────────                                  │  
│  Single DAG with runtime-expanded tasks per entity                  │  
│  → Best for homogeneous transformations                             │  
│                                                                     │  
└─────────────────────────────────────────────────────────────────────┘

Strategy 1: Contract-Based DAG Generation

The Problem with “Generate Everything”

Your current approach:

# Current: Generate all DAGs for all entities  
for entity in all_entities:  
    for transformation in all_transformations:  
        generate_dag(entity, transformation)  # 5,000 DAGs

The Python loader then checks at runtime:

def should_execute(entity, transformation, period):  
    contract = get_delivery_contract(entity)  
    if transformation not in contract.relevant_transformations:  
        return False  # Skip - but DAG still exists!

The Solution: Generate Only Relevant DAGs

Use the Delivery Contract metadata to filter DAG generation at build time:

# Better: Generate only relevant DAGs per entity  
for entity in all_entities:  
    contract = get_delivery_contract(entity)  
    sub_model = derive_sub_model(contract)

  for transformation in sub_model.transformations:  
          generate_dag(entity, transformation)  # 1,500 DAGs

MDDE Pattern: Entity Sub-Model Resolution

In MDDE, we model this as entity-specific sub-models derived from delivery contracts:

# delivery_contracts/bank_x.yaml  
entity_id: bank_x  
delivery_contract:  
  datasets:  
    - accounts  
    - customers  
    - transactions  
  excluded_datasets:  
    - cards          # Bank X doesn't deliver cards  
    - investments    # Not relevant for this entity

  table_overrides:  
      accounts:  
        excluded_columns:  
          - legacy_account_type  # Not delivered by this entity

The DAG generator reads this contract and generates only relevant DAGs:

def generate_entity_dags(entity_id: str) -> List[DAG]:  
    """Generate DAGs based on entity's delivery contract."""  
    contract = load_delivery_contract(entity_id)  
    global_model = load_global_data_model()

    # Derive sub-model from contract  
        sub_model = global_model.filter_by_contract(contract)  
        # Build dependency graph for sub-model only  
        dependency_graph = build_dependency_graph(sub_model)  
        # Generate DAGs for relevant transformations  
        dags = []  
        for transformation in sub_model.transformations:  
            dag = create_dag(  
                entity=entity_id,  
                transformation=transformation,  
                dependencies=dependency_graph.get_dependencies(transformation),  
                join_semantics=contract.get_join_semantics(transformation),  
            )  
            dags.append(dag)  
        return dags

Result


Strategy 2: Hierarchical DAG Structure

The Pattern: Coordination + Execution DAGs

Instead of flat DAGs per entity/transformation, use a two-level hierarchy:

┌─────────────────────────────────────────────────────────────────────┐  
│                    HIERARCHICAL DAG STRUCTURE                       │  
├─────────────────────────────────────────────────────────────────────┤  
│                                                                     │  
│  Level 1: COORDINATION DAGs (few, always parsed)                    │  
│  ┌──────────────────────────────────────────────────────────────┐   │  
│  │  daily_orchestrator                                          │   │  
│  │  ├── Check which entities have data ready                    │   │  
│  │  ├── Trigger relevant entity DAGs                            │   │  
│  │  └── Aggregate completion status                             │   │  
│  └──────────────────────────────────────────────────────────────┘   │  
│                           │                                         │  
│                           ▼                                         │  
│  Level 2: EXECUTION DAGs (many, triggered on demand)                │  
│  ┌──────────────┐ ┌──────────────┐ ┌────────────────┐               │  
│  │ bank_x_load  │ │ bank_y_load  │ │ fintech_z_load │               │  
│  │ - accounts   │ │ - accounts   │ │ - accounts     │               │  
│  │ - customers  │ │ - customers  │ │ - cards        │               │  
│  │ - txns       │ │ - txns       │ │ - txns         │               │  
│  └──────────────┘ └──────────────┘ └────────────────┘               │  
│                                                                     │  
└─────────────────────────────────────────────────────────────────────┘

Implementation

Level 1: Coordination DAG

 # dags/daily_orchestrator.py  
from airflow import DAG  
from airflow.operators.python import PythonOperator  
from airflow.operators.trigger_dagrun import TriggerDagRunOperator

def check_entity_readiness(**context):  
    """Check which entities have data ready for processing."""  
    execution_date = context['execution_date']  
    ready_entities = []  
    for entity in get_all_entities():  
        contract = get_delivery_contract(entity)  
        calendar = get_delivery_calendar(entity)  
        if calendar.expects_delivery(execution_date):  
            if check_source_completeness(entity, execution_date):  
                ready_entities.append(entity)  
    return ready_entities

with DAG(  
    dag_id="daily_orchestrator",  
    schedule="@daily",  
    tags=["orchestration", "coordinator"],  
) as dag:  
    check_readiness = PythonOperator(  
        task_id="check_entity_readiness",  
        python_callable=check_entity_readiness,  
    )  
    # Dynamic task generation based on readiness  
    # (Using Airflow 2.3+ dynamic task mapping)  
    trigger_entities = TriggerDagRunOperator.partial(  
        task_id="trigger_entity_load",  
        wait_for_completion=True,  
        deferrable=True,  # Free worker while waiting  
    ).expand(  
        trigger_dag_id=check_readiness.output.map(lambda e: f"{e}_load"),  
        conf=check_readiness.output.map(lambda e: {"entity": e}),  
    )

Level 2: Entity Execution DAG

 # dags/entity_dags/bank_x_load.py  
def create_entity_dag(entity_id: str) -> DAG:  
    """Create execution DAG for specific entity."""  
    contract = get_delivery_contract(entity_id)  
    sub_model = derive_sub_model(contract)

with DAG(  
        dag_id=f"{entity_id}_load",  
        schedule=None,  # Triggered by coordinator  
        tags=["execution", entity_id],  
    ) as dag:  
        # Generate tasks based on sub-model  
        tasks = {}  
        for transformation in sub_model.transformations:  
            tasks[transformation.id] = create_transformation_task(  
                entity_id=entity_id,  
                transformation=transformation,  
                join_semantics=contract.get_join_semantics(transformation),  
            )  
        # Wire dependencies from sub-model  
        for transformation in sub_model.transformations:  
            for dep in transformation.dependencies:  
                if dep.id in tasks:  
                    tasks[dep.id] >> tasks[transformation.id]  
    return dag  
# Generate DAGs for all entities  
for entity in get_all_entities():  
    globals()[f"{entity}_load"] = create_entity_dag(entity)

Benefits


Strategy 3: Dynamic Task Mapping (Airflow 2.3+)

When to Use This

For homogeneous transformations where the same logic applies across entities, use a single DAG with dynamic task mapping:

# dags/generic_entity_loader.py  
from airflow.decorators import dag, task

@dag(  
    dag_id="generic_entity_loader",  
    schedule="@daily",  
    tags=["multi-tenant"],  
)  
def generic_entity_loader():

    @task  
    def get_ready_entities(execution_date) -> List[dict]:  
        """Return entities ready for processing with their config."""  
        ready = []  
        for entity in get_all_entities():  
            contract = get_delivery_contract(entity)  
            calendar = get_delivery_calendar(entity)  
            if calendar.expects_delivery(execution_date):  
                completeness = check_source_completeness(entity, execution_date)  
                if completeness.inner_sources_ready:  
                    ready.append({  
                        "entity_id": entity,  
                        "sub_model": derive_sub_model(contract),  
                        "completeness": completeness,  
                    })  
        return ready

    @task  
    def load_entity(entity_config: dict):  
        """Load data for a single entity."""  
        entity_id = entity_config["entity_id"]  
        sub_model = entity_config["sub_model"]  
        for transformation in topological_sort(sub_model.transformations):  
            execute_transformation(  
                entity_id=entity_id,  
                transformation=transformation,  
            )  
        return {"entity_id": entity_id, "status": "success"}

    @task  
    def publish_completeness(results: List[dict]):  
        """Update completeness dashboard."""  
        for result in results:  
            update_completeness_api(  
                entity=result["entity_id"],  
                status=result["status"],  
            )

    # Wire it together with dynamic mapping  
    entities = get_ready_entities()  
    results = load_entity.expand(entity_config=entities)  
    publish_completeness(results)

generic_entity_loader()

Combining with Deferrable Operators

For Databricks SQL execution, use deferrable operators to avoid blocking workers:

from airflow.providers.databricks.operators.databricks_sql import DatabricksSqlOperator

@task  
def load_entity_deferred(entity_config: dict):  
    """Load entity using deferrable Databricks SQL."""  
    entity_id = entity_config["entity_id"]  
    sub_model = entity_config["sub_model"]

    tasks = []  
    for transformation in sub_model.transformations:  
        sql = generate_transformation_sql(entity_id, transformation)

        task = DatabricksSqlOperator(  
            task_id=f"{entity_id}_{transformation.id}",  
            databricks_conn_id="databricks_default",  
            sql=sql,  
            deferrable=True,  # Free worker while Databricks processes  
        )  
        tasks.append(task)

    return tasks

MDDE Integration: The Complete Pattern

1. Metadata Structure

# models/entities/bank_x/delivery_contract.yaml  
entity:  
  id: bank_x  
  name: "Bank X"

delivery_contract:  
  # Which datasets this entity delivers  
  datasets:  
    - id: accounts  
      tables: [stg_accounts, stg_account_types]  
      frequency: daily  
      sla: "T+1 10:00 CET"

    - id: customers  
      tables: [stg_customers, stg_addresses]  
      frequency: daily  
      sla: "T+1 10:00 CET"

    - id: cards  
      status: excluded  # Not delivered by this entity  
      reason: "Product not offered"

  # Join semantics for enrichment  
  join_semantics:  
    accounts_enriched:  
      inner_joins: [stg_accounts, stg_customers]  # Must have both  
      left_joins: [stg_cards, stg_preferences]    # Optional enrichment

  # Calendar overrides  
  calendar:  
    exceptions:  
      - date: "2026-01-01"  
        action: skip  
        reason: "Bank holiday"

2. Sub-Model Resolution

# src/mdde/orchestration/sub_model.py  
from dataclasses import dataclass  
from typing import List, Set

@dataclass  
class SubModel:  
    """Entity-specific subset of the global data model."""  
    entity_id: str  
    tables: Set[str]  
    transformations: List[Transformation]  
    dependencies: Dict[str, List[str]]  
    join_semantics: Dict[str, JoinSemantics]

    @classmethod  
    def from_contract(cls, entity_id: str, contract: DeliveryContract, global_model: DataModel):  
        """Derive sub-model from delivery contract."""  
        # Start with contracted datasets  
        relevant_tables = set()  
        for dataset in contract.datasets:  
            if dataset.status != "excluded":  
                relevant_tables.update(dataset.tables)

        # Trace dependencies backwards from relevant tables  
        all_relevant = trace_dependencies(relevant_tables, global_model)  
        # Filter transformations to only relevant ones  
        transformations = [  
            t for t in global_model.transformations  
            if t.target in all_relevant  
        ]  
        # Build dependency graph for sub-model  
        dependencies = {}  
        for t in transformations:  
            deps = [d for d in t.dependencies if d in all_relevant]  
            dependencies[t.id] = deps

        return cls(  
            entity_id=entity_id,  
            tables=all_relevant,  
            transformations=transformations,  
            dependencies=dependencies,  
            join_semantics=contract.join_semantics,  
        )

3. DAG Generation Pipeline

# src/mdde/orchestration/dag_generator.py  
def generate_dags_for_entity(entity_id: str) -> List[DAG]:  
    """Generate Airflow DAGs for an entity based on its contract."""

    # Load metadata  
    contract = load_delivery_contract(entity_id)  
    global_model = load_global_data_model()  
    calendar = load_delivery_calendar(entity_id)

    # Derive sub-model  
    sub_model = SubModel.from_contract(entity_id, contract, global_model)

    # Choose generation strategy based on sub-model characteristics  
    if sub_model.is_simple():  
        # Single DAG with all transformations  
        return [generate_single_dag(entity_id, sub_model)]  
    else:  
        # Multiple DAGs grouped by dataset  
        return generate_dataset_dags(entity_id, sub_model)

def generate_single_dag(entity_id: str, sub_model: SubModel) -> DAG:  
    """Generate a single DAG for simple sub-models."""

    with DAG(  
        dag_id=f"{entity_id}_load",  
        schedule=None,  # Triggered by coordinator  
        tags=["execution", entity_id],  
        doc_md=generate_dag_documentation(entity_id, sub_model),  
    ) as dag:

        # Create tasks for each transformation  
        tasks = {}  
        for transformation in sub_model.transformations:  
            join_sem = sub_model.join_semantics.get(transformation.id)

            tasks[transformation.id] = create_task(  
                transformation=transformation,  
                entity_id=entity_id,  
                join_semantics=join_sem,  
                deferrable=True,  
            )

        # Wire dependencies  
        for t_id, deps in sub_model.dependencies.items():  
            for dep in deps:  
                if dep in tasks:  
                    tasks[dep] >> tasks[t_id]  
    return dag

4. Runtime Completeness Checking

# src/mdde/orchestration/completeness.py  
@dataclass  
class CompletenessResult:  
    entity_id: str  
    period: date  
    inner_sources_ready: bool  
    left_sources_ready: bool  
    missing_inner: List[str]  
    missing_left: List[str]  
    can_publish_base: bool  
    can_publish_enriched: bool

def check_completeness(entity_id: str, period: date) -> CompletenessResult:  
    """Check source completeness for an entity and period."""  
    contract = load_delivery_contract(entity_id)  
    sub_model = SubModel.from_contract(entity_id, contract, load_global_model())

    # Check each source table  
    available = set()  
    for table in sub_model.tables:  
        if table_has_data(table, entity_id, period):  
            available.add(table)

    # Separate by join semantics  
    for transformation in sub_model.transformations:  
        join_sem = sub_model.join_semantics.get(transformation.id)  
        if join_sem:  
            inner_ready = all(t in available for t in join_sem.inner_joins)  
            left_ready = all(t in available for t in join_sem.left_joins)  
            missing_inner = [t for t in join_sem.inner_joins if t not in available]  
            missing_left = [t for t in join_sem.left_joins if t not in available]

    return CompletenessResult(  
        entity_id=entity_id,  
        period=period,  
        inner_sources_ready=inner_ready,  
        left_sources_ready=left_ready,  
        missing_inner=missing_inner,  
        missing_left=missing_left,  
        can_publish_base=inner_ready,  
        can_publish_enriched=inner_ready and left_ready,  
    )

Decision Matrix: Which Strategy When?


Implementation Checklist

Phase 1: Metadata Foundation (Week 1–2)

  • [ ] Define delivery contract schema (YAML/JSON)
  • [ ] Create contracts for top 5 entities
  • [ ] Implement sub-model derivation logic
  • [ ] Build completeness checking API

Phase 2: DAG Generation (Week 3–4)

  • [ ] Implement contract-based DAG generator
  • [ ] Create coordinator DAG
  • [ ] Generate entity DAGs from contracts
  • [ ] Add deferrable operators for Databricks

Phase 3: Runtime Optimization (Week 5–6)

  • [ ] Implement dynamic task mapping for homogeneous loads
  • [ ] Add completeness dashboard
  • [ ] Configure per-entity resource pools
  • [ ] Set up alerting based on contract SLAs

Phase 4: Scale & Refine (Ongoing)

  • [ ] Extend contracts to all entities
  • [ ] Tune Airflow scheduler settings
  • [ ] Implement DAG versioning (Airflow 3.0)
  • [ ] Add self-service contract editing

Airflow Configuration Tips

Based on your multi-tenant Databricks setup:

# airflow.cfg optimizations for multi-tenant

[scheduler]  
# Reduce parsing overhead  
min_file_process_interval = 60  # Parse DAGs less frequently  
dag_dir_list_interval = 120     # Scan for new DAGs less often  
parsing_processes = 4           # Parallel parsing

[core]  
# Optimize for triggered DAGs  
max_active_tasks_per_dag = 16   # Per-entity parallelism  
max_active_runs_per_dag = 1     # One run per entity at a time  
parallelism = 64                # Total concurrent tasks

[operators]  
# Enable deferrable by default where available  
default_deferrable = True

Closing Thoughts

The path from “generate all DAGs, check at runtime” to “generate only relevant DAGs, execute efficiently” involves three shifts:

  1. Metadata as the source of truth — Delivery contracts define what’s relevant, not code
  2. Build-time filtering — Generate only what’s needed, not everything possible
  3. Runtime intelligence — Completeness-aware triggering, not blind scheduling

Your Python loader checking metadata at runtime isn’t wrong — it’s just happening too late. Move that intelligence to DAG generation time, and your Airflow instance becomes a lean orchestrator instead of a overwhelmed babysitter.