DEV Community

Cover image for Airflow vs Dagster vs Prefect vs Kestra vs Mage: Orchestrator Decision Matrix for 2026
Gowtham Potureddi
Gowtham Potureddi

Posted on

Airflow vs Dagster vs Prefect vs Kestra vs Mage: Orchestrator Decision Matrix for 2026

orchestrator comparison is the pick-one platform decision that quietly defines how a data engineering team ships pipelines for the next three years — who authors them (software engineers vs analysts), how lineage is captured (asset graph vs task DAG), how scaling works (Celery vs Kubernetes vs work pools), how partitions and backfills behave under pressure, and whether you buy the managed tier or run the OSS control plane yourself. It is the single biggest platform bet most data teams make and the single most common "why did your team pick X?" question in senior interviews. The five contenders that matter in 2026 — Airflow, Dagster, Prefect, Kestra, Mage — each ship a defensible answer to a different combination of these axes, and picking the wrong one binds every downstream cost, from onboarding time to on-call pager load, for years.

This guide is the senior-DE walkthrough you wanted the first time an interviewer asked "walk me through the data pipeline orchestrator landscape and defend your pick," or "why would you migrate off Airflow?", or "asset graph vs task DAG — which mental model do you prefer and why?" It walks through the five canonical 2026 stacks — Airflow (airflow alternatives are the whole reason the market fragmented), Dagster (Software-Defined Assets and the asset-graph mental model), Prefect (task-graph with work pools and hybrid orchestration), Kestra (YAML-first, Kafka-backed, polyglot), and Mage (block-based, notebook-friendly, data-app UI) — the four axes interviewers actually probe (authoring persona, lineage model, execution architecture, vendor / OSS + managed tier), the mental models for task-graph vs asset-graph, the scheduler + queue + worker topologies each ships to production, the 7-axis feature matrix, and the 5-question decision tree that lands a stack pick in under 60 seconds. Each section pairs a teaching block with a Solution-Tail interview answer — code, a step-by-step trace, an output table, then a concept-by-concept breakdown of why it works.

PipeCode blog header for orchestrator comparison — bold white headline 'Orchestrator Decision Matrix' over a hero composition of five glyph medallions (Airflow feather, Dagster asset-node, Prefect wave, Kestra YAML tag, Mage block) arranged around a central purple 'pick one 2026' seal, on a dark gradient.

When you want hands-on reps immediately after reading, drill the SQL practice library →, rehearse on the design practice library →, and sharpen the streaming axis with the streaming practice library →.


On this page


1. Why the orchestrator choice matters in 2026

Five stacks, five wildly different platform bets — the choice binds you for years

The one-sentence invariant: an orchestrator comparison is a picking exercise between Airflow (Python task DAG, the incumbent), Dagster (Python asset graph, the data-first challenger), Prefect (Python task graph with hybrid work pools), Kestra (YAML-first polyglot with a Kafka executor), and Mage (Python blocks with a data-app UI) — and each choice trades authoring persona, lineage model, execution architecture, and vendor / OSS + managed-tier posture in a way that cannot be undone without a full pipeline rewrite. The stack you pick in month one becomes the stack you fight to migrate away from in year three, because every DAG, every operator import, every asset definition, every YAML flow, and every notebook block hard-codes assumptions about the underlying scheduler, the metadata schema, the RBAC surface, and the deployment topology.

The four axes interviewers actually probe.

  • Authoring persona. Airflow and Prefect assume Python-fluent data or software engineers writing Python DAGs with operators or tasks. Dagster assumes the same Python audience but with a data-modelling mindset — you declare assets (tables, files, models), not tasks. Kestra assumes a polyglot team where YAML is the lingua franca and Python / Bash / SQL / Node tasks live under a common flow schema. Mage assumes analytics engineers and data scientists comfortable in notebooks and block-based pipelines. Interviewers open with the persona question because it separates people who understand team dynamics from those who benchmark toy DAGs.
  • Lineage model. Airflow / Prefect / Kestra / Mage give you a task graph — nodes are units of compute, edges are ordering. Dagster gives you an asset graph — nodes are data assets that should exist, edges are data dependencies. The asset-graph pivot changes retries (rematerialise an asset, not rerun a task), backfills (fill a partition of an asset, not replay a range of DAG runs), lineage (native, at asset granularity, no separate OpenLineage plumbing), and observability (asset freshness, asset health, asset partitions). Interviewers probe this axis because "SDA vs task DAG" is the single biggest architectural fork in the orchestrator market.
  • Execution architecture. Airflow ships scheduler + webserver + Celery / K8s executor + workers with Postgres metadata. Dagster ships webserver + daemon + code servers + user-code Docker containers + Postgres. Prefect ships server + work pools (push or pull) + workers + agents. Kestra ships Kafka + Postgres + executor + worker + webserver. Mage ships a single-container backend + scheduler + workers with a data-app UI. Every stack needs a scheduler, a queue, and a worker; what they call each box differs, and the operational shape (single-node dev vs distributed K8s prod) varies.
  • Vendor / OSS + managed tier. Airflow is Apache 2.0 OSS; managed tiers on Astronomer, AWS MWAA, GCP Composer, Azure Data Factory Managed Airflow. Dagster is Apache 2.0 OSS; Dagster+ is the managed tier from Dagster Labs. Prefect is Apache 2.0 OSS; Prefect Cloud is the managed tier from Prefect Technologies. Kestra is Apache 2.0 OSS; Kestra Cloud + Enterprise Edition. Mage is Apache 2.0 OSS; Mage Pro is the managed tier from Mage AI. Every stack is OSS + a hosted control plane; the pricing shape (seat vs run vs compute) and the ecosystem depth differ dramatically.

The 2026 reality — Airflow is still the incumbent, but the challengers each own a niche.

  • Airflow is the default for large enterprise data teams with mature Python + K8s + dbt + Snowflake stacks. Its 15-year ecosystem (2,000+ providers, every managed tier on every cloud, every consultant fluent in it) is the reason it survives despite the operational complexity. Airflow 3.0 (2025) landed with a new task SDK, DAG versioning, and a rebuilt UI — enough to defend the incumbent for another cycle.
  • Dagster is the default for teams doing analytics engineering with dbt, where the asset graph maps 1:1 onto the dbt DAG. Dagster's Software-Defined Assets (SDA) model, native partitions, native asset lineage, and Dagster+ Insights (cost + health per asset) make it the strongest asset-first choice. Best for greenfield analytics-engineering platforms that expect to hire dbt-fluent staff.
  • Prefect is the default for teams that want Python-first orchestration without Airflow's DAG-file-as-single-unit-of-deploy rigidity. Prefect 3.0's work-pool + worker model separates infrastructure from flow definition; flows are just Python functions with a @flow decorator. Best for teams shipping many small pipelines with heterogeneous compute (some on K8s, some on ECS, some on serverless).
  • Kestra is the default for polyglot teams where SQL / Bash / Python / Node / JavaScript all coexist and YAML is the pipeline-as-code lingua franca. Kestra's Kafka-backed executor and multi-tenant enterprise features make it a strong pick for platform teams building an orchestrator-as-a-service. Best for teams that reject "everything must be Python" and want first-class no-code / low-code flow authoring.
  • Mage is the default for smaller teams and analytics engineers who want a notebook-friendly, block-based authoring UI with data-app previews on every pipeline run. Mage collapses "notebook exploration → production pipeline" into one artifact. Best for teams under ~10 pipelines who want a lower-friction on-ramp than Airflow.

What interviewers listen for.

  • Do you name all five stacks without prompting? — senior signal.
  • Do you say "the asset graph is Dagster's biggest bet" in the first sentence when Dagster comes up? — required answer.
  • Do you push back on "just use Airflow" with the authoring-persona question — "who's writing pipelines and what's their fluency?" — senior signal.
  • Do you name the migration cost (Airflow → Dagster ≈ 6 months for a mid-sized platform) rather than pitching migration as free? — senior signal.
  • Do you describe orchestration as "a scheduler + a work queue + workers with a metadata plane" rather than as vague "pipeline running"? — required answer.

Worked example — the four-axis comparison table

Detailed explanation. The single most useful artifact for an orchestrator interview is a memorised 4×5 comparison table. Every senior orchestrator discussion converges on this table within the first ten minutes; having it in your head is what separates a fluent answer from a stumbling one. Walk through building the table for a hypothetical greenfield analytics-engineering platform picking between the five stacks.

  • Scenario. Greenfield analytics platform: 50 dbt models, 20 ingestion pipelines, 5 reverse-ETL syncs, deployed on AWS EKS.
  • Team. 6 data engineers (all Python-fluent), 4 analytics engineers (SQL + dbt-fluent, comfortable with Python).
  • Constraints. Sub-hour freshness on top 10 assets, PII masked at the source, on-call rotation of 2 engineers.
  • Budget. ≤ $50k / year on the managed tier, ≤ 30% of one platform engineer's time on maintenance.

Question. Build the four-axis × five-stack comparison for this platform and pick the stack.

Input.

Stack Authoring persona Lineage model Execution Managed tier
Airflow Python DE/SWE task DAG scheduler + Celery / K8s MWAA / Astronomer / Composer
Dagster Python DE + AE asset graph (SDA) daemon + code servers Dagster+
Prefect Python DE task graph + work pools server + workers Prefect Cloud
Kestra polyglot + YAML task DAG Kafka + executor Kestra Cloud
Mage AE + notebook DS block DAG backend + scheduler Mage Pro

Code.

# Same 3-stage pipeline expressed in each stack — read the shape, not the dialect

# Airflow (2.10 / 3.0 task-flow API)
from airflow.decorators import dag, task
from datetime import datetime

@dag(schedule="@hourly", start_date=datetime(2026, 1, 1), catchup=False)
def orders_pipeline():
    @task
    def extract() -> list[dict]:
        return read_from_postgres("SELECT * FROM orders WHERE updated_at > %s", (last_watermark(),))
    @task
    def transform(rows: list[dict]) -> list[dict]:
        return [clean(r) for r in rows]
    @task
    def load(rows: list[dict]) -> None:
        write_to_snowflake(rows, table="raw.orders")
    load(transform(extract()))

orders_pipeline()
Enter fullscreen mode Exit fullscreen mode
# Dagster (SDA — assets, not tasks)
from dagster import asset, HourlyPartitionsDefinition

partitions = HourlyPartitionsDefinition(start_date="2026-01-01-00:00")

@asset(partitions_def=partitions)
def raw_orders(context) -> list[dict]:
    hour = context.partition_key
    return read_from_postgres(
        "SELECT * FROM orders WHERE hour = %s", (hour,)
    )

@asset(partitions_def=partitions)
def clean_orders(raw_orders: list[dict]) -> list[dict]:
    return [clean(r) for r in raw_orders]

@asset(partitions_def=partitions)
def snowflake_orders(clean_orders: list[dict]) -> None:
    write_to_snowflake(clean_orders, table="raw.orders")
Enter fullscreen mode Exit fullscreen mode
# Prefect (task graph + work pools)
from prefect import flow, task

@task(retries=3, retry_delay_seconds=60)
def extract() -> list[dict]: ...
@task
def transform(rows: list[dict]) -> list[dict]: ...
@task
def load(rows: list[dict]) -> None: ...

@flow(name="orders-pipeline")
def orders_pipeline():
    load(transform(extract()))

# Deployed against a work pool: prefect deploy --pool k8s-pool
Enter fullscreen mode Exit fullscreen mode
# Kestra (YAML flow)
id: orders_pipeline
namespace: prod.orders
triggers:
  - id: hourly
    type: io.kestra.plugin.core.trigger.Schedule
    cron: "0 * * * *"
tasks:
  - id: extract
    type: io.kestra.plugin.jdbc.postgresql.Query
    sql: "SELECT * FROM orders WHERE updated_at > {{ trigger.previous }}"
  - id: transform
    type: io.kestra.plugin.scripts.python.Script
    script: |
      import json, sys
      rows = json.loads(sys.stdin.read())
      out  = [{"id": r["id"], "amount": r["total"] / 100.0} for r in rows]
      print(json.dumps(out))
  - id: load
    type: io.kestra.plugin.snowflake.Query
    sql: "COPY INTO raw.orders FROM @stage/{{ outputs.transform.uri }}"
Enter fullscreen mode Exit fullscreen mode
# Mage (block-based DAG)
# data_loader.py — one block
@data_loader
def extract() -> list[dict]:
    return read_from_postgres("SELECT * FROM orders WHERE updated_at > %s", (last_watermark(),))

# transformer.py — one block
@transformer
def transform(rows: list[dict]) -> list[dict]:
    return [clean(r) for r in rows]

# data_exporter.py — one block
@data_exporter
def load(rows: list[dict]) -> None:
    write_to_snowflake(rows, table="raw.orders")
# Blocks are wired in the UI or via metadata.yaml
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. The five code snippets illustrate the same 3-stage ETL (extract → transform → load) expressed in five stack-native dialects. The shapes are near-identical for Airflow / Prefect (both are task graphs with Python decorators); Dagster diverges (assets, not tasks); Kestra diverges (YAML, polyglot); Mage diverges (block files with typed IO). The choice is not about "which one can express the pipeline" — all five can — but about which mental model matches your team's fluency and your platform's lineage requirements.
  2. Airflow's task-flow API (@dag, @task) is the modern authoring surface as of 2.5+. It compiles down to the classic operator DAG but the ergonomics match Prefect closely. Deployed as a DAG file in a Git-synced folder, picked up by the scheduler, executed by Celery or K8sPodOperator workers.
  3. Dagster's @asset is the SDA declaration — you say "this asset should exist and it depends on raw_orders" rather than "run this task, then that one." Partitioning is first-class (HourlyPartitionsDefinition), so backfills, retries, and rematerialisations happen at the asset + partition level, not at the DAG-run level. Dagster+'s Insights ships cost per asset per partition out of the box.
  4. Prefect 3.0's @flow + @task looks like Airflow's task-flow API but the deployment model is different — flows are deployed against work pools (K8s, ECS, Docker, process) with workers pulling work. The separation of flow definition from infrastructure is Prefect's biggest differentiator; the same flow ships to K8s in prod and process locally in dev with a single-line deployment change.
  5. Kestra's YAML is the pipeline-as-code lingua franca; every task type is a type: reference (io.kestra.plugin.jdbc.postgresql.Query, io.kestra.plugin.scripts.python.Script). Polyglot is native — SQL, Python, Bash, Node, JavaScript all coexist in one flow. The Kafka-backed executor makes horizontal scale a matter of adding worker pods.
  6. Mage's block model is the notebook-friendly variant — each @data_loader / @transformer / @data_exporter lives in its own Python file with typed IO; blocks are wired in a data-app UI that renders sample output on every run. The on-ramp for analytics engineers is dramatically shorter than Airflow's DAG-file model.
  7. For the analytics-engineering scenario (50 dbt models, 20 ingestion, 5 reverse-ETL, PII masking, sub-hour freshness), the pick comes down to: Dagster if the asset graph maps onto the dbt DAG and asset-level lineage is worth the pivot; Airflow if the team already has Airflow muscle memory and MWAA / Astronomer covers the managed tier; Prefect if flow-plus-work-pool separation matters more than asset lineage. Kestra and Mage lose on this specific scenario because polyglot isn't required and the team is DE-heavy.

Output.

Requirement Winner Runner-up Reason
Analytics-engineering + dbt lineage Dagster Airflow SDA maps 1:1 onto dbt DAG
Incumbent Python team + K8s Airflow Prefect 15-year ecosystem
Flow-plus-work-pool separation Prefect Dagster work pools cleanest per-flow infra
Polyglot + YAML lingua franca Kestra Airflow first-class multi-language
Notebook-friendly + block UI Mage Prefect shortest on-ramp for AEs

Rule of thumb. Never pick an orchestrator based on "which one is trending on Twitter." Pick it based on (authoring persona × lineage model × execution architecture × managed tier). Draw the 4×5 table on a whiteboard first; the pick falls out of the constraints. Never migrate an existing platform without a 6-month runway budget.

Worked example — what interviewers actually probe

Detailed explanation. The 2026 senior data-engineering orchestrator interview has a predictable structure: the interviewer opens with an ambiguous question ("how would you build a data pipeline platform for a new team?"), then progressively narrows to test whether you know the axes. The candidates who name the stack in sentence one and defend it on all four axes score highest; the candidates who describe "we'd probably use Airflow" without justification score lowest. Walk through the interview grading rubric.

  • Ambiguous opener. "How would you build a data pipeline platform for a new analytics team?" — invites you to name a stack.
  • Follow-up 1. "Why not just use Airflow?" — probes incumbent-defence axis.
  • Follow-up 2. "How does your pick handle asset lineage?" — probes lineage-model axis.
  • Follow-up 3. "What does the prod topology look like?" — probes execution-architecture axis.
  • Follow-up 4. "What does the managed tier cost?" — probes vendor / OSS + budget axis.

Question. Draft a 5-minute senior orchestrator answer that covers all four axes without waiting to be asked.

Input.

Interview signal Weak answer Senior answer
Stack named "we'd probably use Airflow" "Dagster + Dagster+ for a dbt-heavy analytics platform"
Persona "everyone writes Python" "6 DEs Python-fluent + 4 AEs SQL+dbt-fluent; Dagster's asset model matches both"
Lineage "we have OpenLineage" "SDA gives asset-level lineage natively; no separate OpenLineage plumbing"
Architecture "we'd run on K8s" "webserver + daemon + code servers as separate deployments; user-code Docker per team"
Cost "it's on the roadmap" "Dagster+ Standard ~$0.03 per material step; ~$1.2k / mo for our volume"

Code.

Senior orchestrator answer template (5 minutes)
================================================

Minute 1 — name the stack up front
  "I'd default to Dagster with Dagster+ Standard for a greenfield
   analytics-engineering platform where dbt is the modelling layer.
   Airflow is the strong runner-up if the team already has
   Airflow muscle memory; Prefect if flow-plus-work-pool
   separation matters more than asset lineage."

Minute 2 — persona
  "The team is 6 Python-fluent DEs plus 4 dbt-fluent AEs. Dagster's
   @asset decorator maps onto how AEs already think — 'this table
   should exist' rather than 'run this task.' The Python surface is
   familiar to the DEs; the asset noun is familiar to the AEs."

Minute 3 — lineage
  "SDA gives asset-level lineage natively — every asset knows its
   upstream and downstream, every partition is a first-class
   entity, and Dagster+ Insights ships cost + health per asset out
   of the box. We don't need OpenLineage plumbing on top."

Minute 4 — architecture
  "The prod topology is webserver + daemon + code servers running
   in EKS, with per-team user-code Docker containers so team A's
   deploy doesn't restart team B's assets. Postgres for metadata,
   S3 for the compute log, Datadog for on-call."

Minute 5 — cost + migration
  "Dagster+ Standard is ~$0.03 per material step; at our volume
   (50 dbt models × hourly × 30 days = 36k steps/mo + ingestion) we
   land around $1.2k / month. If we ever migrate off, the SDA
   surface is Python — porting to Airflow's task DAG is a
   6-month project; we don't plan to."
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. Minute 1 is the crucial framing. Naming the stack immediately — "Dagster with Dagster+ Standard" — signals you're a decision-maker, not a task-runner. Weak candidates dive into features ("we'd use OpenLineage and dbt-artifacts…") before naming the platform, which telegraphs zero platform-selection experience.
  2. Minute 2 addresses the persona axis before the interviewer asks. The DE-plus-AE split is real in most 2026 teams; naming the split and mapping it to the stack's authoring surface preempts the "why not Airflow?" follow-up. Naming both roles and the specific decorator (@asset) is senior signal.
  3. Minute 3 is the lineage probe. Saying "SDA gives asset-level lineage natively" and naming Dagster+ Insights is the exact answer senior interviewers listen for. Weak candidates conflate lineage with metadata; senior candidates distinguish asset-graph lineage from task-DAG lineage.
  4. Minute 4 is the architecture axis. Naming the components (webserver + daemon + code servers), the deployment target (EKS), and the per-team isolation (user-code Docker) shows you've operated Dagster in prod. Weak candidates say "we'd run it on K8s" without naming the boxes.
  5. Minute 5 covers cost and migration — the axes that separate a demo from a production commitment. Naming the pricing shape ($/material step), the multiplication to a monthly figure, and the migration cost (6 months to port off) makes the answer complete. Rehearse it once against Dagster; port the template to Airflow / Prefect / Kestra / Mage by swapping the specifics.

Output.

Grading criterion Weak score Senior score
Names stack in minute 1 rare mandatory
Names persona split rare required
Names lineage model occasional mandatory
Names execution topology rare senior signal
Names cost per unit rare senior signal

Rule of thumb. The senior orchestrator answer is a 5-minute monologue that covers all four axes without waiting for the follow-ups. Rehearse it once against your default stack; deploy it every time. Interviewers grade on structure and named specifics, not on which stack you picked.

Worked example — the "pick the stack" 5-question decision tree

Detailed explanation. Given a new team + workload, the senior architect runs a 5-question decision tree in their head. Codifying the tree makes the interview answer reproducible: any interviewer can hand you a scenario and you can walk the tree out loud. Walk through the tree with three canonical scenarios: greenfield analytics-engineering platform, migrating off Airflow at a fintech, and a small AE team building a data-app pipeline.

  • Q1. Python-first team? → yes = go to Q2; no = Kestra.
  • Q2. Asset lineage / dbt-heavy? → yes = Dagster; no = go to Q3.
  • Q3. Work-pool / hybrid infrastructure? → yes = Prefect; no = go to Q4.
  • Q4. Notebook-friendly / block UI needed for AEs? → yes = Mage; no = Airflow.
  • Q5 (parallel). Managed-only mandate? → yes = pick the stack's cloud tier; no = OSS control plane on K8s.

Question. Walk the decision tree for the three scenarios and record the stack each ends up with.

Input.

Scenario Q1 (Python?) Q2 (asset lineage?) Q3 (work pool?) Q4 (notebook?) Q5 (managed?)
Greenfield analytics platform yes yes yes
Fintech migrating off Airflow yes no yes no yes
Small AE team + data-app yes no no yes maybe

Code.

# Decision-tree helper (illustrative)
def pick_orchestrator(python_first: bool,
                      asset_lineage: bool,
                      work_pool_infra: bool,
                      notebook_friendly: bool,
                      managed_only: bool) -> tuple[str, str]:
    """Return (primary orchestrator, managed tier if applicable)."""
    if not python_first:
        return ("Kestra", "Kestra Cloud" if managed_only else "self-hosted")
    if asset_lineage:
        return ("Dagster", "Dagster+" if managed_only else "self-hosted")
    if work_pool_infra:
        return ("Prefect", "Prefect Cloud" if managed_only else "self-hosted")
    if notebook_friendly:
        return ("Mage", "Mage Pro" if managed_only else "self-hosted")
    return ("Airflow", "MWAA or Astronomer" if managed_only else "self-hosted")


# Walk the three scenarios
print(pick_orchestrator(True,  True,  False, False, True))
# → ('Dagster', 'Dagster+')

print(pick_orchestrator(True,  False, True,  False, True))
# → ('Prefect', 'Prefect Cloud')

print(pick_orchestrator(True,  False, False, True,  False))
# → ('Mage', 'self-hosted')
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. Scenario 1 — greenfield analytics platform with dbt as the modelling layer. Q1 = yes (Python-first), Q2 = yes (asset lineage matches the dbt DAG) → Dagster; Q5 = yes → Dagster+. This is the modern analytics-engineering default.
  2. Scenario 2 — fintech team migrating off Airflow because DAG-file rigidity and single-node scheduler bottlenecks bit them at scale. Q1 = yes, Q2 = no (they don't need asset lineage; task DAG works fine), Q3 = yes (work pools give per-flow infrastructure separation) → Prefect; Q5 = yes → Prefect Cloud. Prefect Cloud + K8s work pool covers 90% of the Airflow migration reasons.
  3. Scenario 3 — small AE team of 3 building a data-app pipeline for a startup. Q1 = yes, Q2 = no, Q3 = no, Q4 = yes (notebook-friendly + block UI drives shorter on-ramp) → Mage. Self-hosted is fine at this scale; Mage Pro is optional. This is the "we don't need Airflow" pattern for smaller teams.
  4. The parallel Q5 branch (managed) is orthogonal to the primary pick. Every stack has a managed tier; the pick between managed and self-hosted turns on team size, on-call budget, and compliance requirements. Managed tiers cost ~$5k–$50k / year at mid-scale; self-hosting saves that but costs ~30% of one platform engineer's time.
  5. If Q1 = no (polyglot / YAML-first team), the tree short-circuits to Kestra. If none of the Python branches match cleanly, Airflow is the safe default — it's the incumbent for a reason and the ecosystem carries the operational burden.

Output.

Scenario Primary stack Managed tier
Greenfield analytics platform Dagster Dagster+
Fintech migrating off Airflow Prefect Prefect Cloud
Small AE team + data-app Mage self-hosted

Rule of thumb. The 5-question decision tree is a whiteboard-friendly answer. Practice walking it end-to-end so an interviewer can hand you any scenario and get a stack name in under 60 seconds. The tree order matters — Python-first first (screens out Kestra), asset lineage next (screens out Airflow / Prefect / Mage / Kestra), then infrastructure separation, then notebook UI, then Airflow as the fallback.

Senior interview question on orchestrator selection

A senior interviewer often opens with: "You're building a greenfield data platform for a 15-person data team — 10 DEs plus 5 analytics engineers — on AWS EKS. They have 40 dbt models, 15 ingestion pipelines, 3 reverse-ETL syncs, and a target of sub-hour freshness on top assets. Walk me through the orchestrator comparison you'd run, the stack you'd pick, and the cost + migration + on-call defence."

Solution Using Dagster + Dagster+ Standard on EKS with per-team code locations and dbt asset factory

# 1. Dagster project layout — one repo, multiple code locations per team
# analytics-platform/
# ├── platform/          — shared code (io_managers, resources, sensors)
# ├── team_ingest/       — code location: raw ingestion assets
# ├── team_marketing/    — code location: marketing dbt project
# ├── team_finance/      — code location: finance dbt project
# └── deploy/            — Docker + K8s manifests

# platform/resources.py
from dagster import ConfigurableResource
from dagster_snowflake import SnowflakeResource
from dagster_dbt import DbtCliResource

class Env(ConfigurableResource):
    snowflake: SnowflakeResource
    dbt:       DbtCliResource
Enter fullscreen mode Exit fullscreen mode
# 2. dbt asset factory — one call, N assets auto-generated
from dagster_dbt import DbtCliResource, dbt_assets, DagsterDbtTranslator
from pathlib import Path

DBT_PROJECT = Path(__file__).parent / "dbt"

@dbt_assets(manifest=DBT_PROJECT / "target" / "manifest.json",
            dagster_dbt_translator=DagsterDbtTranslator())
def marketing_dbt_assets(context, dbt: DbtCliResource):
    yield from dbt.cli(["build"], context=context).stream()
Enter fullscreen mode Exit fullscreen mode
# 3. Partitioned ingestion asset — hourly partitions, backfill-friendly
from dagster import asset, HourlyPartitionsDefinition, AssetExecutionContext
from datetime import datetime

hourly = HourlyPartitionsDefinition(start_date="2026-01-01-00:00")

@asset(partitions_def=hourly,
       key_prefix=["raw", "ingest"],
       group_name="team_ingest",
       compute_kind="python")
def raw_orders(context: AssetExecutionContext, env: Env) -> None:
    hour = context.partition_key                   # e.g. "2026-07-21-14:00"
    with env.snowflake.get_connection() as conn:
        rows = read_from_source_pg(
            "SELECT * FROM orders WHERE hour = %s", (hour,))
        conn.cursor().execute(
            "INSERT INTO raw.orders SELECT * FROM PARSE_JSON(%s)",
            (rows_json(rows),))
    context.add_output_metadata({"row_count": len(rows)})
Enter fullscreen mode Exit fullscreen mode
# 4. K8s topology — webserver + daemon + one code-server per team
apiVersion: apps/v1
kind: Deployment
metadata:
  name: dagster-webserver
spec:
  replicas: 2
  template:
    spec:
      containers:
        - name: dagster-webserver
          image: analytics-platform/webserver:v1
          env:
            - name: DAGSTER_POSTGRES_HOST
              value: postgres.analytics.svc
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: dagster-daemon
spec:
  replicas: 1
  template:
    spec:
      containers:
        - name: dagster-daemon
          image: analytics-platform/daemon:v1
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: dagster-code-team-ingest
spec:
  replicas: 1
  template:
    spec:
      containers:
        - name: code-server
          image: analytics-platform/team_ingest:v1
          # Team-ingest deploys don't restart team-marketing / team-finance
Enter fullscreen mode Exit fullscreen mode
# 5. Dagster+ Standard cost estimate (2026 published rates, illustrative)
Ingestion:   15 pipelines × 24 partitions/day × 30 = 10,800 materialisations/mo
dbt:         40 models   ×  8 runs/day    × 30 = 9,600  materialisations/mo
Reverse-ETL:  3 syncs    × 24 partitions × 30 =  2,160  materialisations/mo
Total:                                          ~22,560 materialisations/mo
$0.03 / step × 22,560 = ~$680 / mo (Standard tier)
Add: Insights, alerting, RBAC included at Standard tier.
Enter fullscreen mode Exit fullscreen mode

Step-by-step trace.

Layer Component Purpose
Repo one monorepo + per-team code location team deploys isolate
Assets @asset + @dbt_assets factory ingestion + dbt in one graph
Partitions HourlyPartitionsDefinition backfill + retry per hour
K8s webserver + daemon + code servers per-team isolation
Managed Dagster+ Standard Insights + RBAC + alerting
Cost ~$680 / mo compute + AWS EKS under $12k / yr budget

After the rollout, all 40 dbt models + 15 ingestion pipelines + 3 reverse-ETL syncs live in one Dagster asset graph. Analytics engineers open the Asset Catalog UI to see freshness, cost, and lineage per asset. Data engineers ship team-scoped deploys without restarting other teams. On-call triage uses asset-level alerting; backfills happen at the partition level with a single-click rematerialise.

Output:

Metric Value
Assets in graph ~120 (dbt + ingestion + reverse-ETL)
Freshness on top assets ≤ 1 h
Team deploy isolation one code server per team
Backfill primitive partition-level rematerialise
Dagster+ monthly cost ~$680 (compute)
On-call rotation 2 engineers, asset-level alerting

Why this works — concept by concept:

  • Software-Defined Assets — Dagster's @asset declares "this data should exist and it depends on these upstream assets," which maps directly onto how AEs think about dbt models and onto how DEs think about raw + staged tables. The asset graph is the single source of truth for lineage; no separate OpenLineage plumbing needed.
  • dbt asset factory@dbt_assets(manifest=...) reads the dbt manifest.json and auto-generates one Dagster asset per dbt model. A single decorator materialises 40 dbt models as Dagster-native assets with lineage, partitioning, and materialisation events. Zero-friction dbt integration is the single biggest reason analytics teams pick Dagster.
  • HourlyPartitionsDefinition — declares that each asset has 24 partitions per day; backfills, retries, and rematerialisations happen at partition granularity. A failed hour is a one-partition rematerialise, not a full DAG-run replay. This is qualitatively different from Airflow's DAG-run-level retry model.
  • Per-team code locations — every team ships its own Docker image loaded into a separate code-server deployment. Team A's deploy doesn't restart team B's assets; team A's code errors don't fail team B's materialisations. This is Dagster's answer to Airflow's "one shared DAG folder" problem.
  • Cost — Dagster+ Standard at ~$0.03 / material step lands ~$680 / month at our volume — well under the $50k / yr budget. Add EKS costs (webserver / daemon / code-servers ≈ 3 small pods) and Postgres + S3, and total infrastructure is under $12k / year. The eliminated cost is the "we built our own lineage service" project that would have taken 2 engineers a full quarter. O(1) per material step; the platform scales with assets, not with DAG runs.

SQL
Topic — sql
SQL orchestration and pipeline scheduling problems

Practice →

Design Topic — design Design problems on data pipeline platforms

Practice →


2. Task-graph vs asset-graph mental models

The single biggest architectural fork in the orchestrator market — one noun changes retries, backfills, lineage, and observability

The mental model in one line: workflow orchestration in 2026 splits cleanly into two camps — the task-graph camp (Airflow, Prefect, Kestra, Mage), where you declare tasks and their execution dependencies, and the asset-graph camp (Dagster), where you declare data assets and let the orchestrator figure out which tasks to run to keep them fresh — and the axis on which they differ is not "which one is more powerful" (both express any pipeline) but which noun the platform reasons about, which cascades into radically different retries, backfills, lineage, and observability. Every senior DE interview eventually asks "task DAG or asset graph, and why?"; the answer is the load-bearing philosophical distinction in modern orchestration.

Iconographic mental-models diagram — a split-canvas contrasting a task-graph (linked task-node chevrons on the left) with an asset-graph (linked asset-cylinder nodes on the right), plus small tags marking Airflow / Prefect as task-graph and Dagster as asset-graph.

The task-graph mental model — what Airflow / Prefect / Kestra / Mage assume.

  • The noun. A task is a unit of compute — a Python function, a SQL query, a Bash command, a K8sPodOperator. Nodes in the graph are tasks; edges are execution dependencies ("task B runs after task A succeeds").
  • The primitive. A DAG run (Airflow), a flow run (Prefect), an execution (Kestra), a pipeline run (Mage). Retries operate at the task level; backfills operate at the DAG-run / flow-run level.
  • The lineage. Not native. To get column-level or table-level lineage you bolt on OpenLineage (Airflow), Prefect Observability, or a home-grown metadata layer. Task DAGs know which task depends on which task — they do not know which table a task wrote.
  • The observability. Task-run duration, task-run status, DAG-run status, scheduler latency. To see "when did dim_customer last refresh?" you either name every task carefully or add a metadata layer.

The asset-graph mental model — what Dagster ships.

  • The noun. An asset is a piece of data that should exist — a table, a file, a partition, a dbt model, a machine-learning model. Nodes in the graph are assets; edges are data dependencies ("fct_revenue depends on dim_customer and fct_orders").
  • The primitive. A materialisation — the event that produces an asset (or a partition of an asset). Retries operate at the asset-partition level; backfills operate at the asset-partition level. "Rematerialise the last 4 hours of fct_revenue" is a first-class action.
  • The lineage. Native. The asset graph is the lineage graph. Every asset knows its upstream and downstream assets; every materialisation records the run that produced it. No OpenLineage plumbing needed.
  • The observability. Asset freshness ("when did dim_customer last materialise?"), asset health ("did the last 24 partitions all succeed?"), asset cost (Dagster+ Insights: "how much did fct_revenue cost this month?"), asset partitions (grid view of every partition's status).

Why the fork exists — the pivot from 2019 to 2024.

  • The 2019 orchestrator. Airflow 1.x — the "compute scheduler with a DAG UI." Perfect for the ETL era where the question was "did the extract job run?"
  • The 2022 orchestrator. Prefect 2.x — the "flow-as-Python-function scheduler." Better ergonomics; still task-first. Kestra shipped for the polyglot case.
  • The 2024 pivot. Dagster's SDA — the "declare-what-data-should-exist scheduler." Renamed the problem from "run these tasks" to "keep these assets fresh." Everyone else responded with dataset APIs (Airflow 2.4 Datasets; Airflow 3.0 Assets), OpenLineage adapters, and lineage tabs — but the noun in the code stayed "task," so the pivot is skin-deep.
  • The 2026 status. Both models coexist in the market. Dagster owns the analytics-engineering-plus-dbt niche where the asset graph maps 1:1 onto the dbt DAG. Task-graph stacks own the general ETL / integration / event-driven niche where the data model is less crystalline. The fork is here to stay.

Common interview probes on task-graph vs asset-graph.

  • "What is a Software-Defined Asset?" — required answer: a declaration that "this data should exist and it depends on these upstream assets," with materialisation as the primitive event.
  • "When would you pick asset-graph over task-graph?" — dbt-heavy analytics platforms; teams where AEs are the primary authors; workloads where per-asset freshness / cost / lineage is a first-class product requirement.
  • "When would you pick task-graph over asset-graph?" — event-driven pipelines with no crystalline data model; integration workloads (Airflow's 2,000 providers); polyglot pipelines (Kestra's YAML); small AE teams (Mage's blocks).
  • "Doesn't Airflow have datasets / assets now?" — Airflow 2.4 introduced Datasets (data-aware scheduling — "run this DAG when this dataset is updated"); Airflow 3.0 rebranded to Assets. Useful for cross-DAG triggering; not a full asset-graph pivot — the primary noun in the code stays "task" and retries / backfills stay DAG-run-level.

Worked example — the same pipeline in Airflow task DAG vs Dagster asset graph

Detailed explanation. Read the same 4-node pipeline (raw → clean → dim → fct) in both mental models. The Airflow version is a task DAG with explicit >> operators; the Dagster version is an asset graph where the wiring is inferred from function arguments. Walk through every difference.

  • Pipeline. raw_ordersclean_ordersdim_customer (join with customers) → fct_revenue (aggregate by day).
  • Frequency. Daily.
  • Backfill. "Rerun the last 7 days for fct_revenue."
  • Retries. Any task failure retries 3× with exponential backoff.

Question. Implement the pipeline in Airflow task-flow and in Dagster SDA, then show how each handles the backfill and the retry.

Input.

Node Kind Airflow Dagster
raw_orders source ingest @task @asset (source)
clean_orders transform @task @asset
dim_customer join @task @asset
fct_revenue aggregate @task @asset (partitioned)

Code.

# Airflow task-flow API (2.10 / 3.0)
from airflow.decorators import dag, task
from datetime import datetime, timedelta

@dag(
    schedule="@daily",
    start_date=datetime(2026, 7, 1),
    catchup=False,
    default_args={
        "retries": 3,
        "retry_delay": timedelta(minutes=5),
        "retry_exponential_backoff": True,
    },
)
def revenue_pipeline():
    @task
    def raw_orders() -> str:
        return "s3://raw/orders/{{ ds }}.parquet"

    @task
    def clean_orders(raw_path: str) -> str:
        return clean_write(raw_path, out="s3://clean/orders/{{ ds }}.parquet")

    @task
    def dim_customer() -> str:
        return "snowflake://dim.customer"        # snapshot, not partitioned

    @task
    def fct_revenue(clean_path: str, dim_uri: str) -> str:
        return write_snowflake(
            aggregate_by_day(clean_path, dim_uri),
            "fct.revenue",
        )

    r = raw_orders()
    c = clean_orders(r)
    d = dim_customer()
    fct_revenue(c, d)

revenue_pipeline()
# Backfill:  airflow dags backfill revenue_pipeline -s 2026-07-14 -e 2026-07-20
# Retry:     configured per DAG default_args; task-level
Enter fullscreen mode Exit fullscreen mode
# Dagster SDA — the same pipeline as assets
from dagster import (
    asset, AssetIn, DailyPartitionsDefinition, RetryPolicy,
    Backoff, MetadataValue,
)

daily = DailyPartitionsDefinition(start_date="2026-07-01")
retry3 = RetryPolicy(max_retries=3, delay=300, backoff=Backoff.EXPONENTIAL)

@asset(partitions_def=daily, retry_policy=retry3, compute_kind="ingest")
def raw_orders(context) -> None:
    day = context.partition_key
    write_parquet(f"s3://raw/orders/{day}.parquet",
                  read_source_pg(day))

@asset(partitions_def=daily, retry_policy=retry3,
       ins={"raw_orders": AssetIn()}, compute_kind="python")
def clean_orders(context, raw_orders) -> None:
    day = context.partition_key
    clean_write(f"s3://raw/orders/{day}.parquet",
                out=f"s3://clean/orders/{day}.parquet")

@asset(retry_policy=retry3, compute_kind="snowflake")
def dim_customer(context) -> None:
    context.log.info("Rebuilding dim_customer snapshot")
    rebuild_snapshot("dim.customer")

@asset(partitions_def=daily, retry_policy=retry3,
       ins={"clean_orders": AssetIn(), "dim_customer": AssetIn()},
       compute_kind="snowflake")
def fct_revenue(context, clean_orders, dim_customer) -> None:
    day = context.partition_key
    n = write_snowflake(
        aggregate_by_day(f"s3://clean/orders/{day}.parquet", "dim.customer"),
        "fct.revenue",
        day=day,
    )
    context.add_output_metadata({"row_count": MetadataValue.int(n)})

# Backfill:  select fct_revenue in the UI → "Backfill" → pick 7 partitions
# Retry:     per-asset retry_policy; partition-level
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. In Airflow, the pipeline shape is expressed by calling tasks with their outputs as arguments (c = clean_orders(r)). The scheduler executes tasks in dependency order; every task lives inside one DAG run per schedule interval. Retries are configured in default_args; backfills are DAG-level (airflow dags backfill).
  2. In Dagster, the pipeline shape is expressed by declaring assets and their ins= map. There is no explicit DAG object — the orchestrator infers the graph from the asset declarations. Retries are configured per asset (RetryPolicy); backfills are per-asset per-partition ("select these 7 partitions of fct_revenue, rematerialise").
  3. Partitioning: Airflow uses the DAG's schedule + a template variable ({{ ds }}) to slice work by day; the partition is implicit in the DAG run. Dagster uses partitions_def=DailyPartitionsDefinition(...) on each asset; the partition is first-class (context.partition_key), and partitions are queryable in the UI as a grid.
  4. dim_customer is unpartitioned in Dagster (a snapshot rebuilt each run) even though fct_revenue is daily-partitioned. The asset graph handles this cleanly — fct_revenue's partitions materialise against whichever version of dim_customer exists at run time. In Airflow, mixing partitioned and unpartitioned upstreams requires manual dependency management or ExternalTaskSensor.
  5. Backfilling 7 days: in Airflow, airflow dags backfill revenue_pipeline -s 2026-07-14 -e 2026-07-20 triggers 7 DAG runs. If only fct_revenue needs backfilling but raw_orders and clean_orders are already fresh, Airflow re-runs them anyway (wasted compute). In Dagster, you select only the fct_revenue asset's 7 partitions in the UI and rematerialise; upstream is not re-computed unless stale. The compute savings on a mature analytics platform are ~40–60%.

Output.

Concern Airflow (task) Dagster (asset)
Retry primitive task-run asset-partition
Backfill primitive DAG-run range asset-partition selection
Lineage OpenLineage plugin native (asset graph)
Freshness UI task-duration timeline asset freshness card
Wasted backfill compute re-runs all upstream only rematerialises selected
Mixed partition granularity manual (ExternalTaskSensor) first-class (partitions_def)

Rule of thumb. For any pipeline whose primary product is data (tables, files, ML models) and where partition-level backfills are a real cost concern, the asset-graph model wins on compute waste, lineage, and observability. For pipelines whose primary product is side effects (send emails, trigger APIs, invoke ML training) with no persistent data output, the task-graph model is a better fit — the "asset" abstraction over side effects is awkward.

Worked example — Kestra YAML flow with polyglot tasks

Detailed explanation. Kestra's differentiator is polyglot-plus-YAML. The same ETL pipeline uses SQL for the extract, Python for the transform, and a Snowflake COPY for the load — all in one YAML flow. Walk through the flow and highlight what YAML-first buys you.

  • Extract. Postgres SELECT via io.kestra.plugin.jdbc.postgresql.Query.
  • Transform. Python script via io.kestra.plugin.scripts.python.Script.
  • Load. Snowflake COPY via io.kestra.plugin.snowflake.Query.
  • Trigger. Hourly cron.

Question. Write the Kestra YAML flow and highlight the multi-language wiring.

Input.

Task Language Kestra plugin type
extract SQL jdbc.postgresql.Query
transform Python scripts.python.Script
load SQL snowflake.Query
trigger cron core.trigger.Schedule

Code.

# Kestra flow — polyglot ETL in one YAML file
id: orders_hourly
namespace: prod.orders
description: |
  Pull incremental orders from source Postgres,
  transform in Python, COPY into Snowflake raw layer.

labels:
  env: prod
  team: analytics-platform

inputs:
  - id: max_batch_size
    type: INT
    defaults: 100000

triggers:
  - id: hourly
    type: io.kestra.plugin.core.trigger.Schedule
    cron: "0 * * * *"

tasks:
  - id: extract
    type: io.kestra.plugin.jdbc.postgresql.Query
    url: "jdbc:postgresql://source-db:5432/prod"
    username: "{{ secret('SOURCE_DB_USER') }}"
    password: "{{ secret('SOURCE_DB_PASSWORD') }}"
    sql: |
      SELECT id, customer_id, total_cents, status, updated_at
      FROM   public.orders
      WHERE  updated_at >= '{{ trigger.previous | date("yyyy-MM-dd HH:mm:ss") }}'
        AND  updated_at <  '{{ trigger.date     | date("yyyy-MM-dd HH:mm:ss") }}'
      LIMIT  {{ inputs.max_batch_size }}
    fetchType: STORE

  - id: transform
    type: io.kestra.plugin.scripts.python.Script
    inputFiles:
      raw.jsonl: "{{ outputs.extract.uri }}"
    beforeCommands:
      - pip install --quiet pandas pyarrow
    script: |
      import json, pandas as pd
      df = pd.read_json("raw.jsonl", lines=True)
      df["total_usd"] = df["total_cents"] / 100.0
      df["ingest_hour"] = "{{ trigger.date | date('yyyy-MM-dd-HH') }}"
      df.to_parquet("clean.parquet", index=False)
    outputFiles:
      - clean.parquet

  - id: load
    type: io.kestra.plugin.snowflake.Query
    url: "jdbc:snowflake://acct.snowflakecomputing.com/?warehouse=INGEST_WH"
    username: "{{ secret('SF_USER') }}"
    password: "{{ secret('SF_PASSWORD') }}"
    sql: |
      COPY INTO raw.orders
      FROM @stage/{{ outputs.transform.outputFiles['clean.parquet'] }}
      FILE_FORMAT = (TYPE = PARQUET);

errors:
  - id: alert_on_fail
    type: io.kestra.plugin.notifications.slack.SlackIncomingWebhook
    url: "{{ secret('SLACK_WEBHOOK') }}"
    payload: |
      { "text": ":rotating_light: Flow {{ flow.id }} failed at {{ execution.startDate }}" }
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. The whole flow is one YAML file — no Python DAG, no operator imports, no Airflow-style Python-file-per-DAG deploy pattern. Kestra reads the file, registers the flow in Postgres, and the executor picks it up. Editing the flow means editing the YAML in the UI or in Git; the change is picked up on the next scheduler tick.
  2. inputs: defines flow-level parameters that the caller (UI, API, or another flow) can override at trigger time. secret('SOURCE_DB_USER') pulls from Kestra's secret backend (Vault, AWS Secrets Manager, or Postgres-backed encrypted secrets) — the credentials never live in the YAML.
  3. The extract task is a SQL query; the transform task is a Python script; the load task is a Snowflake COPY. All three coexist in the same flow with typed IO via outputs.<task_id>.uri. Kestra automatically stages files between tasks — the Python task receives raw.jsonl because the extract task's fetchType: STORE writes to Kestra's file store, and inputFiles: on the transform task references it.
  4. The Python task includes beforeCommands: for pip install, which spins up a fresh Python environment per task run. No pre-baked Docker images required; the platform ships with a Python base image plus per-task package overlays. For heavier ML workloads, swap to a custom Docker image via the docker: block.
  5. errors: defines a task that runs when any task in the flow fails — a Slack webhook here. This is Kestra's answer to Airflow's on_failure_callback, but declaratively at the flow level. Cleaner than per-task callback wiring; you get one alert path for the whole flow.

Output.

Aspect Kestra YAML flow Airflow Python DAG
Authoring YAML in UI or Git Python file in DAG folder
Polyglot first-class (any task type) Python operators wrapping other langs
Deploy Git-sync or API push DAG folder sync
Secrets secret(...) inline Airflow Variables / Secrets Backend
File wiring native outputs.<id>.uri manual XCom + external stage
Error handler errors: block on_failure_callback per task

Rule of thumb. Kestra's YAML-first model is the correct pick when the authoring persona is polyglot — SQL analysts, Python engineers, Bash scripters, TypeScript devs all shipping tasks in one flow. The trade-off is that Python-heavy teams find YAML verbose compared to Airflow / Prefect / Dagster's Python DSL. Choose Kestra when polyglot is a requirement; skip it when Python is the lingua franca.

Worked example — Mage block DAG with data-app previews

Detailed explanation. Mage's differentiator is the block model plus the data-app UI. Every block (data loader, transformer, exporter) shows its sample output in the UI on every run — the pipeline development experience feels like a live notebook. Walk through the same orders pipeline in Mage blocks.

  • Data loader. Postgres query.
  • Transformer. Pandas transform.
  • Data exporter. Snowflake COPY.
  • Trigger. Hourly.

Question. Write the Mage blocks and show the data-app preview behaviour.

Input.

Block file Kind Decorator Sample output
load_orders.py data loader @data_loader pandas DataFrame
clean_orders.py transformer @transformer pandas DataFrame
load_snowflake.py data exporter @data_exporter rows written

Code.

# data_loaders/load_orders.py
import pandas as pd
from mage_ai.data_preparation.decorators import data_loader
from mage_ai.io.postgres import Postgres
from mage_ai.io.config import ConfigFileLoader

@data_loader
def load_incremental(*args, **kwargs) -> pd.DataFrame:
    """Pull the last hour of orders from the source Postgres."""
    config = ConfigFileLoader("io_config.yaml", "default")
    with Postgres.with_config(config) as loader:
        return loader.load(
            """
            SELECT id, customer_id, total_cents, status, updated_at
            FROM   public.orders
            WHERE  updated_at >= now() - INTERVAL '1 hour'
            """
        )
Enter fullscreen mode Exit fullscreen mode
# transformers/clean_orders.py
import pandas as pd
from mage_ai.data_preparation.decorators import transformer

@transformer
def clean(rows: pd.DataFrame, *args, **kwargs) -> pd.DataFrame:
    """Convert cents → USD, drop cancelled orders, add ingest column."""
    df = rows.copy()
    df["total_usd"]     = df["total_cents"] / 100.0
    df                  = df[df["status"] != "cancelled"]
    df["ingest_at"]     = pd.Timestamp.utcnow()
    return df.reset_index(drop=True)


@transformer
def test_no_nulls(output_df, *args, **kwargs) -> None:
    """Block-level test — fails the pipeline if nulls appear."""
    assert output_df["total_usd"].notna().all(), "total_usd has nulls"
    assert output_df["customer_id"].notna().all(), "customer_id has nulls"
Enter fullscreen mode Exit fullscreen mode
# data_exporters/load_snowflake.py
import pandas as pd
from mage_ai.data_preparation.decorators import data_exporter
from mage_ai.io.snowflake import Snowflake
from mage_ai.io.config import ConfigFileLoader

@data_exporter
def export(rows: pd.DataFrame, *args, **kwargs) -> None:
    """Append the cleaned batch into raw.orders in Snowflake."""
    config = ConfigFileLoader("io_config.yaml", "default")
    with Snowflake.with_config(config) as sf:
        sf.export(
            rows,
            table_name="orders",
            database="ANALYTICS",
            schema="RAW",
            if_exists="append",
        )
Enter fullscreen mode Exit fullscreen mode
# pipelines/orders_hourly/metadata.yaml
# Auto-generated by the Mage UI when blocks are wired
name: orders_hourly
type: python
blocks:
  - uuid: load_incremental
    type: data_loader
    upstream_blocks: []
    downstream_blocks: [clean]
  - uuid: clean
    type: transformer
    upstream_blocks: [load_incremental]
    downstream_blocks: [export]
  - uuid: export
    type: data_exporter
    upstream_blocks: [clean]
    downstream_blocks: []
schedules:
  - interval_type: hourly
    start_time: "2026-07-21 00:00:00"
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. Each Mage block lives in its own Python file with a typed decorator (@data_loader, @transformer, @data_exporter) and typed IO. The DAG is not expressed in code — it's stored in metadata.yaml and rendered in the UI as a block canvas. Analytics engineers wire blocks visually; the YAML is auto-generated.
  2. Every block, on every run, records its output DataFrame as a preview in the UI. Analytics engineers hit "Run block" during development and see the exact sample rows the transformer produced — no need to re-run the whole pipeline to debug. This is the "notebook-native" DX Mage optimises for.
  3. Block-level tests (test_no_nulls above) run as part of the pipeline; a failing test fails the block, which fails the pipeline. This mirrors dbt's test-per-model model but at the transformer-block granularity.
  4. Secrets and IO configuration live in io_config.yaml (typically Git-ignored, populated per environment via env vars). Mage's Postgres.with_config(config) and Snowflake.with_config(config) wrap the boilerplate; the block author sees only the SQL / DataFrame surface.
  5. The trade-off vs Airflow / Dagster / Prefect is that Mage's model is optimised for pipelines with fewer than ~50 blocks. Beyond that, the block-file-per-node model becomes hard to navigate — but for small analytics teams shipping a handful of pipelines, the UX advantage is significant.

Output.

Aspect Mage block DAG Airflow / Dagster
Block file per node yes no (one DAG / asset file)
Sample output preview first-class in UI via task-log inspection
DAG stored metadata.yaml + UI wiring Python DSL
Best team size ≤ 10 pipelines any scale
Analytics-engineer on-ramp shortest steeper

Rule of thumb. Mage is the correct pick for small teams (≤ 10 pipelines, ≤ 10 engineers) who value the notebook-native block UI over the Python-DSL rigidity of Airflow / Dagster / Prefect. Beyond that scale, the block-per-file model becomes hard to navigate and the platform choice pivots to Dagster (assets) or Airflow (task DAG).

Senior interview question on mental models

A senior interviewer might ask: "Your team is building a new analytics platform. Your CTO says 'use Airflow, everyone knows it.' Your lead AE says 'Dagster's asset graph maps onto dbt.' Walk me through the mental-model trade-off, defend a choice for a 40-dbt-model workload, and explain what changes when you have to backfill the last 30 days of one downstream fact."

Solution Using Dagster SDA for a dbt-heavy platform with per-partition backfills and asset-level RBAC

# 1. Asset factory that turns every dbt model into a Dagster asset
from dagster_dbt import dbt_assets, DagsterDbtTranslator
from dagster import AssetKey, AssetIn, MonthlyPartitionsDefinition
from pathlib import Path

DBT_PROJECT = Path(__file__).parent / "dbt_analytics"

class DailyDbtTranslator(DagsterDbtTranslator):
    def get_group_name(self, dbt_resource_props):
        # Group by dbt package: staging / marts / metrics
        return dbt_resource_props["fqn"][1]

@dbt_assets(
    manifest=DBT_PROJECT / "target" / "manifest.json",
    dagster_dbt_translator=DailyDbtTranslator(),
    select="tag:daily",
)
def analytics_dbt_assets(context, dbt):
    yield from dbt.cli(["build"], context=context).stream()
Enter fullscreen mode Exit fullscreen mode
# 2. Downstream partitioned fact — fct_revenue by day
from dagster import asset, DailyPartitionsDefinition, RetryPolicy, Backoff

daily = DailyPartitionsDefinition(start_date="2026-01-01")
retry = RetryPolicy(max_retries=3, delay=600, backoff=Backoff.EXPONENTIAL)

@asset(partitions_def=daily,
       retry_policy=retry,
       key_prefix=["marts", "finance"],
       group_name="marts_finance",
       ins={"stg_orders":    AssetIn(key=AssetKey(["staging", "stg_orders"])),
            "dim_customer":  AssetIn(key=AssetKey(["marts",  "dim_customer"]))},
       compute_kind="snowflake",
       description="Daily revenue rollup by customer segment")
def fct_revenue(context, stg_orders, dim_customer, sf) -> None:
    day = context.partition_key
    n = sf.execute_dml(
        f"""
        DELETE FROM marts.finance.fct_revenue WHERE day = '{day}';
        INSERT INTO marts.finance.fct_revenue
        SELECT day, segment, SUM(total_usd)
        FROM   staging.stg_orders     s
        JOIN   marts.dim_customer     c ON c.id = s.customer_id
        WHERE  s.day = '{day}'
        GROUP  BY day, segment;
        """
    )
    context.add_output_metadata({"row_count": n, "partition": day})
Enter fullscreen mode Exit fullscreen mode
# 3. RBAC — teams' role grants map onto asset key prefixes
# dagster+ Insights + Alerting configured for the "marts_finance" group:
#   - freshness alert: fct_revenue must materialise every 2 h
#   - failure alert:   any asset in group marts_finance
#   - cost alert:      group cost > $500 / day

# 4. Backfill CLI: rematerialise 30 partitions of fct_revenue
# $ dagster asset backfill \
#     --select 'marts/finance/fct_revenue' \
#     --partition-range '2026-06-21..2026-07-20'
# → Dagster spawns 30 partition runs, each running only the SQL for its day.
# → Upstream stg_orders / dim_customer are NOT re-materialised
#   unless the operator opts in with --include-upstream.
Enter fullscreen mode Exit fullscreen mode

Step-by-step trace.

Layer Design Result
dbt integration @dbt_assets(manifest=...) factory 40 dbt models → 40 Dagster assets
Downstream fact partitioned @asset day-level materialisation
RBAC key_prefix + group_name + Dagster+ per-team role grants
Backfill 30 days dagster asset backfill --partition-range 30 partition runs, upstream skipped
Failure retry RetryPolicy(max_retries=3, exponential) partition-level, not DAG-level
Alerting Dagster+ freshness + failure + cost per-asset, per-group

After the rollout, the AE team sees the dbt DAG + downstream facts as one asset graph in the Dagster UI. The 30-day backfill for fct_revenue costs 30 SQL statements against Snowflake — not 30 × (extract + clean + join + fact), which is what a naive DAG-run backfill would trigger. On-call sees per-asset freshness cards; audit sees per-asset lineage tracing back to the source table.

Output:

Metric Value
dbt models materialised as assets 40
Downstream partitioned facts 8
Backfill cost (30 days, 1 fact) 30 SQL statements
Backfill cost (task-DAG equivalent) 30 × 4-node DAG runs
Compute savings vs task-DAG ~65% (upstream skipped)
RBAC granularity per-asset key prefix

Why this works — concept by concept:

  • Software-Defined Assets@asset declares "this data should exist and it depends on these assets." The graph is inferred from the ins= map; the primitive is materialisation, not task execution. Every asset is queryable, freshness-checkable, and cost-attributable independently.
  • dbt asset factory@dbt_assets(manifest=...) reads the dbt manifest and generates one Dagster asset per dbt model with dependencies wired automatically. The AE team writes dbt models; Dagster materialises them with asset-level lineage. Zero-friction dbt integration is the single strongest reason to pick Dagster for analytics engineering.
  • Partition-level backfill — the 30-day backfill selects fct_revenue and its partition range; Dagster spawns 30 partition runs, each running only the downstream SQL for its partition. Upstream stg_orders / dim_customer are not re-materialised unless the operator explicitly asks. Compute savings on mature platforms are 50–70% versus a naive DAG-run backfill.
  • Key prefix + group_name RBACkey_prefix=["marts", "finance"] and group_name="marts_finance" turn each asset into an RBAC-tagged entity. Dagster+ role grants target key prefixes, so team finance sees / edits only marts/finance/* assets; team marketing sees / edits only marts/marketing/*.
  • Cost — 40 dbt materialisations + 8 daily fact partitions + on-demand backfills = ~15k materialisations / mo on Dagster+ Standard = ~$450 / mo. The eliminated cost is the "wasted upstream re-run" that a naive task-DAG backfill would trigger — at Snowflake $2 / credit rates, that's often $500+ per multi-day backfill. Net O(partitions to rebuild) versus O(partitions × upstream depth) per backfill.

SQL
Topic — sql
SQL asset and dbt-model interview problems

Practice →

Design Topic — design Design problems on analytics-engineering platforms

Practice →


3. Execution architecture comparison

Every stack ships a scheduler + work queue + workers — the boxes each calls its own name

The mental model in one line: the execution architecture of any 2026 orchestrator is a variation on the same three-layer topology — a scheduler that decides "when to run what," a work queue that carries the runnables to executors, and workers that actually run the code against a metadata plane (usually Postgres) — but each stack packages these boxes differently, ships different defaults for local vs distributed deployment, and has different failure semantics on scheduler crash, queue backpressure, and worker restart. Understanding the topology per stack is what separates the platform engineer who ships an orchestrator to production from the one who follows the "getting started" guide and hopes.

Iconographic execution architecture diagram — five stacked topology strips (Airflow, Dagster, Prefect, Kestra, Mage) each with its named components as small icon cards connected by arrows.

The five topologies, side by side.

  • Airflow. scheduler + webserver + executor (Celery / K8s / Local) + workers + Postgres metadata. The scheduler polls the DAG folder every N seconds, parses Python DAG files, and pushes runnable tasks onto the executor's queue. Celery workers pull from Redis / RabbitMQ; K8sExecutor spawns a K8s pod per task. The scheduler is a single-writer that can crash and restart without losing state (Postgres holds the source of truth). The 15-year-old topology; well-understood, well-scaled, well-monitored.
  • Dagster. webserver + daemon + code servers + user-code Docker + Postgres metadata + run coordinator (K8s / Docker / Local). The daemon is the scheduler equivalent; it enqueues asset materialisation runs. Code servers host user-code repositories in isolated processes (per-team, per-Python-env). The run coordinator launches each run in its own container. Team A's pip install doesn't affect team B; team A's deploy doesn't restart team B's assets.
  • Prefect. Prefect server (API + UI) + work pools + workers + flow runs. Work pools are the queue abstraction — a work pool defines where flows run (K8s, ECS, Docker, process). Workers subscribe to work pools (pull) or the server pushes to serverless work pools. Flow runs are queued to work pools; workers execute. The clean separation of flow definition from infrastructure is Prefect's biggest architectural bet.
  • Kestra. Kafka + Postgres + executor + worker + webserver. The executor consumes Kafka topics to schedule tasks; workers consume Kafka to run them. Kafka is the queue (not Redis / RabbitMQ / K8s Job); Postgres is the metadata store. Kafka-backed makes Kestra unusually horizontally scalable — add worker pods, increase Kafka partitions, done. The trade-off is that a Kestra deployment always requires a Kafka cluster (or the bundled dev-mode Kafka).
  • Mage. backend (Flask + React) + scheduler + workers + data-app UI + Postgres metadata. The single-container dev mode runs all four in one process; prod mode splits scheduler and workers into K8s deployments. The data-app UI serves block previews and pipeline canvases. Mage's topology is the simplest of the five — deliberately, for the small-team use case.

The scheduler axis — how each stack decides what to run.

  • Airflow. DAG-file scan every 30 s (configurable); ready DAG runs enqueued to executor. Scheduler is single-writer; can run HA with a leader election. Airflow 3.0's rebuilt scheduler is 3–5× faster than 2.x.
  • Dagster. Daemon runs schedules + sensors + auto-materialise policies + backfills. Sensors are Python functions that emit run requests based on external state (S3 arrival, Kafka lag, external DB row). Auto-materialise policies (AMP) declaratively re-materialise assets when upstream is stale.
  • Prefect. Server schedules based on flow deployments. Deployments carry the schedule + parameters + work-pool target. Server pushes run requests to work pools; workers pick them up.
  • Kestra. Triggers (schedule, flow, webhook, Kafka) fire and produce executions. Executor consumes execution requests from Kafka; the whole scheduling loop is Kafka-based, so replaying / debugging schedules is a Kafka consumer group operation.
  • Mage. Scheduler polls the metadata.yaml triggers; on trigger fire, a pipeline run is enqueued for the workers. Simpler than the four above; scales to hundreds of pipelines but not to thousands.

The queue axis — how work reaches workers.

  • Airflow. Celery (Redis / RabbitMQ) or K8sExecutor (one pod per task). CeleryKubernetesExecutor is the hybrid; use Celery for small tasks, K8s pods for heavy ones.
  • Dagster. K8s / Docker / Local run launcher; per-run isolation. No Celery-style queue — the daemon enqueues in Postgres and the run launcher spawns runs directly.
  • Prefect. Work pools; pull (workers poll) or push (Prefect serverless work pool for AWS ECS / GCP Cloud Run). Workers subscribe to work pools by name / tag.
  • Kestra. Kafka topics per execution kind. Kafka's partition-consumer model gives natural horizontal scale.
  • Mage. Postgres queue + worker processes. Simplest; adequate for ≤ 500 pipeline runs / hour.

The worker axis — where user code actually runs.

  • Airflow. Celery worker processes, K8sPodOperator pods, or LocalExecutor threads. Airflow's operator library (2,000+) means user code often runs in the operator's process; K8sPodOperator escapes this into a pod.
  • Dagster. Per-run Docker containers (K8s or Docker run launcher) with team-specific user-code images. Code servers host repository code; run launcher spawns run containers.
  • Prefect. Worker processes / K8s pods / ECS tasks matching the work-pool type. Each work-pool type defines where the flow runs.
  • Kestra. Worker pods (Docker or bare) consuming Kafka. Scripts run in per-task subprocesses; heavy workloads use custom Docker images.
  • Mage. Worker processes on the same host as the backend (dev) or as K8s deployments (prod).

Common interview probes on execution architecture.

  • "What runs when the scheduler crashes?" — Airflow: DAG parsing pauses; queued runs continue on executor / workers. Dagster: daemon down means no new runs; existing runs continue. Prefect: server down means the API is offline but running flows continue via the local worker. Kestra: executor down means Kafka lag grows; workers keep processing existing execs. Mage: scheduler pause; workers finish current runs.
  • "How does each stack isolate teams?" — Airflow: shared DAG folder (weak); K8sExecutor with per-pod resource limits. Dagster: per-team code servers + per-team Docker (strong). Prefect: per-flow work pools (strong). Kestra: namespaces (medium). Mage: shared workspace (weak).
  • "What's the minimum viable prod deployment?" — Airflow: scheduler + webserver + Postgres + one CeleryExecutor worker (~4 pods). Dagster: webserver + daemon + one code server + Postgres (~3 pods). Prefect: server + one worker + Postgres (~2 pods). Kestra: executor + worker + Kafka + Postgres (~4 pods + Kafka cluster). Mage: single-container prod mode (1 pod for < 10 pipelines).
  • "How do you scale each stack horizontally?" — Airflow: add Celery workers or K8sExecutor pod capacity. Dagster: add code servers and increase run coordinator concurrency. Prefect: add workers per work pool. Kestra: add worker pods + Kafka partitions. Mage: add worker replicas + shard by pipeline.

Worked example — Airflow prod topology on EKS with CeleryKubernetesExecutor

Detailed explanation. The canonical Airflow prod deployment on Kubernetes: separate deployments for scheduler, webserver, and Celery workers, with K8sPodOperator escape hatch for heavy tasks, Postgres for metadata, and Redis for the Celery broker. Walk through the topology and Helm-style manifests.

  • Scheduler. 2 replicas for HA (Airflow 2.4+ supports HA scheduler natively).
  • Webserver. 2 replicas behind an ingress.
  • Celery workers. 6 replicas for light tasks.
  • K8sExecutor. For any operator tagged queue=k8s — one pod per task.
  • Redis. Celery broker.
  • Postgres. Metadata store (RDS or on-cluster).

Question. Provide the prod topology sketch and the Helm-style deployment for a mid-scale Airflow on EKS.

Input.

Component Replicas Resource
scheduler 2 (HA) 2 vCPU / 4 GB each
webserver 2 1 vCPU / 2 GB each
Celery worker 6 2 vCPU / 4 GB each
K8sExecutor pods on-demand per-task pod resource
Redis 1 (or Elasticache) 1 vCPU / 2 GB
Postgres RDS db.t4g.medium 2 vCPU / 4 GB

Code.

# Helm values (chart: apache-airflow/airflow, v1.15+)
executor: "CeleryKubernetesExecutor"

postgresql:
  enabled: false        # use external RDS
data:
  metadataConnection:
    host: airflow-metadata.rds.internal
    dbname: airflow

redis:
  enabled: true         # in-cluster; use Elasticache for prod hardening

scheduler:
  replicas: 2
  resources:
    requests: { cpu: "1", memory: "2Gi" }
    limits:   { cpu: "2", memory: "4Gi" }
  logGroomerSidecar:
    enabled: true

webserver:
  replicas: 2
  service:
    type: ClusterIP     # front with ingress + oidc

workers:
  replicas: 6           # Celery workers for light tasks
  keda:
    enabled: true       # autoscale on queue depth
    minReplicaCount: 2
    maxReplicaCount: 24

triggerer:
  replicas: 2           # required for deferrable operators (async sensors)

extraEnv: |
  - name: AIRFLOW__CORE__PARALLELISM
    value: "128"
  - name: AIRFLOW__CELERY__WORKER_CONCURRENCY
    value: "8"
  - name: AIRFLOW__SCHEDULER__PARSING_PROCESSES
    value: "4"

dags:
  gitSync:
    enabled: true
    repo: git@github.com:acme/airflow-dags.git
    branch: main
    subPath: dags
    depth: 1
Enter fullscreen mode Exit fullscreen mode
# Example DAG using CeleryKubernetesExecutor — routing by queue
from airflow.decorators import dag, task
from airflow.operators.python import PythonOperator
from airflow.providers.cncf.kubernetes.operators.pod import KubernetesPodOperator
from datetime import datetime

@dag(schedule="@hourly", start_date=datetime(2026, 7, 1), catchup=False)
def hybrid_pipeline():

    @task(queue="default")            # runs on a Celery worker
    def light_extract() -> str:
        return read_and_stage()       # small; ~30s

    heavy_transform = KubernetesPodOperator(
        task_id="heavy_transform",
        image="acme/heavy-transform:v3",
        cmds=["python", "/app/run.py"],
        namespace="airflow-workers",
        resources={"requests": {"cpu": "4", "memory": "16Gi"},
                   "limits":   {"cpu": "8", "memory": "32Gi"}},
        queue="kubernetes",           # routed to K8sExecutor
    )

    light_extract() >> heavy_transform

hybrid_pipeline()
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. CeleryKubernetesExecutor gives you the best of both — small tasks on cheap Celery workers, heavy tasks on ephemeral K8s pods. The queue= argument on each task selects the routing. This is the canonical mid-scale prod topology.
  2. The scheduler runs HA with 2 replicas; Airflow 2.4+ supports scheduler HA natively via row-level locking in Postgres. If one scheduler dies, the other keeps enqueuing. The webserver also runs 2 replicas behind an ingress with OIDC / SSO in front.
  3. Redis is the Celery broker; workers pull from Redis. KEDA scales worker replicas based on Celery queue depth — a spike from 6 to 24 workers absorbs a large backfill without paging on-call. Elasticache is the prod-hardened Redis; in-cluster Redis is fine for staging.
  4. The triggerer (2 replicas) runs deferrable operators — async sensors that give up their worker slot while waiting. Without the triggerer, a 6-hour sensor holds a worker slot for 6 hours; with it, the sensor defers to the triggerer's asyncio loop and the worker slot is freed.
  5. gitSync deploys DAGs by cloning the Git repo to a shared volume; the scheduler and workers all see the same DAG files. Alternative: bake DAGs into the Airflow Docker image and redeploy on every DAG change. GitSync is faster to iterate; baked images are stronger on determinism.

Output.

Layer Component count Resource total
Scheduler 2 pods 4 vCPU / 8 GB
Webserver 2 pods 2 vCPU / 4 GB
Celery workers 6–24 pods (KEDA) 12–48 vCPU / 24–96 GB
Triggerer 2 pods 2 vCPU / 4 GB
K8sExecutor on-demand per-task
Redis 1 pod 1 vCPU / 2 GB
RDS Postgres db.t4g.medium 2 vCPU / 4 GB

Rule of thumb. For any Airflow prod on EKS deployment, run CeleryKubernetesExecutor + HA scheduler + triggerer + KEDA autoscaling. Use Celery for the small-task backbone and K8sPodOperator for anything that needs > 4 vCPU or > 8 GB. This topology scales from 100 to 10,000 DAG runs / day without architectural changes.

Worked example — Dagster prod topology with per-team code locations

Detailed explanation. Dagster's differentiator is per-team code isolation. Each team's Python code lives in its own Docker image, loaded into a separate code server deployment. Team A's pip install pandas==2.1 doesn't clash with team B's pandas==1.5. Walk through the K8s topology.

  • Webserver. 2 replicas serving the Dagster UI + GraphQL.
  • Daemon. 1 replica (single writer for schedules + sensors + backfills + AMP).
  • Code servers. N replicas — one deployment per team, each with the team's user-code Docker.
  • Run launcher. K8s run launcher; spawns one K8s Job per run.
  • Postgres. Metadata store (RDS).

Question. Provide the Dagster on EKS Helm values + per-team code-server manifests.

Input.

Component Replicas Resource
dagster-webserver 2 1 vCPU / 2 GB each
dagster-daemon 1 1 vCPU / 2 GB
code-server (team A) 1 0.5 vCPU / 1 GB
code-server (team B) 1 0.5 vCPU / 1 GB
K8s run Jobs on-demand per-asset
RDS Postgres db.t4g.medium 2 vCPU / 4 GB

Code.

# Helm values (chart: dagster/dagster)
dagsterWebserver:
  replicaCount: 2
  service:
    type: ClusterIP

dagsterDaemon:
  enabled: true
  runCoordinator:
    enabled: true
    type: QueuedRunCoordinator
    config:
      maxConcurrentRuns: 25

runLauncher:
  type: K8sRunLauncher
  config:
    k8sRunLauncher:
      jobNamespace: dagster-runs
      loadInclusterConfig: true

postgresql:
  enabled: false
  postgresqlHost: dagster-metadata.rds.internal
  postgresqlDatabase: dagster
Enter fullscreen mode Exit fullscreen mode
# Per-team code-server deployment (team_ingest)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: dagster-code-team-ingest
spec:
  replicas: 1
  selector:
    matchLabels: {app: dagster-code, team: ingest}
  template:
    metadata:
      labels: {app: dagster-code, team: ingest}
    spec:
      containers:
        - name: code-server
          image: acme/dagster-team-ingest:v37
          command: [dagster, api, grpc, -h, 0.0.0.0, -p, "4000",
                    -m, team_ingest]
          ports: [{containerPort: 4000}]
          env:
            - name: DAGSTER_HOME
              value: /opt/dagster/home
          resources:
            requests: {cpu: "500m", memory: "1Gi"}
            limits:   {cpu: "1",    memory: "2Gi"}
---
apiVersion: v1
kind: Service
metadata:
  name: dagster-code-team-ingest
spec:
  selector: {app: dagster-code, team: ingest}
  ports: [{port: 4000, targetPort: 4000}]
Enter fullscreen mode Exit fullscreen mode
# workspace.yaml — the webserver's map of code locations
load_from:
  - grpc_server:
      host: dagster-code-team-ingest.dagster.svc
      port: 4000
      location_name: team_ingest
  - grpc_server:
      host: dagster-code-team-marketing.dagster.svc
      port: 4000
      location_name: team_marketing
  - grpc_server:
      host: dagster-code-team-finance.dagster.svc
      port: 4000
      location_name: team_finance
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. The Dagster webserver + daemon are shared infrastructure — one deployment each. They serve the UI, schedule runs, and enqueue against the run coordinator. QueuedRunCoordinator limits concurrent runs to 25 across all teams; adjust based on capacity.
  2. Each team ships a dagster-code-team-<name> deployment with the team's Python code baked into a Docker image. The code server exposes a gRPC endpoint on port 4000; the webserver connects to the endpoint listed in workspace.yaml to load the team's assets, jobs, schedules, and sensors.
  3. When a team deploys new code, only that team's code-server deployment restarts. The webserver picks up the new gRPC endpoint on the next code-location reload. Other teams' assets are unaffected — no cross-team blast radius on deploy.
  4. K8sRunLauncher spawns a K8s Job per Dagster run. The Job uses the same Docker image as the team's code server (the "user code" image). This means every run is isolated in its own pod with its own Python environment, its own pip installs, and its own resource limits.
  5. postgresql.postgresqlHost: dagster-metadata.rds.internal uses external RDS for the metadata plane. Prod Dagster always uses external Postgres; the in-cluster Bitnami chart is fine for dev but not for prod-hardened deployments.

Output.

Layer Component count Resource total
Webserver 2 pods 2 vCPU / 4 GB
Daemon 1 pod 1 vCPU / 2 GB
Code servers (3 teams) 3 pods 1.5 vCPU / 3 GB
Run Jobs on-demand per-asset
RDS Postgres db.t4g.medium 2 vCPU / 4 GB

Rule of thumb. For any multi-team Dagster deployment, run one code-server deployment per team with the team's user-code Docker. This is the operational payoff of Dagster's architecture — team A's pip install doesn't touch team B, and team A's deploys don't restart team B's runs. Skip this pattern only at very small scale (≤ 2 teams).

Worked example — Prefect work pool + hybrid worker deployment

Detailed explanation. Prefect's work-pool model separates flow definition (what to run) from infrastructure (where to run). One flow can deploy against a K8s work pool in prod, a process work pool locally, and an ECS work pool for a scheduled batch. Walk through defining two work pools and a flow that ships to both.

  • Flow. ETL that runs hourly on K8s + a nightly batch on ECS.
  • Work pool 1. k8s-hourly (Kubernetes work pool, autoscaling).
  • Work pool 2. ecs-nightly (AWS ECS Fargate work pool).
  • Worker. One worker per work pool.

Question. Configure the two work pools and deploy the same flow to both.

Input.

Deployment Work pool Schedule Worker
orders-hourly k8s-hourly @hourly K8s worker
orders-nightly-recon ecs-nightly 0 3 * * * ECS worker

Code.

# flows/orders.py
from prefect import flow, task
from prefect.deployments import Deployment
from prefect.infrastructure.kubernetes import KubernetesJob
from prefect_aws.ecs import ECSTask

@task(retries=3, retry_delay_seconds=[60, 300, 900])
def extract(hour: str) -> list[dict]:
    return read_source_pg_for_hour(hour)

@task
def clean(rows: list[dict]) -> list[dict]:
    return [normalise(r) for r in rows]

@task
def load(rows: list[dict], table: str) -> None:
    write_snowflake(rows, table)

@flow(name="orders-hourly")
def orders_hourly(hour: str):
    load(clean(extract(hour)), "raw.orders")

@flow(name="orders-nightly-recon")
def orders_nightly_recon(date: str):
    """Nightly full-table reconciliation."""
    rows = read_source_pg_full_day(date)
    load(clean(rows), "recon.orders_full_day")
Enter fullscreen mode Exit fullscreen mode
# deploy.py — one command deploys both flows to their respective work pools
from prefect import serve
from prefect.client.schemas.schedules import CronSchedule

if __name__ == "__main__":
    hourly_dep = orders_hourly.to_deployment(
        name="orders-hourly-k8s",
        work_pool_name="k8s-hourly",
        cron="0 * * * *",
        parameters={"hour": "{{ scheduled_time | date('%Y-%m-%dT%H:00:00') }}"},
    )
    nightly_dep = orders_nightly_recon.to_deployment(
        name="orders-nightly-recon-ecs",
        work_pool_name="ecs-nightly",
        cron="0 3 * * *",
        parameters={"date": "{{ scheduled_time | date('%Y-%m-%d') }}"},
    )
    serve(hourly_dep, nightly_dep)
Enter fullscreen mode Exit fullscreen mode
# Create the two work pools once per environment
prefect work-pool create --type kubernetes k8s-hourly \
  --base-job-template k8s-hourly-template.json

prefect work-pool create --type ecs ecs-nightly \
  --base-job-template ecs-nightly-template.json

# Start workers (one per pool; can run many)
prefect worker start --pool k8s-hourly
prefect worker start --pool ecs-nightly
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. The flow definitions in flows/orders.py are pure Python — no infrastructure code. Prefect flows are just decorated functions; retries are configured on the tasks; parameters flow through function arguments.
  2. deploy.py binds each flow to a work pool + a schedule + default parameters. The work pool name determines where the flow runs; the same flow could deploy to three different pools without changing its code.
  3. prefect work-pool create --type kubernetes k8s-hourly creates a Kubernetes work pool with a base-job-template that describes how to spawn a K8s Job per flow run. The template covers image, resource requests, service account, namespace, and node selector.
  4. prefect worker start --pool k8s-hourly starts a worker process that subscribes to the k8s-hourly pool. On each scheduled trigger, the Prefect server publishes a run request to the pool; the worker sees it, invokes the K8s API to create the Job, monitors the Job, streams logs back.
  5. This architecture cleanly separates the what (flow code) from the where (work pool). Local development uses a process work pool; staging uses a Docker work pool; prod uses K8s or ECS. The same flow code ships everywhere with a single-line deployment change — this is Prefect's biggest operational advantage.

Output.

Aspect Prefect Airflow Dagster
Flow ↔ infra binding work pool executor config run launcher
Local vs prod same flow, different pool different DAG config different launcher
Hybrid infra per flow first-class manual per-task queue run launcher per repo
Multi-cloud worker native (workers per cloud) complex complex

Rule of thumb. For any team shipping many small pipelines with heterogeneous infrastructure (some K8s, some ECS, some serverless), Prefect's work-pool model is the strongest architectural fit. The clean separation of flow from infrastructure is what Airflow's queue= argument tries to do; Prefect makes it first-class.

Senior interview question on execution architecture

A senior interviewer might ask: "You're inheriting an Airflow deployment on EKS that runs 1,500 DAGs / day with a shared DAG folder for 4 teams. Teams complain about deploys restarting each other's schedulers, dependency conflicts on shared workers, and Celery queue backpressure during peak. Walk me through the diagnosis, a Dagster migration proposal with per-team code isolation, and the 6-month migration plan."

Solution Using Dagster per-team code servers with K8sRunLauncher and QueuedRunCoordinator

# 1. Dagster prod topology on EKS — one deployment, shared control plane
# Namespace: dagster
apiVersion: apps/v1
kind: Deployment
metadata:
  name: dagster-webserver
  namespace: dagster
spec:
  replicas: 2
  template:
    spec:
      containers:
        - name: webserver
          image: dagster/dagster-webserver:1.9
          env:
            - name: DAGSTER_POSTGRES_HOST
              value: dagster-metadata.rds.internal
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: dagster-daemon
  namespace: dagster
spec:
  replicas: 1
  template:
    spec:
      containers:
        - name: daemon
          image: dagster/dagster-daemon:1.9
          args: [dagster-daemon, run]
Enter fullscreen mode Exit fullscreen mode
# 2. Per-team code-server deployments
# Team ingest
apiVersion: apps/v1
kind: Deployment
metadata:
  name: dagster-code-team-ingest
  namespace: dagster
spec:
  replicas: 1
  template:
    spec:
      containers:
        - name: code-server
          image: acme/dagster-team-ingest:v42   # team's Python + Dagster
          command: [dagster, api, grpc, -h, 0.0.0.0, -p, "4000",
                    -m, team_ingest]
---
# Team analytics
apiVersion: apps/v1
kind: Deployment
metadata:
  name: dagster-code-team-analytics
  namespace: dagster
spec:
  replicas: 1
  template:
    spec:
      containers:
        - name: code-server
          image: acme/dagster-team-analytics:v37
          command: [dagster, api, grpc, -h, 0.0.0.0, -p, "4000",
                    -m, team_analytics]
Enter fullscreen mode Exit fullscreen mode
# 3. Workspace binding
# workspace.yaml (loaded by webserver + daemon)
load_from:
  - grpc_server:
      host: dagster-code-team-ingest.dagster.svc
      port: 4000
      location_name: team_ingest
  - grpc_server:
      host: dagster-code-team-analytics.dagster.svc
      port: 4000
      location_name: team_analytics
  - grpc_server:
      host: dagster-code-team-finance.dagster.svc
      port: 4000
      location_name: team_finance
  - grpc_server:
      host: dagster-code-team-marketing.dagster.svc
      port: 4000
      location_name: team_marketing
Enter fullscreen mode Exit fullscreen mode
# 4. Run launcher + coordinator — K8s Jobs, capped concurrency
# dagster.yaml
run_coordinator:
  module: dagster.core.run_coordinator
  class:  QueuedRunCoordinator
  config:
    max_concurrent_runs: 40
    tag_concurrency_limits:
      - key: team
        limit: 15      # cap per-team concurrency
run_launcher:
  module: dagster_k8s
  class:  K8sRunLauncher
  config:
    service_account_name: dagster-run-sa
    job_namespace: dagster-runs
    env_config_maps: [dagster-env]
Enter fullscreen mode Exit fullscreen mode
# 5. Migration plan (6 months, illustrative sprint plan)
# Sprint 1  — stand up Dagster webserver + daemon on EKS
# Sprint 2  — team ingest ports 5 pilot DAGs to Dagster assets
# Sprint 3  — team ingest ports remaining 15 DAGs; retire Airflow copies
# Sprint 4  — team analytics ports dbt models via @dbt_assets factory
# Sprint 5  — team finance + marketing port their DAGs; consolidate on Dagster
# Sprint 6  — Airflow retirement; document runbooks; on-call handover
Enter fullscreen mode Exit fullscreen mode

Step-by-step trace.

Sprint Milestone Airflow status Dagster status
1 Dagster infrastructure up 100% live shadow, no user code
2 5 ingest DAGs ported 95% live 5 assets running
3 20 ingest DAGs ported 80% live 20 assets running
4 40 dbt models ported 65% live 60 assets running
5 Finance + marketing ported 20% live 90% of workload
6 Airflow retired 0% 100%

After the migration, all 1,500 daily runs live in Dagster's asset graph. Each team ships to its own code-server deployment; team analytics's pip install never touches team ingest. The Celery queue-backpressure problem disappears because K8sRunLauncher spawns one Job per run; capacity is a K8s scaling problem, not a queue-depth problem. Backfills are per-asset per-partition; wasted upstream compute drops by ~60%.

Output:

Metric Airflow (before) Dagster (after)
Team isolation shared DAG folder per-team code servers
Deploy blast radius all teams one team
Dependency conflict rate ~1 / week ~0 (per-team Docker)
Backfill compute waste ~40–60% ~0 (per-partition)
Scheduler HA 2 replicas Airflow HA 1 daemon + Postgres locking
Run isolation shared Celery worker per-run K8s Job
Migration duration 6 months, 4 teams

Why this works — concept by concept:

  • Per-team code servers — each team ships its own Docker image with its own Python environment; the code server exposes a gRPC endpoint the webserver connects to. Team A's pip install doesn't touch team B; team A's deploy doesn't restart team B's runs. This is the operational payoff of Dagster's architecture and the single biggest reason a mid-sized team migrates off Airflow's shared DAG folder.
  • K8sRunLauncher — spawns one K8s Job per run using the team's user-code Docker image. Each run has its own resource limits, its own env vars, its own service account. Failure of one run does not touch other runs; horizontal scale is a K8s scaling problem, not a Celery queue-depth problem.
  • QueuedRunCoordinator + tag_concurrency_limits — caps concurrent runs globally (40) and per-team (15). Prevents one team's backfill from starving another team's scheduled runs. tag_concurrency_limits is the multi-tenant scheduler primitive Airflow's pool mechanism approximates.
  • 6-month migration plan — sprint-by-sprint, team-by-team, port DAGs to assets. The old and new stacks run in parallel; teams pilot 5 DAGs first, then port the rest, then retire. Skipping the parallel-run phase is a common failure mode; running both platforms for 4–8 weeks per team is the honest migration cost.
  • Cost — Dagster+ Standard at ~$0.03 / material step × 45k materialisations / mo = ~$1.4k / month; plus EKS compute for webserver / daemon / 4 code servers / on-demand run Jobs = ~$800 / month; plus RDS Postgres = ~$100 / month. Total ~$2.3k / month against a $50k / yr budget. The eliminated cost is the ~40% of one platform engineer's time that Airflow's dependency conflicts + shared-folder deploys were consuming. Net O(assets) per material step versus O(DAGs) per DAG-run with hidden multi-team coupling.

Design
Topic — design
Design problems on orchestrator deployment topologies

Practice →

Streaming Topic — streaming Streaming and Kafka-backed orchestrator problems

Practice →


4. Feature matrix

Seven axes, five stacks — the row-by-row comparison every senior interviewer expects on a whiteboard

The mental model in one line: the 2026 orchestrator feature matrix is a 5-column × 7-row grid — Airflow / Dagster / Prefect / Kestra / Mage across the columns, and dynamic tasks / partitions + backfills / RBAC + secrets / dbt integration / streaming / K8s-native / managed-tier across the rows — where no stack wins every row, most rows have a clear leader, and the senior architect's job is to pick the two rows their workload actually lives on and score the winners of those two rows. Every interview eventually asks the "which stack for this workload?" question; the matrix is the reproducible answer.

Iconographic feature-matrix diagram — a 5-column comparison card with Airflow / Dagster / Prefect / Kestra / Mage columns rated on dynamic-tasks / partitions / RBAC / dbt / streaming / K8s / managed axes with dot-rating pips per cell.

Row 1: dynamic tasks — creating N tasks at runtime based on data.

  • Airflow. Dynamic task mapping (2.3+) via .expand() on task-flow tasks; runtime fan-out with per-index task instances. Solid; the "map over 100 files" pattern is one line of Python.
  • Dagster. Dynamic outputs via DynamicOut + map_asset_specs; assets can fan out at runtime. Slightly more ceremony than Airflow but well-integrated with the asset graph.
  • Prefect. .map(...) on tasks; concurrent execution against the work pool. Cleanest dev ergonomics of the five.
  • Kestra. EachSequential / EachParallel tasks iterate over YAML lists; dynamic fan-out is declarative. Slightly awkward at very large fan-outs (> 10k).
  • Mage. Dynamic blocks let a transformer produce N downstream block instances. Adequate; less-used pattern in Mage deployments.

Row 2: partitions + backfills — first-class time / dim slicing and replay.

  • Airflow. DAG-run-level partitioning via execution_date (renamed to logical_date in 2.x). Backfills via airflow dags backfill --start --end. Airflow 2.4 added Datasets; Airflow 3.0 rebranded to Assets — partial move toward per-asset partitions.
  • Dagster. First-class partitions via partitions_def=; multi-dimensional partitions (time × region × category); per-partition backfill in the UI. The strongest partitioning story of the five.
  • Prefect. No native partitions; use flow parameters (hour: str) plus a backfill script that triggers N flow runs with the parameter varying. Adequate but manual.
  • Kestra. No native partitions; backfills via backfill execution kind that replays a date range. Adequate.
  • Mage. No native partitions; backfills via UI "run for date range." Adequate at small scale.

Row 3: RBAC + secrets — role grants and credential management.

  • Airflow. Flask-AppBuilder RBAC with 5 default roles (Admin, Op, User, Viewer, Public); custom roles + permissions per DAG. Secrets Backend integrates with Vault, AWS Secrets Manager, GCP Secret Manager. Solid enterprise story.
  • Dagster. Dagster+ Enterprise ships RBAC on code locations, jobs, assets; asset-level grants. OSS Dagster has minimal RBAC — assumes Dagster+ or SSO+proxy. Secrets via env vars / K8s secrets / EnvVarConfig.
  • Prefect. Prefect Cloud ships RBAC with workspace / project / role. OSS server has basic auth only. Secrets via Secret.load(...) blocks (stored in Prefect server or Vault).
  • Kestra. Enterprise Edition ships RBAC with tenants, namespaces, and per-role permissions. OSS has basic auth. Secrets via secret('KEY') expression backed by Vault / AWS Secrets Manager.
  • Mage. Mage Pro ships RBAC with roles and per-pipeline permissions. OSS has basic auth. Secrets via Mage Vault or env vars.

Row 4: dbt integration — how tightly the stack integrates dbt.

  • Airflow. airflow-dbt, astronomer-cosmos (renders dbt DAG as Airflow tasks). Best-in-class ecosystem tooling; Cosmos is the modern default.
  • Dagster. @dbt_assets(manifest=...) — first-class asset generation from dbt manifest. Assets, lineage, freshness all native. The strongest dbt integration of the five.
  • Prefect. prefect-dbt collection with DbtCoreOperation task; runs dbt commands as flow tasks. Adequate; no asset-level lineage.
  • Kestra. io.kestra.plugin.dbt.cli.DbtCLI task; runs dbt commands as YAML tasks. Adequate; models are opaque to Kestra's UI.
  • Mage. dbt block type; Mage renders dbt DAG in its block UI. Good for small dbt projects; less used at scale.

Row 5: streaming support — Kafka / Flink / stream-aware scheduling.

  • Airflow. DAG-aware scheduling only; polling operators for Kafka. Deferrable sensors (2.2+) reduce worker cost for long-running listeners. Airflow is fundamentally batch-oriented.
  • Dagster. Sensors + AutoMaterialize policies react to external events (S3, Kafka lag, DB row). Not a stream engine but stream-aware scheduling.
  • Prefect. Task caching + deferred flows; serve() daemon runs flow in a long-lived process listening for triggers. Getting closer to stream-aware.
  • Kestra. Kafka-backed executor; triggers on Kafka topics as first-class citizens. Strongest streaming integration of the five — you can wire a Kafka consumer as a trigger with 3 YAML lines.
  • Mage. Streaming pipelines as first-class kind; blocks consume Kafka / Kinesis. Streaming DX competitive with Kestra at small scale.

Row 6: Kubernetes-native — how naturally the stack runs on K8s.

  • Airflow. K8sExecutor, K8sPodOperator, official Helm chart; solid but "K8s bolted onto Celery." Not K8s-native in origin.
  • Dagster. K8sRunLauncher + per-team code servers; official Helm chart. Designed with K8s in mind from 2020.
  • Prefect. Kubernetes work-pool; workers run in K8s and launch K8s Jobs. Prefect 3.0 fully embraces K8s as a first-class deployment target.
  • Kestra. Deploy via Helm; executor + worker + Kafka + Postgres all in K8s. K8s-native from the start.
  • Mage. Deploy via Helm; simpler K8s story (fewer components). K8s-friendly, not K8s-native.

Row 7: managed tier — the hosted control plane, 2026 pricing shape.

  • Airflow. MWAA (AWS, $0.49–$3.87 / hour per instance size), Astronomer (starts ~$1k / mo), Google Composer (~$0.75 / hour + workers), Azure Data Factory Managed Airflow. Broadest choice.
  • Dagster. Dagster+ Serverless ($0.01–$0.03 / material step) or Hybrid (BYO K8s + Dagster-managed control plane at ~$1k / mo). Simple pricing shape.
  • Prefect. Prefect Cloud tiers: Free (up to 100 flow runs / day), Starter ($1k / mo), Team, Enterprise. Predictable seat-based tiers.
  • Kestra. Kestra Cloud tiers: Free, Team, Enterprise. Enterprise licensed by contract for on-prem.
  • Mage. Mage Pro tiers; Starter ~$300 / mo, Team ~$1k / mo, Enterprise custom. Newest of the five managed tiers.

Common interview probes on the feature matrix.

  • "Rank the five on dbt integration." — required: Dagster > Airflow (Cosmos) > Prefect > Mage > Kestra.
  • "Rank the five on managed tier maturity." — required: Airflow (MWAA / Astronomer / Composer) > Dagster+ > Prefect Cloud > Kestra Cloud > Mage Pro.
  • "Which is strongest on streaming triggers?" — required: Kestra > Mage > Dagster > Prefect > Airflow.
  • "What's a K8sPodOperator?" — Airflow's escape hatch that spawns a K8s Pod per task; equivalent to K8sRunLauncher in Dagster or a K8s work pool in Prefect.

Worked example — dbt integration compared across the five stacks

Detailed explanation. Every serious analytics-engineering platform in 2026 runs dbt. The orchestrator's dbt integration is often the single deciding feature. Walk through wiring dbt build in all five stacks and compare the lineage / observability payload each surfaces.

  • dbt project. 40 models spanning staging, marts, metrics.
  • Frequency. dbt build every 2 hours.
  • Goal. Per-model materialisation events with lineage into the orchestrator's UI.

Question. Write the dbt-integration wiring in each stack and rank the lineage surfaces.

Input.

Stack dbt package Lineage per model Freshness per model
Airflow astronomer-cosmos via Cosmos task tree Cosmos manifest surface
Dagster dagster-dbt native asset graph native asset freshness
Prefect prefect-dbt opaque (single flow run) none
Kestra dbt.cli.DbtCLI opaque (single task) none
Mage dbt block type Mage block graph Mage block preview

Code.

# Airflow — astronomer-cosmos renders dbt DAG as Airflow tasks
from airflow import DAG
from datetime import datetime
from cosmos import DbtDag, ProjectConfig, ProfileConfig, ExecutionConfig, RenderConfig

my_dbt_dag = DbtDag(
    project_config=ProjectConfig("/opt/dbt/analytics"),
    profile_config=ProfileConfig(
        profile_name="analytics",
        target_name="prod",
        profiles_yml_filepath="/opt/dbt/profiles.yml"),
    execution_config=ExecutionConfig(
        dbt_executable_path="/usr/local/bin/dbt"),
    render_config=RenderConfig(select=["tag:daily"]),
    schedule_interval="0 */2 * * *",
    start_date=datetime(2026, 7, 1),
    catchup=False,
    dag_id="analytics_dbt",
)
Enter fullscreen mode Exit fullscreen mode
# Dagster — @dbt_assets factory; each model becomes an asset
from dagster_dbt import dbt_assets, DagsterDbtTranslator, DbtCliResource
from dagster import Definitions, ScheduleDefinition, define_asset_job
from pathlib import Path

DBT = Path("/opt/dbt/analytics")

@dbt_assets(manifest=DBT / "target" / "manifest.json",
            dagster_dbt_translator=DagsterDbtTranslator(),
            select="tag:daily")
def analytics_dbt(context, dbt: DbtCliResource):
    yield from dbt.cli(["build"], context=context).stream()

analytics_job = define_asset_job("analytics_job", selection=[analytics_dbt])
analytics_sched = ScheduleDefinition(job=analytics_job, cron_schedule="0 */2 * * *")

defs = Definitions(
    assets=[analytics_dbt],
    resources={"dbt": DbtCliResource(project_dir=str(DBT))},
    jobs=[analytics_job],
    schedules=[analytics_sched],
)
Enter fullscreen mode Exit fullscreen mode
# Prefect — prefect-dbt with DbtCoreOperation
from prefect import flow
from prefect_dbt.cli.commands import DbtCoreOperation

@flow(name="analytics-dbt")
def analytics_dbt():
    DbtCoreOperation(
        commands=["dbt build --select tag:daily"],
        project_dir="/opt/dbt/analytics",
        profiles_dir="/opt/dbt",
    ).run()

# Deploy: prefect deploy analytics_dbt --cron "0 */2 * * *" --pool k8s
Enter fullscreen mode Exit fullscreen mode
# Kestra — dbt.cli.DbtCLI task
id: analytics_dbt
namespace: prod.dbt
triggers:
  - id: every_two_hours
    type: io.kestra.plugin.core.trigger.Schedule
    cron: "0 */2 * * *"
tasks:
  - id: dbt_build
    type: io.kestra.plugin.dbt.cli.DbtCLI
    projectDir: /opt/dbt/analytics
    profileDir: /opt/dbt
    commands:
      - dbt deps
      - dbt build --select tag:daily
Enter fullscreen mode Exit fullscreen mode
# Mage — dbt block type in a pipeline
# blocks/dbt_analytics.py
from mage_ai.data_preparation.templates.constants import BlockType
# In the Mage UI, add a "dbt" block pointing at /opt/dbt/analytics
# Mage renders each dbt model as a sub-node in the block canvas
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. Airflow + Cosmos renders each dbt model as its own Airflow task, so the Airflow UI shows the full dbt DAG. Model-level retries are supported; model-level lineage flows through OpenLineage. Cosmos is the current-best Airflow-dbt story; older airflow-dbt packages predate it.
  2. Dagster + @dbt_assets reads manifest.json and generates one Dagster asset per dbt model. The asset graph in the Dagster UI is the dbt DAG, with native materialisation events, native freshness, and native cost per asset (via Dagster+ Insights). This is the strongest dbt-orchestrator integration in the 2026 market.
  3. Prefect + prefect-dbt runs dbt build as a single flow task. Model-level lineage is not surfaced in the Prefect UI — the whole dbt run is one flow run. Adequate if dbt lineage is not a Prefect responsibility (delegated to dbt Cloud or a metadata service).
  4. Kestra + DbtCLI runs dbt build as one YAML task. Similar to Prefect — no per-model lineage; the run is one task in Kestra's view. Useful when Kestra orchestrates dbt as one node in a polyglot flow.
  5. Mage + dbt block renders each model as a sub-node in the block canvas. Best for small dbt projects (< 20 models) where the visual DAG maps cleanly onto a Mage pipeline.

Output.

Stack Per-model lineage in UI Cost / freshness per model Best for
Airflow (Cosmos) yes (task per model) via OpenLineage bolt-on mid-scale dbt with existing Airflow
Dagster yes (asset per model) native (Dagster+ Insights) greenfield dbt platforms
Prefect no (one flow run) none dbt as a side pipeline
Kestra no (one YAML task) none dbt inside polyglot flows
Mage yes (block sub-nodes) via block preview small dbt projects (< 20)

Rule of thumb. For any dbt-heavy analytics platform, Dagster's @dbt_assets factory is the strongest integration and the primary reason greenfield teams pick Dagster over Airflow. Airflow + Cosmos is a close second when incumbent Airflow expertise dominates. Prefect / Kestra / Mage are competitive for smaller dbt footprints but do not match Dagster's model-level lineage.

Worked example — managed-tier pricing across the five stacks in 2026

Detailed explanation. Managed-tier pricing is the second-most-common interview question in the "which orchestrator?" family (the first is dbt integration). Walk through the 2026 published rates and the "what does it cost me at 500 pipelines / day?" calculation for each stack.

  • Workload. 500 pipeline runs / day, average 4 tasks per run, 30 days = 15k pipeline runs / month = 60k task executions / month.
  • Compute. Assume the runs execute against external compute (Snowflake / EMR / K8s) — orchestrator cost is control-plane only.
  • Users. 15 active users on the UI.

Question. Compute the monthly managed-tier bill for each stack at this workload.

Input.

Stack Managed tier 2026 pricing (published)
Airflow Astronomer Hosted ~$1k–$3k / mo per environment
Airflow AWS MWAA ~$0.49–$3.87 / hour per instance (mw1.small–mw1.xlarge)
Dagster Dagster+ Serverless ~$0.03 / material step (Standard)
Prefect Prefect Cloud Starter ~$1k / mo (10 users incl.), then per-seat
Kestra Kestra Cloud Team ~$500 / mo (10 users), Enterprise custom
Mage Mage Pro Starter ~$300 / mo, Team ~$1k / mo

Code.

# Managed-tier cost estimator (illustrative 2026 rates)
def monthly_cost(stack: str, runs_per_month: int, users: int) -> float:
    if stack == "airflow_mwaa":
        # mw1.medium at $1.05 / hour x 730 hours / month
        return 1.05 * 730

    if stack == "airflow_astronomer":
        # Hosted Standard tier
        return 2000.0

    if stack == "dagster_serverless_standard":
        # ~4 material steps per pipeline run (extract, clean, dim, fct)
        steps = runs_per_month * 4
        return 0.03 * steps

    if stack == "prefect_cloud_starter":
        # 10 users included; $15 / additional user seat
        return 1000.0 + max(0, users - 10) * 15

    if stack == "kestra_cloud_team":
        # 10 users included; $10 / additional user seat
        return 500.0 + max(0, users - 10) * 10

    if stack == "mage_pro_team":
        return 1000.0

    return float("nan")

for stack in [
    "airflow_mwaa", "airflow_astronomer",
    "dagster_serverless_standard", "prefect_cloud_starter",
    "kestra_cloud_team", "mage_pro_team",
]:
    cost = monthly_cost(stack, runs_per_month=15000, users=15)
    print(f"{stack:35s} → ${cost:,.0f} / month")
# airflow_mwaa                        → $767 / month
# airflow_astronomer                  → $2,000 / month
# dagster_serverless_standard         → $1,800 / month
# prefect_cloud_starter               → $1,075 / month
# kestra_cloud_team                   → $550 / month
# mage_pro_team                       → $1,000 / month
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. MWAA is the cheapest hosted Airflow at small scale — an mw1.medium at ~$1.05 / hour × 730 hours / month = ~$770 / month. Above ~2,000 DAG runs / hour you outgrow mw1.medium and jump to mw1.large ($2.02 / hour) or mw1.xlarge ($3.87 / hour); Astronomer starts to look competitive.
  2. Astronomer Hosted Standard is ~$2k / month for a mid-sized environment with unlimited runs; add environments for staging + prod and it doubles. Astronomer includes their runtime, CI/CD, and support — the premium buys operational maturity.
  3. Dagster+ Serverless Standard bills per material step at $0.03. At 15k runs / mo × 4 assets / run = 60k steps × $0.03 = ~$1.8k / mo. Insights, alerting, RBAC included. The pricing shape rewards workloads with cheap assets and punishes very high-frequency small tasks.
  4. Prefect Cloud Starter is $1k / mo flat with 10 user seats included; $15 per additional seat. At 15 users that's $1,075. Predictable pricing shape; scales cleanly.
  5. Kestra Cloud Team is $500 / mo with 10 seats; the cheapest of the five at this workload. Kestra's positioning is "easy to try, cheap to scale" — the pricing reflects that.
  6. Mage Pro Team is $1k / mo. Positioning is "notebook-friendly platform for analytics engineering teams," pricing matches Prefect Cloud.

Output.

Stack Monthly cost Notes
Kestra Cloud Team $550 cheapest at this scale
MWAA (mw1.medium) $767 Airflow if you can self-manage DAGs
Mage Pro Team $1,000 notebook-friendly
Prefect Cloud Starter $1,075 seat-priced
Dagster+ Serverless $1,800 per-step; scales with assets
Astronomer Hosted $2,000 premium Airflow

Rule of thumb. For any orchestrator managed-tier evaluation, model the actual monthly bill using published rates and your projected volume. Do not compare list prices in isolation — MWAA at $770 looks cheapest until you factor in the platform engineer's ~$180k / year of on-call time it doesn't eliminate. Astronomer at $2k / mo looks expensive until you count the CI/CD, upgrades, and support that come with it. Cost is only one axis; combine with the four in the decision tree.

Worked example — RBAC + secrets — the enterprise story per stack

Detailed explanation. Every enterprise deployment must answer three questions: who can view flows, who can run flows, and where do credentials live. Walk through the RBAC and secrets story for each stack at "enterprise" tier.

  • Requirement. SSO via Okta; 4 teams with independent namespaces; secrets in HashiCorp Vault.
  • RBAC. Per-namespace admin / editor / viewer roles.
  • Secrets. Injected at runtime; never stored in the orchestrator UI.

Question. Configure RBAC + Vault for each stack.

Input.

Stack RBAC surface Secrets backend
Airflow Flask-AppBuilder roles + custom permissions Vault secrets backend
Dagster+ Enterprise RBAC on code locations + assets Vault via env / init container
Prefect Cloud Workspace / project / role Vault via Prefect Secret blocks
Kestra Enterprise Tenant + namespace + role Vault via secret()
Mage Pro Roles + per-pipeline permissions Mage Vault or env vars

Code.

# Airflow — Vault secrets backend
# airflow.cfg
[secrets]
backend = airflow.providers.hashicorp.secrets.vault.VaultBackend
backend_kwargs = {
  "connections_path": "connections",
  "variables_path":   "variables",
  "mount_point":      "airflow",
  "url":              "https://vault.internal:8200",
  "auth_type":        "kubernetes",
  "kubernetes_role":  "airflow"
}

# Custom RBAC — role that can view + trigger DAGs in one namespace
# via Airflow REST API or webserver_config.py
from airflow.www.security import AirflowSecurityManager

class CustomSecManager(AirflowSecurityManager):
    def create_custom_dag_permission_view(self):
        return self.add_permission_view("can_dag_read", "team_ingest_dags")
Enter fullscreen mode Exit fullscreen mode
# Dagster+ — RBAC via code-location grants; secrets via env
# In Dagster+ Enterprise UI: grants map user groups → code locations
# Grant "team-ingest-admins" → code location "team_ingest" with role "admin"
# Grant "team-analytics-viewers" → code location "team_analytics" with role "viewer"

# Secrets via Kubernetes Secret + envFrom on the code-server deployment
# spec.containers[].envFrom = [{secretRef: {name: dagster-team-ingest-secrets}}]

# Or via Vault Agent Injector sidecar populating /vault/secrets/
Enter fullscreen mode Exit fullscreen mode
# Prefect Cloud — workspace + role + secret block
from prefect.blocks.system import Secret
from prefect_hashicorp import HashicorpVaultSecret

vault_secret = HashicorpVaultSecret(
    vault_url="https://vault.internal:8200",
    path="secret/data/snowflake/prod",
    key="password",
)
vault_secret.save("snowflake-prod-pass", overwrite=True)

@task
def read_snowflake():
    password = HashicorpVaultSecret.load("snowflake-prod-pass").get()
    return connect_snowflake(user="etl_user", password=password)
Enter fullscreen mode Exit fullscreen mode
# Kestra Enterprise — namespace + tenant + role + secret expression
id: ingest_orders
namespace: prod.ingest        # namespace enforces RBAC boundary
tenantId: acme-prod
tasks:
  - id: extract
    type: io.kestra.plugin.jdbc.postgresql.Query
    url:      "jdbc:postgresql://db-primary:5432/prod"
    username: "{{ secret('DB_USER') }}"           # Vault-backed
    password: "{{ secret('DB_PASSWORD') }}"
    sql: "SELECT * FROM orders WHERE updated_at > '{{ trigger.previous }}'"
Enter fullscreen mode Exit fullscreen mode
# Mage Pro — role + per-pipeline permission + Vault
# In Mage Pro UI: Settings → Roles → create role "team-ingest-editor"
# Permissions: view pipelines, trigger pipelines, edit pipeline "orders_hourly"
# Users: assign via Okta group sync

# Secrets via Mage Vault (backed by env vars or an external Vault)
# io_config.yaml
default:
  POSTGRES_PASSWORD: "{{ env_var('SOURCE_DB_PASSWORD') }}"
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. Airflow's Flask-AppBuilder RBAC is powerful but old-school — you define custom roles and per-DAG permissions in Python. Vault as the secrets backend is a first-class integration; a Variable.get("db_password") call fetches from Vault at runtime, so DAG code never sees the plaintext.
  2. Dagster+ Enterprise's RBAC surface is per-code-location — grants map user groups to code locations with a role (admin, editor, viewer). Secrets are injected via K8s Secret + envFrom on the code-server deployment or via a Vault Agent Injector sidecar. Cleaner than Airflow's per-DAG permission model for multi-team deployments.
  3. Prefect Cloud's RBAC is workspace + project + role. Secrets live in Prefect Blocks — HashicorpVaultSecret.load(name) at task runtime pulls from Vault via the Prefect API. The abstraction is cleaner than raw env vars but adds Prefect as a dependency for the secret path.
  4. Kestra Enterprise ships tenant + namespace + role. Namespaces enforce the RBAC boundary; a user with editor role on prod.ingest can edit flows in that namespace but not in prod.analytics. secret() expressions resolve through Vault at task runtime.
  5. Mage Pro's RBAC is roles + per-pipeline permissions with Okta group sync. Secrets flow through io_config.yaml referencing env vars or Vault-backed values. Simpler than the others; competitive at the small-team scale Mage targets.

Output.

Stack RBAC granularity Secrets integration Enterprise story
Airflow per-DAG (custom) Vault backend (native) mature (FAB)
Dagster+ Enterprise per-code-location + per-asset Vault via K8s or sidecar modern (2024+)
Prefect Cloud workspace + project Vault via Blocks modern (2023+)
Kestra Enterprise tenant + namespace Vault via secret() modern (2024+)
Mage Pro role + per-pipeline Vault via env / config newest (2024+)

Rule of thumb. For any enterprise orchestrator deployment, insist on Vault-backed secrets injected at task runtime (never stored in the UI) and role-based access at a granularity matching your team topology. Airflow and Dagster+ Enterprise are the two most mature enterprise stacks in 2026; Prefect Cloud and Kestra Enterprise are competitive; Mage Pro is the newest and best suited for smaller deployments.

Senior interview question on the feature matrix

A senior interviewer might ask: "You've been asked to run an orchestrator comparison for your CTO. The workload is 40 dbt models + 20 ingestion pipelines, deployed on EKS, with 15 users across 3 teams, SSO via Okta, and a budget of $2k / month on the managed tier. Score all five stacks on the 7-axis feature matrix, name a winner, and defend the pick against a follow-up 'why not just use MWAA?'"

Solution Using a scored 7×5 matrix that lands Dagster+ Serverless as the winner

1. Score each cell 1-5 (higher = stronger for THIS workload)
-----------------------------------------------------------
Axis                    | Air | Dag | Pre | Kes | Mag
---------------------- -+-----+-----+-----+-----+-----
dynamic tasks           |  4  |  4  |  5  |  3  |  3
partitions + backfills  |  3  |  5  |  2  |  2  |  2
RBAC + secrets          |  4  |  5  |  4  |  4  |  3
dbt integration         |  4  |  5  |  3  |  2  |  3
streaming triggers      |  2  |  4  |  3  |  5  |  3
K8s native              |  4  |  5  |  4  |  4  |  3
managed tier fit        |  4  |  5  |  4  |  4  |  3
---------------------- -+-----+-----+-----+-----+-----
Weighted total (equal)  | 25  | 33  | 25  | 24  | 20
Enter fullscreen mode Exit fullscreen mode
# 2. Winner: Dagster + Dagster+ Serverless
# Justification per axis
axes_notes = {
    "partitions + backfills": "dbt-heavy + reruns; native per-partition rematerialise",
    "dbt integration":        "@dbt_assets factory; asset-per-model lineage",
    "K8s native":             "K8sRunLauncher + per-team code servers",
    "managed tier fit":       "Dagster+ Serverless $0.03/step ~$1.8k / mo",
}
Enter fullscreen mode Exit fullscreen mode
# 3. Defence against "why not MWAA"
answer = """
MWAA is the cheapest managed Airflow at this scale (~$770/mo for
mw1.medium), but it wins only two of the seven axes for this
workload — managed tier fit and (with Cosmos) dbt integration.
It loses on partitions + backfills (DAG-run-level, not asset-partition)
and on per-team isolation (shared DAG folder). At 40 dbt models we'd
have to re-run all upstream on any downstream rebuild — with Dagster
that upstream compute is skipped. On our estimated 5 backfills/month
across the fact layer that saves roughly $600/mo of Snowflake credits,
which more than offsets the Dagster+ Serverless premium over MWAA.
"""
Enter fullscreen mode Exit fullscreen mode
# 4. Sprint plan (illustrative)
# Sprint 1: stand up Dagster+ Serverless; import first 5 dbt models via factory
# Sprint 2: import all 40 dbt models; wire per-team code locations
# Sprint 3: port 20 ingestion pipelines from previous stack
# Sprint 4: cut over reverse-ETL syncs; retire previous stack
# Sprint 5: RBAC + Okta SSO; Vault secrets; on-call runbooks
# Sprint 6: cost + freshness dashboards; consolidate on Dagster+ Insights
Enter fullscreen mode Exit fullscreen mode

Step-by-step trace.

Sprint Milestone Stack coverage
1 Dagster+ live, 5 dbt models 12%
2 All 40 dbt models materialised 65%
3 20 ingestion pipelines ported 90%
4 Reverse-ETL cut over 100% (with prev-stack shadow)
5 RBAC + secrets consolidated prev stack retired
6 Cost + freshness dashboards steady-state

After the rollout, all workloads live in Dagster's asset graph, per-team code locations enforce team isolation, and Dagster+ Serverless bills ~$1.8k / month while eliminating an estimated $600 / month of wasted-backfill Snowflake compute. The CTO sees one dashboard for cost, freshness, and lineage per team; the on-call rotation triages at the asset level rather than at the DAG-run level.

Output:

Metric Airflow (MWAA) Dagster+ Serverless
dbt-model lineage Cosmos-rendered tasks asset-per-model native
Backfill primitive DAG-run range asset-partition selection
Per-team isolation shared DAG folder per-team code server
Managed tier monthly ~$770 ~$1,800
Snowflake waste per backfill ~$600 / mo estimated ~$0
Net platform cost $770 + $600 = $1,370 $1,800
Winner on this workload Dagster

Why this works — concept by concept:

  • 7-axis scored matrix — turn the "which stack?" question into a reproducible number. Each cell is a 1–5 score against the specific workload; the sum picks the winner. Interviewers grade on the method, not on the specific scores; the reproducibility is the answer.
  • Partitions + backfills axis — for a dbt-heavy workload, Dagster's per-partition rematerialise saves 40–60% of upstream compute on every downstream backfill. Naming this saving in Snowflake credits ($600 / mo estimate) is the argument that beats the MWAA sticker-price advantage.
  • dbt integration axis@dbt_assets factory generates one Dagster asset per dbt model with native lineage and freshness. Cosmos on Airflow is a strong second but doesn't ship native cost per asset the way Dagster+ Insights does.
  • Managed tier fit — Dagster+ Serverless at $0.03 / material step lands ~$1.8k / mo for this workload; total-cost-of-ownership including saved Snowflake backfill compute makes it the winner despite the higher sticker price than MWAA.
  • Cost — Dagster+ Serverless $1.8k + EKS ~$0 (Serverless runs elsewhere) + RDS metadata $100 = ~$1.9k / mo; well within the $2k budget. The eliminated cost is the wasted-upstream-compute on backfills and the ~30% of a platform engineer's time that shared-DAG-folder deploys were consuming under Airflow. Net O(assets × partitions) versus O(DAGs × runs × upstream depth) with hidden multi-team coupling.

SQL
Topic — sql
SQL orchestrator-comparison interview problems

Practice →

Design Topic — design Design problems on managed-tier orchestration platforms

Practice →


5. Decision tree + migration paths + interview signals

The 5-question decision tree picks the stack; migration paths quantify the cost; interview signals prove you can defend the pick under pressure

The mental model in one line: a defensible orchestrator comparison answer has three layers — a 5-question decision tree that lands a stack pick in under 60 seconds, a set of migration paths that quantify the cost of moving between stacks (Airflow → Dagster ≈ 6 months; Airflow → Prefect ≈ 4 months; Mage → Airflow ≈ 8 weeks), and a set of interview signals that separate senior candidates who name platforms and defend picks from candidates who describe features and hope. Interviewers grade on structure, named specifics, and the ability to walk the decision tree end-to-end without hedging.

Iconographic decision-tree diagram — a vertical 5-question flowchart on the left branching to the five stack medallions on the right, plus a bottom strip showing three migration arrows Airflow→Dagster, Airflow→Prefect, Mage→Airflow.

The 5-question decision tree, in order.

  • Q1 — Python-first team? If no (polyglot / YAML-first) → Kestra. If yes, continue.
  • Q2 — Asset lineage or dbt-heavy? If yes → Dagster. If no, continue.
  • Q3 — Work-pool / hybrid infrastructure? If yes → Prefect. If no, continue.
  • Q4 — Notebook-friendly / block UI for AEs? If yes → Mage. If no, continue.
  • Q5 — Anything else / incumbent / broadest ecosystem?Airflow.

Why the order matters.

  • Q1 screens out Kestra first because polyglot is the single biggest fork — if you need YAML-first polyglot, none of the Python stacks match.
  • Q2 screens out Airflow / Prefect / Mage / Kestra next because the asset-graph pivot is qualitatively different from a task-graph.
  • Q3 comes before Q4 because hybrid infrastructure separation (K8s + ECS + serverless) is a stronger architectural signal than "notebook-friendly UI."
  • Q4 catches the small-team AE-heavy use case; Mage's block UI is the differentiator.
  • Q5 is the Airflow default; if none of the branches match cleanly, the incumbent's 15-year ecosystem is the safe answer.

The three canonical migration paths in 2026.

  • Airflow → Dagster. ~6 months for a mid-sized platform (20–50 DAGs, 3–5 teams). Rewrite tasks as assets; adopt the @dbt_assets factory; stand up per-team code servers; consolidate scheduling under the Dagster daemon. Payoff: per-partition backfills, native asset lineage, per-team isolation.
  • Airflow → Prefect. ~4 months for a mid-sized platform. Port DAG functions to @flow functions; migrate operator wraps to Python tasks; set up work pools per infrastructure target. Payoff: cleaner flow-plus-infra separation, better ergonomics for many-small-pipelines teams.
  • Mage → Airflow. ~8 weeks when the team outgrows Mage (~30+ pipelines, 4+ teams). Port each Mage pipeline's blocks into a DAG file with @task decorators; move io_config.yaml credentials to Airflow Variables / Secrets Backend; set up Astronomer or MWAA. Payoff: scale to hundreds of pipelines, mature RBAC, broader ecosystem.

Migration paths that are rare in 2026.

  • Dagster → Airflow. Almost never — going from asset-graph to task-graph loses the per-partition rematerialise story. Only happens when a team retires an experimental Dagster deployment and consolidates on an existing Airflow platform.
  • Prefect → Airflow. Sometimes when an enterprise mandates Airflow as the corporate standard; the migration is straightforward (both are Python task-graph) but loses the work-pool ergonomics.
  • Kestra → any Python stack. Rare — teams that pick Kestra do so for the polyglot property; the migration is a full rewrite.
  • Airflow → Mage. Never at scale; Mage's scale ceiling means the migration works only for very small deployments where a rewrite is cheap.

The interview signal ladder — how senior candidates get graded.

  • Level 1 (junior). Names one orchestrator (usually Airflow). Cannot defend the pick.
  • Level 2 (mid). Names two or three orchestrators. Compares features but not axes.
  • Level 3 (senior). Names all five. Walks the 4-axis comparison. Picks based on workload constraints.
  • Level 4 (staff). Level 3 + names migration cost between stacks in engineer-months. Names the 5-question decision tree. Defends against "why not the incumbent?" without hedging.
  • Level 5 (principal). Level 4 + names the history of the orchestrator market (Airflow 2015 → Prefect 2018 → Dagster 2019 → Kestra 2019 → Mage 2022 → Airflow 3.0 2025). Distinguishes marketing pivots from architectural pivots (Airflow's "Assets" is marketing; Dagster's SDA is architectural).

Common interview probes on the decision tree + migration paths.

  • "You inherit an Airflow deployment; would you migrate?" — probe on migration cost and honest ROI. Senior answer: "not without a specific pain point; migration is 4–6 engineer-months; the incumbent works."
  • "Walk me through the 5 questions." — required: recite the tree in order without hedging.
  • "Why is Dagster winning greenfield?" — required: asset-graph + dbt-heavy analytics engineering trend.
  • "Is Airflow dying?" — required: no; incumbent for 15 years; Airflow 3.0 (2025) landed with meaningful improvements; the market is fragmented but Airflow retains the largest share.
  • "When would you pick Mage over Prefect?" — required: small AE-heavy team, ≤ 10 pipelines, notebook-native DX matters.

Worked example — the "greenfield analytics platform" scenario

Detailed explanation. The most common interview scenario in 2026 is "build a data platform for a new analytics team." Walk the decision tree end-to-end with the interviewer's likely follow-ups.

  • Scenario. 15-person data team (10 DEs + 5 AEs), 40 dbt models, deployed on EKS, $2k / mo managed budget.
  • Q1. Python-first? → yes.
  • Q2. Asset lineage / dbt-heavy? → yes → Dagster.
  • Q5. Managed-only? → yes → Dagster+ Serverless.

Question. Walk the tree out loud and answer three follow-up questions: "why not Airflow?", "why Dagster+ over self-hosted?", and "what's the migration path if the CTO overrules you?"

Input.

Follow-up Weak answer Senior answer
Why not Airflow? "Dagster is newer" "asset graph + per-partition backfills save 40–60% Snowflake credits on rerun; per-team code servers eliminate shared-DAG-folder deploys"
Why Dagster+ over self-hosted? "less work" "Dagster+ Insights ships cost + freshness per asset out of the box; self-hosting adds ~30% of a platform engineer's time"
CTO overrules? "we'd use Airflow" "Airflow + Cosmos for dbt; Astronomer Hosted; per-team K8sPodOperator isolation; 4 months to port from our Dagster prototype"

Code.

Greenfield analytics platform decision walk (60 seconds)
========================================================

Q1 Python-first?           yes  (10 DEs Python-fluent, 5 AEs comfortable in Python)
Q2 Asset lineage / dbt?    yes  (40 dbt models; per-partition backfills are a real cost)
                                → Dagster
Q5 Managed-only?           yes  (~30% of a platform engineer is the alternative)
                                → Dagster+ Serverless Standard

Answer:  Dagster + Dagster+ Serverless Standard, deployed against EKS
         via Dagster+ Hybrid or fully-serverless per team preference.
         Estimated monthly cost: ~$1.8k managed tier + ~$100 RDS.
         Backfill compute savings vs Airflow: ~$600 / mo Snowflake credits.
         Net: $1.9k managed - $600 savings = $1.3k / mo effective cost.
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. Q1 is answered "yes" without hedging — a Python-fluent DE team of 10 plus 5 AEs comfortable in Python maps cleanly onto Python-first orchestrators. Naming both roles is senior signal; hedging ("we could use Kestra if…") is junior.
  2. Q2 is answered "yes" with the specific justification — 40 dbt models means the asset graph maps 1:1 onto the dbt DAG; per-partition backfills save Snowflake compute on rerun. Naming the savings mechanism (skip upstream rematerialisation) is senior signal.
  3. Q3 (work-pool separation), Q4 (notebook UI) are skipped because Q2 already locked Dagster. Naming Q5 separately proves you're not confusing "managed tier" with "orchestrator pick" — the two axes are orthogonal.
  4. The "why not Airflow?" follow-up is answered with the specific cost saving ($600 / mo Snowflake credits from skipped upstream rematerialisation) plus the per-team isolation (per-team code servers vs shared DAG folder). Both are concrete; both are defensible.
  5. The "CTO overrules" hypothetical is answered with a graceful fallback plan — Airflow + Cosmos + Astronomer + K8sPodOperator per team + 4-month migration. Refusing to answer the fallback is a red flag; naming the fallback with realistic engineer-months is senior signal.

Output.

Decision Answer Justification
Primary stack Dagster Q1 yes, Q2 yes
Managed tier Dagster+ Serverless Q5 yes; $1.8k / mo
Fallback if overruled Airflow + Cosmos + Astronomer familiar; 4 months to port
Effective monthly cost ~$1.3k $1.9k - $600 backfill savings

Rule of thumb. For the greenfield analytics platform scenario, always default to Dagster + Dagster+ Serverless with 40+ dbt models. Have a clean Airflow + Cosmos + Astronomer fallback ready for the "CTO overrules" follow-up. Naming both the primary and the fallback with realistic migration costs is what senior interviewers listen for.

Worked example — the "migrate off Airflow" scenario

Detailed explanation. The second-most-common scenario is "we've been on Airflow for 4 years and we want to consider a migration." The senior answer is careful — migration is 4–6 engineer-months, not free; the case for migration must be specific pain points, not "Dagster is newer." Walk through the honest ROI analysis.

  • Scenario. 50 DAGs on MWAA, 4 teams sharing one DAG folder, complaints about deploys restarting each other's schedulers, backfill compute costs $2k / mo on Snowflake.
  • Q1. Python-first? → yes.
  • Q2. Asset lineage / dbt-heavy? → yes.

Question. Score the migration ROI over 12 months.

Input.

Cost / benefit Estimate
Migration engineering 6 engineer-months × $180k / yr / 12 × 1.3 = ~$117k
Ongoing Dagster+ Serverless cost ~$1.8k / mo × 12 = ~$22k / yr
Ongoing MWAA cost eliminated ~$770 / mo × 12 = ~$9k / yr
Snowflake backfill compute saved ~$600 / mo × 12 = ~$7k / yr
Platform-engineer time saved ~30% × 1 FTE × $180k = ~$54k / yr

Code.

# Migration ROI calculator (12-month horizon, illustrative)
def migration_roi(months: int = 12) -> dict:
    migration_capex   = 117_000                  # one-time engineering
    dagster_opex      =  1_800 * months          # ongoing managed tier
    mwaa_opex_saved   =    770 * months          # eliminated
    snow_backfill_sav =    600 * months          # from per-partition rematerialise
    platform_eng_sav  = 54_000 / 12 * months     # ~30% of one FTE

    total_cost  = migration_capex + dagster_opex
    total_saved = mwaa_opex_saved + snow_backfill_sav + platform_eng_sav
    return {
        "capex":       migration_capex,
        "opex_net":    dagster_opex - mwaa_opex_saved,
        "hard_savings": snow_backfill_sav,
        "soft_savings": platform_eng_sav,
        "12m_net":     total_saved - total_cost,
        "break_even_months": migration_capex / max(1, (mwaa_opex_saved
                                                       + snow_backfill_sav
                                                       + platform_eng_sav
                                                       - dagster_opex) / months),
    }

print(migration_roi(12))
# {'capex': 117000,
#  'opex_net': 12360,
#  'hard_savings': 7200,
#  'soft_savings': 54000,
#  '12m_net': -68160,
#  'break_even_months': 25.3}
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. The one-time migration engineering cost dominates the year-1 ROI. 6 engineer-months at fully-loaded rates is roughly $117k; this is the honest number to bring to the CTO conversation.
  2. Ongoing OPEX is roughly break-even — Dagster+ Serverless ($1.8k/mo) minus MWAA ($770/mo) = ~$1k/mo net add. Not a compelling saving on its own.
  3. Hard savings are the Snowflake backfill compute avoided by per-partition rematerialise (~$600/mo). Modest.
  4. Soft savings are the platform-engineer time (~30% of one FTE = ~$54k/yr) freed up by eliminating shared-DAG-folder deploy conflicts. This is the real ROI driver but the hardest to defend to finance.
  5. At 12 months, the migration is net negative (~-$68k) if you only count hard savings. Break-even happens at ~25 months. The migration case must include the soft platform-engineer savings to be worth pursuing.

Output.

Time horizon Net ROI (hard only) Net ROI (hard + soft)
6 months -$115k -$106k
12 months -$68k -$14k
18 months -$21k +$78k
24 months +$26k +$170k
36 months +$120k +$355k

Rule of thumb. For any orchestrator migration proposal, model the honest 12- and 24-month ROI including the migration engineering cost, the OPEX delta, the hard savings (compute), and the soft savings (platform-engineer time). If the soft savings dominate the case, be transparent about it — CFOs discount soft savings heavily. Only propose a migration that's net-positive within 18 months on hard savings alone or that has a specific compliance / capability trigger.

Worked example — the interview signal one-liner per stack

Detailed explanation. Every senior candidate should have a rehearsed one-liner per stack — a 15-second pitch that names the stack, its differentiator, and its ideal workload. Walk through the five one-liners and the follow-up rebuttals.

  • Airflow. "The incumbent; 15-year Python task-DAG ecosystem; 2,000 providers; three cloud managed tiers; the safe choice."
  • Dagster. "Asset graph; SDA + @dbt_assets factory; per-team code servers; native lineage; the analytics-engineering choice."
  • Prefect. "Task graph + work pools; cleanest flow-to-infra separation; Python decorators; the many-small-pipelines choice."
  • Kestra. "YAML flows + Kafka executor; first-class polyglot; namespaces + tenants; the platform-team-as-a-service choice."
  • Mage. "Block DAG + data-app UI; notebook-native DX; shortest AE on-ramp; the small-team analytics choice."

Question. Draft the one-liner + rebuttal for each stack.

Input.

Stack 15-second pitch Common rebuttal
Airflow "the incumbent" "isn't it old and complex?"
Dagster "the asset-graph" "isn't SDA overkill?"
Prefect "flow + work pools" "isn't it just Airflow's ergonomic clone?"
Kestra "YAML polyglot" "isn't YAML too rigid?"
Mage "block UI + notebooks" "doesn't it fall over at scale?"

Code.

Stack one-liners + rebuttals — memorise verbatim
================================================

AIRFLOW
  Pitch: "The 15-year Python task-DAG incumbent — 2,000 providers,
          MWAA / Astronomer / Composer managed, Airflow 3.0 (2025)
          rebuilt scheduler + task SDK; the ecosystem depth
          nothing else touches."
  Rebuttal: "Complex, yes — but 'the shared-DAG-folder problem' is
             fixable via per-team K8sPodOperator isolation; the age
             is the ecosystem's asset, not a liability."

DAGSTER
  Pitch: "Asset-graph orchestrator; declare data assets not tasks;
          native lineage; @dbt_assets factory generates one asset
          per dbt model; Dagster+ Insights ships cost per asset."
  Rebuttal: "SDA is only 'overkill' when data isn't the primary
             product. For dbt-heavy analytics platforms it's the
             right noun and it saves 40-60% backfill compute."

PREFECT
  Pitch: "Python task graph with work pools; the same flow ships
          to K8s / ECS / process without code changes; @flow
          decorator; Prefect Cloud pricing scales cleanly."
  Rebuttal: "Prefect isn't Airflow's clone — the work-pool model
             is qualitatively different; teams with heterogeneous
             infra (K8s + ECS + serverless) benefit the most."

KESTRA
  Pitch: "YAML-first polyglot; Kafka-backed executor; namespaces
          + tenants; first-class Kafka triggers; the platform-team-
          as-a-service choice for polyglot organisations."
  Rebuttal: "YAML is rigid only if your team is Python-mono; for
             polyglot teams (SQL + Python + Bash + JS) it's the
             lingua franca."

MAGE
  Pitch: "Block-based DAG with data-app UI; notebook-native DX;
          block previews on every run; shortest on-ramp for
          analytics engineers under ~10 pipelines."
  Rebuttal: "Mage's scale ceiling is real (~30 pipelines) but it's
             the correct choice for the small-team AE-heavy use
             case where Airflow's ergonomics are overkill."
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. Every one-liner names the stack, its architectural differentiator, and its ideal workload. Interviewers listen for all three; missing any one telegraphs surface-level knowledge.
  2. Every rebuttal names the specific criticism and answers it with a specific counter. Generic rebuttals ("well, all tools have trade-offs") are junior; specific rebuttals ("SDA is only overkill when data isn't the primary product") are senior.
  3. The one-liners map onto the 5-question decision tree — Airflow is Q5 default, Dagster is Q2 yes, Prefect is Q3 yes, Kestra is Q1 no, Mage is Q4 yes. Rehearsing the pitches together makes the tree walk fluent.
  4. Rehearsing the one-liners lets you answer "give me a 15-second pitch for each stack" without hedging. Interviewers use this exact question as a screening filter; passing it moves the conversation to workload-specific scenarios.
  5. The rebuttals let you close the "isn't X better?" loop cleanly. Never argue with the criticism; acknowledge it and reframe. This is the difference between a candidate who thinks they're smarter than the interviewer and one who's collaborating on the correct answer.

Output.

Stack Interview one-liner score Notes
Airflow mandatory to memorise Q5 default
Dagster mandatory to memorise Q2 winner (dbt)
Prefect mandatory to memorise Q3 winner (work pools)
Kestra senior signal Q1 winner (polyglot)
Mage senior signal Q4 winner (small AE)

Rule of thumb. Rehearse the one-liner + rebuttal for each of the five stacks. Deploy them in order (Airflow → Dagster → Prefect → Kestra → Mage) when asked "compare the orchestrator market." This is the single most impactful interview-prep exercise for an orchestrator-heavy platform role.

Senior interview question on the decision tree + migration paths

A senior interviewer might ask: "Your team has been on Airflow for 3 years; DAGs work but on-call is painful because 4 teams share the DAG folder and one team's deploy occasionally restarts everyone's scheduler. The CTO wants to know: (a) is a migration worth it, (b) which stack you'd migrate to, (c) what the migration plan looks like, and (d) what happens if the migration goes over budget by 50%. Answer end-to-end."

Solution Using a Dagster migration proposal with 6-month runway, per-team code isolation, and a budget-overrun escape hatch

1. Is a migration worth it? — Honest ROI
=========================================
Pain point:          shared-DAG-folder deploys restart 4 teams' schedulers
Root cause:          Airflow's single-DAG-folder deploy model
Migration target:    Dagster (per-team code servers eliminate this)
Migration cost:      ~6 engineer-months × $180k / 12 × 1.3 = ~$117k
Hard savings / yr:   ~$7k (Snowflake backfill compute)
Soft savings / yr:   ~$54k (~30% platform-engineer time)
Break-even:          ~25 months on hard + soft; ~15 years on hard-only
Recommendation:      GO — the soft savings are real; the pain point is
                     acute; the on-call cost is under-reported

2. Which stack? — 5-question decision tree
==========================================
Q1 Python-first?    yes  → not Kestra
Q2 dbt-heavy?       yes  → Dagster (winner)
Q5 Managed-only?    yes  → Dagster+ Serverless Standard

3. Migration plan — 6 months, team-by-team
==========================================
Enter fullscreen mode Exit fullscreen mode
# Migration sprint plan (illustrative)
sprints = [
    {"n": 1, "team": "platform", "goal": "Dagster+ prod up; RBAC via Okta; Vault secrets"},
    {"n": 2, "team": "ingest",   "goal": "5 pilot DAGs → assets; per-team code server"},
    {"n": 3, "team": "ingest",   "goal": "remaining 15 DAGs → assets; retire Airflow"},
    {"n": 4, "team": "analytics","goal": "40 dbt models via @dbt_assets factory"},
    {"n": 5, "team": "finance",  "goal": "8 DAGs → assets; per-team code server"},
    {"n": 6, "team": "marketing","goal": "10 DAGs → assets; retire Airflow"},
]

for s in sprints:
    print(f"Sprint {s['n']}: {s['team']:12s}{s['goal']}")
Enter fullscreen mode Exit fullscreen mode
4. Budget-overrun escape hatch (50% over)
=========================================
Trigger:      If we're 50% over budget at sprint 4 checkpoint (~$175k spent
              vs planned $117k), we stop new team migrations and hold at
              partial cut-over.

Steady state: Ingest + analytics on Dagster; finance + marketing stay on
              Airflow. Two orchestrators in production; extra ~15% on-call
              overhead; net not a disaster.

Recovery:     Finish finance + marketing migrations in next fiscal year
              once other priorities clear.

Rollback:     Not applicable — assets already ported are in Dagster; we
              don't rewrite back to Airflow. We just delay the remaining
              two teams.
Enter fullscreen mode Exit fullscreen mode

Step-by-step trace.

Sprint Team Milestone Airflow coverage Dagster coverage
1 platform Dagster+ prod up 100% shadow
2 ingest 5 pilot assets 95% 5 assets
3 ingest 20 assets 80% 20 assets
4 analytics 40 dbt models 40% 60 assets
5 finance 8 assets 30% 68 assets
6 marketing 10 assets 0% 78 assets

After the migration, all 4 teams live in Dagster with per-team code-server deployments; the shared-DAG-folder pain is gone; per-partition backfills save Snowflake compute; Dagster+ Insights gives per-team cost + freshness dashboards. If the migration runs 50% over budget, the escape hatch pauses at 2-of-4 teams migrated; the CTO isn't surprised because the escape hatch was named on day 1.

Output:

Metric Airflow (before) Dagster (after)
Teams 4 sharing DAG folder 4 with own code servers
Deploy blast radius all teams one team
On-call incidents / mo ~6 (mostly deploys) ~1
Backfill compute waste ~$600 / mo ~$0
Platform-engineer time ~30% on Airflow ~10% on Dagster
Migration duration 6 months (or 9 if 50% over)
Break-even (hard + soft) ~25 months

Why this works — concept by concept:

  • Honest ROI framing — separating hard savings (compute) from soft savings (platform-engineer time) is the CFO-friendly way to defend a migration. Hard-only break-even at 15 years is a non-starter; hard + soft break-even at 25 months is defensible.
  • 5-question decision tree — Q1 (Python-first) → yes; Q2 (dbt-heavy) → Dagster. The tree lands the pick in seconds; the CTO conversation is then about migration cost, not about stack selection.
  • 6-month sprint plan — team-by-team migration with per-team code servers matches Dagster's architecture. Sprint 1 stands up shared infra; sprints 2–6 migrate one team at a time. Each team has a clear "retire Airflow" milestone.
  • Budget-overrun escape hatch — naming the escape hatch on day 1 pre-empts the "what if it goes over?" panic later. The escape hatch is "hold at partial cut-over"; two orchestrators in prod for a quarter is a manageable steady state, not a rollback.
  • Cost — $117k one-time migration + $1.8k / mo Dagster+ Serverless (~$22k / yr) - $770 / mo MWAA saved (~$9k / yr) - $600 / mo Snowflake backfill saved (~$7k / yr) - $54k / yr platform-engineer time saved = ~$47k / yr net saving after year 1 amortisation. Net O(teams migrated per sprint) with a clean escape hatch versus the alternative of accepting the shared-DAG-folder pain indefinitely.

SQL
Topic — sql
SQL orchestrator migration and asset-lineage problems

Practice →

Design
Topic — design
Design problems on orchestrator migrations

Practice →


Cheat sheet — orchestrator comparison recipes

  • Which stack when — one-liner per stack. Airflow is the Python task-DAG incumbent — 2,000 providers, three cloud managed tiers, Airflow 3.0's rebuilt scheduler, Cosmos-based dbt, the safe default when nothing screams "specialised choice." Dagster is the asset-graph analytics-engineering choice — @dbt_assets factory, per-partition backfills, per-team code servers, Dagster+ Insights for cost + freshness per asset. Prefect is the flow-plus-work-pool choice — @flow decorator, work pools separate flow from infra, cleanest ergonomics for many-small-pipelines teams shipping across K8s + ECS + serverless. Kestra is the YAML-first polyglot choice — Kafka-backed executor, first-class Kafka triggers, namespaces + tenants, correct pick when SQL / Bash / Python / Node all coexist. Mage is the notebook-block choice — data-app UI with block previews, shortest AE on-ramp, correct pick for small teams (≤ 10 pipelines) that value notebook-native DX over Python-DSL rigidity.
  • Airflow K8sPodOperator DAG snippet. from airflow.providers.cncf.kubernetes.operators.pod import KubernetesPodOperator; KubernetesPodOperator(task_id='heavy_transform', image='acme/heavy:v3', cmds=['python','/app/run.py'], namespace='airflow-workers', resources={'requests':{'cpu':'4','memory':'16Gi'}}, queue='kubernetes') inside a @dag(schedule='@hourly', catchup=False, default_args={'retries': 3, 'retry_delay': timedelta(minutes=5), 'retry_exponential_backoff': True}) — routes to K8sExecutor via the queue argument; light tasks route to Celery.
  • Dagster software-defined asset snippet. from dagster import asset, HourlyPartitionsDefinition, RetryPolicy, Backoff; hourly = HourlyPartitionsDefinition(start_date='2026-01-01-00:00'); @asset(partitions_def=hourly, retry_policy=RetryPolicy(max_retries=3, delay=300, backoff=Backoff.EXPONENTIAL), key_prefix=['raw','ingest'], group_name='team_ingest', compute_kind='python') def raw_orders(context): ... — one function, one asset, one partition per hour, RBAC-ready via key_prefix and group_name. Add @dbt_assets(manifest=DBT/'target'/'manifest.json') for the dbt factory.
  • Prefect work-pool + flow snippet. from prefect import flow, task; @task(retries=3, retry_delay_seconds=[60,300,900]) def extract(hour: str) -> list[dict]: ...; @flow(name='orders-hourly') def orders_hourly(hour: str): load(clean(extract(hour))) — deployed via orders_hourly.to_deployment(name='orders-hourly-k8s', work_pool_name='k8s-hourly', cron='0 * * * *'). Work pool created once via prefect work-pool create --type kubernetes k8s-hourly --base-job-template k8s-hourly-template.json; worker started via prefect worker start --pool k8s-hourly.
  • Kestra YAML flow snippet. id: orders_hourly; namespace: prod.orders; triggers: - id: hourly, type: io.kestra.plugin.core.trigger.Schedule, cron: '0 * * * *'; tasks: - id: extract, type: io.kestra.plugin.jdbc.postgresql.Query, sql: 'SELECT * FROM orders WHERE updated_at >= {{ trigger.previous }}'; - id: transform, type: io.kestra.plugin.scripts.python.Script, inputFiles: {raw.jsonl: '{{ outputs.extract.uri }}'}, script: '...'; - id: load, type: io.kestra.plugin.snowflake.Query, sql: 'COPY INTO raw.orders FROM @stage/{{ outputs.transform.uri }}' — polyglot in one YAML flow, secrets via {{ secret('KEY') }}, error handler as a top-level errors: block.
  • Mage block snippet. # data_loaders/load_orders.py — @data_loader def load_incremental(*args, **kwargs) -> pd.DataFrame: with Postgres.with_config(ConfigFileLoader('io_config.yaml','default')) as loader: return loader.load('SELECT * FROM orders WHERE updated_at >= now() - INTERVAL 1 HOUR') plus # transformers/clean.py — @transformer def clean(rows: pd.DataFrame, *args, **kwargs) -> pd.DataFrame: ... plus # data_exporters/load_snowflake.py — @data_exporter def export(rows: pd.DataFrame, *args, **kwargs) -> None: ... — wired in the Mage UI via metadata.yaml auto-generation, block previews on every run.
  • 5-question decision tree — memorise the order. (1) Python-first team? — no → Kestra. (2) Asset lineage / dbt-heavy? — yes → Dagster. (3) Work-pool / hybrid infrastructure? — yes → Prefect. (4) Notebook-friendly / block UI for AEs? — yes → Mage. (5) Anything else / incumbent / broadest ecosystem? — → Airflow. Q5 (managed-only?) is orthogonal — every stack has a managed tier. Walking the tree end-to-end in under 60 seconds is the interview signal that separates staff from senior.
  • 5-stack × 7-axis feature matrix (illustrative 1-5 scores). Axes: dynamic tasks / partitions + backfills / RBAC + secrets / dbt integration / streaming / K8s native / managed tier. Airflow: 4, 3, 4, 4, 2, 4, 4 = 25. Dagster: 4, 5, 5, 5, 4, 5, 5 = 33. Prefect: 5, 2, 4, 3, 3, 4, 4 = 25. Kestra: 3, 2, 4, 2, 5, 4, 4 = 24. Mage: 3, 2, 3, 3, 3, 3, 3 = 20. Adjust weights per workload — dbt-heavy: weight dbt integration 2×; polyglot: weight streaming + polyglot 2×; small AE team: weight notebook UI 2×. The winner emerges from the workload's actual weights, not from the raw sum.
  • Migration cost cheat sheet in engineer-months. Airflow → Dagster ≈ 6 months for a mid-sized platform (20–50 DAGs, 3–5 teams); rewrite tasks as assets, adopt @dbt_assets factory, stand up per-team code servers. Airflow → Prefect ≈ 4 months; port @task decorators, migrate operator wraps to Python tasks, set up work pools. Mage → Airflow ≈ 8 weeks when team outgrows Mage (~30+ pipelines); port each Mage pipeline's blocks into a DAG file. Dagster → Airflow ≈ 6 months but rarely done (loses per-partition rematerialise). Kestra → any Python stack ≈ full rewrite (~6+ months). Never propose migration without naming the engineer-month cost.
  • Managed-tier pricing 2026 (published rates, illustrative). AWS MWAA: mw1.small $0.49/hr, mw1.medium $1.05/hr (~$770/mo), mw1.large $2.02/hr, mw1.xlarge $3.87/hr. Astronomer Hosted: Standard ~$2k/mo, Enterprise custom. Google Composer: ~$0.75/hr + workers. Dagster+ Serverless: Standard ~$0.03 per material step; Pro ~$0.05 per step; Enterprise custom. Prefect Cloud: Starter ~$1k/mo (10 seats), Team ~$3k/mo, Enterprise custom. Kestra Cloud: Team ~$500/mo (10 seats), Enterprise custom. Mage Pro: Starter ~$300/mo, Team ~$1k/mo, Enterprise custom. Always model your volume against these to get an honest monthly figure before recommending.
  • RBAC + secrets contract every enterprise deployment needs. Vault-backed secrets injected at runtime (never stored in the orchestrator UI); SSO via Okta / Azure AD with group-to-role mapping; per-team namespace or code-location boundary; audit log of every trigger / edit / delete; per-team resource quotas (concurrent runs, storage, compute). Airflow: FAB roles + Vault secrets backend. Dagster+ Enterprise: RBAC on code locations + Vault via K8s / sidecar. Prefect Cloud: workspace + project + role + Vault via Blocks. Kestra Enterprise: tenant + namespace + role + secret() expression. Mage Pro: role + per-pipeline permissions + Mage Vault.
  • Dagster + dbt integration recipe. pip install dagster-dbt; run dbt parse to generate target/manifest.json; @dbt_assets(manifest=DBT/'target'/'manifest.json', dagster_dbt_translator=DagsterDbtTranslator(), select='tag:daily') factory generates one Dagster asset per dbt model. Each asset ships with lineage inferred from the dbt DAG, materialisation events, freshness policies, and (Dagster+ Insights) cost per asset per partition. The select= argument accepts any dbt selection syntax (tag:daily, +downstream, staging.*). Backfill via dagster asset backfill --select 'marts/finance/fct_revenue' --partition-range '2026-06-21..2026-07-20' rematerialises 30 partitions of one asset with upstream skipped.
  • Airflow → Dagster port pattern. For each Airflow @task, become a Dagster @asset where the asset key = the table / file / model the task produces; the asset's ins= map = the upstream Airflow tasks. Snapshotted tables become unpartitioned assets; hourly / daily Airflow DAGs become HourlyPartitionsDefinition / DailyPartitionsDefinition assets. Airflow sensors become Dagster sensors (@sensor). Airflow XCom becomes Dagster IO managers (typed asset outputs). One Airflow DAG typically maps onto 3–8 Dagster assets. Port one team at a time (per-team code server); keep both stacks live for 4–8 weeks per team.
  • Interview one-liner + rebuttal per stack. Airflow — "15-year Python task-DAG incumbent with 2,000 providers and three cloud managed tiers"; rebuttal to "isn't it old?": "the age is the ecosystem's asset; MWAA / Astronomer / Composer / Airflow 3.0 keep it modern." Dagster — "asset-graph orchestrator with per-partition backfills and native dbt asset factory"; rebuttal to "isn't SDA overkill?": "only when data isn't the primary product; for dbt-heavy platforms it's the right noun and saves 40–60% backfill compute." Prefect — "Python task graph with work-pool separation of flow from infra"; rebuttal to "isn't it just Airflow-with-nice-decorators?": "the work-pool model is qualitatively different; hybrid-infra teams benefit most." Kestra — "YAML-first polyglot with Kafka-backed executor and namespace RBAC"; rebuttal to "isn't YAML too rigid?": "only if your team is Python-mono; for polyglot organisations YAML is the lingua franca." Mage — "block DAG with data-app UI and notebook-native DX"; rebuttal to "doesn't it fall over at scale?": "the scale ceiling is real (~30 pipelines) but it's the correct choice for the small-team AE-heavy use case."
  • When to run all five in the same organisation. Almost never — the operational cost of running multiple orchestrators (per-stack expertise, per-stack on-call, per-stack RBAC integration) dwarfs the workload-specific benefit at 90% of organisations. Exceptions: (a) very large orgs with genuinely independent business units (finance on Airflow, product analytics on Dagster, marketing on Kestra — each unit self-supports); (b) M&A situations where the acquired company's stack is left running until end-of-life; (c) research / experimental teams running Mage or Prefect alongside the enterprise Airflow. Default to one primary orchestrator + one specialised for a narrow use case; consolidate on the primary unless the specialised use case is worth the tax.

Frequently asked questions

What is a data pipeline orchestrator in one sentence?

A data pipeline orchestrator is a control-plane service that decides when to run data-processing tasks (scheduling), what to run (dependency graph), where to run (executor + worker), and what happened (metadata + lineage + observability) so that a team can compose complex multi-step pipelines — extract, transform, load, notify, retry, backfill — declaratively rather than by writing bespoke cron jobs and glue scripts. In 2026 the five canonical stacks are Airflow (Python task DAG, the incumbent), Dagster (Python asset graph with SDA), Prefect (Python task graph with work pools), Kestra (YAML-first polyglot with Kafka executor), and Mage (block DAG with data-app UI). Every stack ships a scheduler + a work queue + workers + a metadata plane; what they call each box and how they package the boxes differs, and that difference is the load-bearing platform decision most data teams make.

Airflow vs Dagster — how do I pick?

Pick Airflow when the team already has Airflow muscle memory, the workload is dominated by integrations across many source and sink systems (Airflow's 2,000-provider ecosystem is unmatched), the managed-tier choice matters (MWAA / Astronomer / Google Composer / Azure Data Factory Managed Airflow give you three cloud options), and the pipeline product is fundamentally task-driven ("run this, then that, then notify"). Pick Dagster when the workload is dominated by dbt models and downstream analytics assets, when per-partition backfills are a real cost concern (Dagster's rematerialise skips upstream; Airflow's DAG-run backfill re-runs everything), when per-team code isolation matters (Dagster's per-team code servers eliminate the shared-DAG-folder problem), and when the pipeline product is fundamentally data-driven ("these tables must exist"). In greenfield analytics engineering with 40+ dbt models, Dagster wins nine times out of ten; in incumbent Airflow platforms with mixed integration + analytics work, staying on Airflow + Cosmos is the honest answer unless the per-team pain is acute.

Should I migrate off Airflow in 2026?

Only if you have a specific pain point that a migration solves. Airflow 3.0 (2025) landed with a rebuilt scheduler, a new task SDK, DAG versioning, and Datasets → Assets renaming; the incumbent has kept up. Reasons to migrate: (a) shared-DAG-folder deploys restart other teams' schedulers (Dagster's per-team code servers fix this); (b) DAG-run-level backfills waste 40–60% of Snowflake compute on downstream reruns (Dagster's per-partition rematerialise fixes this); (c) work-pool separation of flow from infrastructure matters (Prefect's work pools fix this); (d) polyglot teams where YAML is the lingua franca (Kestra fixes this). Reasons not to migrate: (a) "Dagster / Prefect / Kestra is newer and cooler" — insufficient; migration is 4–6 engineer-months; (b) "we heard Airflow is dying" — false, it retains the largest market share; (c) "our on-call is painful" — sometimes the fix is per-team K8sPodOperator isolation, not a migration. Model the honest 12- and 24-month ROI including migration engineering ($117k for a mid-sized platform), OPEX delta, hard savings (compute), and soft savings (platform-engineer time). If break-even is beyond 24 months on hard savings alone and there's no specific compliance / capability trigger, defer the migration.

What is a Software-Defined Asset (SDA)?

A Software-Defined Asset (SDA) is Dagster's foundational concept: instead of declaring tasks (units of compute) and their dependencies, you declare assets (units of data — tables, files, ML models) and their data dependencies, and let the orchestrator figure out which computation to run to keep the assets fresh. An SDA is a Python function decorated with @asset; the function's return value defines what data should exist; the function's arguments (ins=) define which upstream assets it depends on. The orchestrator infers the graph from the assets and computes execution order automatically. The pivot is philosophical — task-graph orchestrators ask "which tasks should run?"; asset-graph orchestrators ask "which data should exist?" — but it cascades into radically different retries (asset-partition-level, not task-run-level), backfills (rematerialise selected partitions of one asset without re-running upstream), lineage (native, at asset granularity, no separate OpenLineage plumbing), and observability (asset freshness, asset health, asset cost per Dagster+ Insights). The SDA model is the reason Dagster owns the analytics-engineering-plus-dbt niche in 2026 and the reason greenfield teams often pick Dagster over the incumbent Airflow.

Do I still need Kubernetes for orchestration in 2026?

Not for small workloads; increasingly yes for anything mid-scale. All five orchestrators run on Kubernetes as their prod deployment target — Airflow via the official Helm chart + K8sExecutor / K8sPodOperator, Dagster via K8sRunLauncher + per-team code-server deployments, Prefect via Kubernetes work pools, Kestra via Helm with executor + worker + Kafka + Postgres deployments, Mage via a Helm chart with backend + workers. Kubernetes buys you per-run resource isolation (one Job per run so a memory leak in one task doesn't OOM the whole worker), horizontal auto-scaling (KEDA on Celery queue depth for Airflow, Kubernetes HPA on worker CPU for others), and a standard deployment surface across cloud providers. The alternatives — process workers, ECS Fargate, Docker Compose, single-VM installs — work at the tens-of-pipelines scale and become fragile beyond that. Serverless alternatives (Prefect's push-based serverless work pools targeting ECS / Cloud Run) push the "no K8s" line further, and managed-tier offerings (MWAA, Dagster+ Serverless, Prefect Cloud, Kestra Cloud, Mage Pro) hide the K8s layer entirely. For most 2026 teams the honest answer is: you don't need to see Kubernetes if you buy the managed tier, but you almost certainly need it underneath the orchestrator at anything past small-team scale.

What are the interview questions on orchestrator comparison in 2026?

The senior orchestrator interview probes five families of questions. Stack selection — "How would you build a data platform for a new team?" (expects: name the stack in minute one; defend it on authoring persona + lineage model + execution architecture + managed tier). Comparison — "Rank Airflow / Dagster / Prefect / Kestra / Mage on dbt integration / streaming / RBAC / managed tier / K8s-native." (expects: named ranking with justification per axis; Dagster > Airflow > Prefect > Mage > Kestra for dbt, Kestra > Mage > Dagster > Prefect > Airflow for streaming). Migration — "You inherit an Airflow deployment; would you migrate?" (expects: honest ROI framing, hard + soft savings separation, 4–6 engineer-month cost, break-even at 18–25 months). Architecture — "What's the prod topology?" (expects: named components — scheduler + queue + workers + metadata — per stack, K8s manifests or Helm values, HA story, autoscaling story). Interview one-liners — "Give me a 15-second pitch for each stack." (expects: rehearsed one-liner + rebuttal per stack). Bonus probes for very senior roles: SDA vs task DAG philosophical distinction, per-partition backfill compute savings, Airflow 3.0 / Prefect 3.0 / Dagster 1.x version-history awareness, work-pool vs executor architecture, and the "when would you not use an orchestrator" question (event-driven pipelines under 5 steps; cron jobs on a single VM; managed serverless workflows via Step Functions / Cloud Workflows). Rehearse the 5-minute stack-selection monologue against your default stack, port the template by swapping specifics, and you'll land any question in this family.

Practice on PipeCode

  • Drill the SQL practice library → for the orchestrator scheduling, watermark-advance, and asset-materialisation SQL problems senior orchestrator interviewers actually load into their evaluation sets.
  • Rehearse on the design practice library → for the orchestrator selection, migration ROI, and multi-team platform-topology scenarios that separate architects from implementers.
  • Sharpen the streaming axis with the streaming practice library → for the Kafka-backed executor, delta-sync scheduling, and stream-aware trigger patterns that ship in production Kestra + Dagster deployments.
  • Stack the prerequisites against PipeCode's broader 450+ data-engineering catalogue to anchor the 5-question decision tree against real graded inputs — feature matrices, migration plans, cost defences, and interview one-liners per stack.

Lock in orchestrator comparison muscle memory

Docs explain features. PipeCode drills explain the decision — when Dagster's asset graph earns its migration cost, when Airflow's incumbent ecosystem beats every challenger, when Prefect's work-pool separation matters, when Kestra's polyglot YAML wins over Python, when Mage's block UI is the correct small-team choice. Pipecode.ai is Leetcode for Data Engineering — pattern-first practice tuned for the production trade-offs senior data engineers actually face.

Practice SQL problems →
Practice design problems →

Top comments (0)