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.
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
- Why the orchestrator choice matters in 2026
- Task-graph vs asset-graph mental models
- Execution architecture comparison
- Feature matrix
- Decision tree + migration paths + interview signals
- Cheat sheet — orchestrator comparison recipes
- Frequently asked questions
- Practice on PipeCode
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
@flowdecorator. 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()
# 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")
# 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
# 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 }}"
# 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
Step-by-step explanation.
- 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.
- 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. - Dagster's
@assetis the SDA declaration — you say "this asset should exist and it depends onraw_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. - Prefect 3.0's
@flow+@tasklooks 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. - 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. - Mage's block model is the notebook-friendly variant — each
@data_loader/@transformer/@data_exporterlives 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. - 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."
Step-by-step explanation.
- 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.
- 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. - 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.
- 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.
- 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')
Step-by-step explanation.
- 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.
- 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.
- 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.
- 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.
- 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
# 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()
# 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)})
# 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
# 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.
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
@assetdeclares "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 dbtmanifest.jsonand 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
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.
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_customerlast 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_revenuedepends ondim_customerandfct_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_customerlast materialise?"), asset health ("did the last 24 partitions all succeed?"), asset cost (Dagster+ Insights: "how much didfct_revenuecost 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_orders→clean_orders→dim_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
# 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
Step-by-step explanation.
- 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 indefault_args; backfills are DAG-level (airflow dags backfill). - 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 offct_revenue, rematerialise"). - 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 usespartitions_def=DailyPartitionsDefinition(...)on each asset; the partition is first-class (context.partition_key), and partitions are queryable in the UI as a grid. -
dim_customeris unpartitioned in Dagster (a snapshot rebuilt each run) even thoughfct_revenueis daily-partitioned. The asset graph handles this cleanly —fct_revenue's partitions materialise against whichever version ofdim_customerexists at run time. In Airflow, mixing partitioned and unpartitioned upstreams requires manual dependency management or ExternalTaskSensor. - Backfilling 7 days: in Airflow,
airflow dags backfill revenue_pipeline -s 2026-07-14 -e 2026-07-20triggers 7 DAG runs. If onlyfct_revenueneeds backfilling butraw_ordersandclean_ordersare already fresh, Airflow re-runs them anyway (wasted compute). In Dagster, you select only thefct_revenueasset'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 }}" }
Step-by-step explanation.
- 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.
-
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. - 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 receivesraw.jsonlbecause the extract task'sfetchType: STOREwrites to Kestra's file store, andinputFiles:on the transform task references it. - The Python task includes
beforeCommands:forpip 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 thedocker:block. -
errors:defines a task that runs when any task in the flow fails — a Slack webhook here. This is Kestra's answer to Airflow'son_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'
"""
)
# 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"
# 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",
)
# 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"
Step-by-step explanation.
- 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 inmetadata.yamland rendered in the UI as a block canvas. Analytics engineers wire blocks visually; the YAML is auto-generated. - 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.
- Block-level tests (
test_no_nullsabove) 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. - Secrets and IO configuration live in
io_config.yaml(typically Git-ignored, populated per environment via env vars). Mage'sPostgres.with_config(config)andSnowflake.with_config(config)wrap the boilerplate; the block author sees only the SQL / DataFrame surface. - 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()
# 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})
# 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.
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 —
@assetdeclares "this data should exist and it depends on these assets." The graph is inferred from theins=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_revenueand its partition range; Dagster spawns 30 partition runs, each running only the downstream SQL for its partition. Upstreamstg_orders/dim_customerare 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 RBAC —
key_prefix=["marts", "finance"]andgroup_name="marts_finance"turn each asset into an RBAC-tagged entity. Dagster+ role grants target key prefixes, so team finance sees / edits onlymarts/finance/*assets; team marketing sees / edits onlymarts/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
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.
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'spip installdoesn'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.yamltriggers; 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
# 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()
Step-by-step explanation.
-
CeleryKubernetesExecutorgives you the best of both — small tasks on cheap Celery workers, heavy tasks on ephemeral K8s pods. Thequeue=argument on each task selects the routing. This is the canonical mid-scale prod topology. - 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.
- 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.
- 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.
-
gitSyncdeploys 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
# 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}]
# 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
Step-by-step explanation.
- The Dagster webserver + daemon are shared infrastructure — one deployment each. They serve the UI, schedule runs, and enqueue against the run coordinator.
QueuedRunCoordinatorlimits concurrent runs to 25 across all teams; adjust based on capacity. - 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 inworkspace.yamlto load the team's assets, jobs, schedules, and sensors. - 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.
-
K8sRunLauncherspawns 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 ownpip installs, and its own resource limits. -
postgresql.postgresqlHost: dagster-metadata.rds.internaluses 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")
# 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)
# 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
Step-by-step explanation.
- The flow definitions in
flows/orders.pyare pure Python — no infrastructure code. Prefect flows are just decorated functions; retries are configured on the tasks; parameters flow through function arguments. -
deploy.pybinds 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. -
prefect work-pool create --type kubernetes k8s-hourlycreates 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. -
prefect worker start --pool k8s-hourlystarts a worker process that subscribes to thek8s-hourlypool. 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. - 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]
# 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]
# 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
# 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]
# 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
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 installdoesn'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_limitsis the multi-tenant scheduler primitive Airflow'spoolmechanism 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
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.
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/EachParalleltasks 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 tological_datein 2.x). Backfills viaairflow 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
backfillexecution 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-dbtcollection withDbtCoreOperationtask; runs dbt commands as flow tasks. Adequate; no asset-level lineage. -
Kestra.
io.kestra.plugin.dbt.cli.DbtCLItask; 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 +
AutoMaterializepolicies 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",
)
# 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],
)
# 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
# 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
# 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
Step-by-step explanation.
- 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-dbtpackages predate it. - Dagster +
@dbt_assetsreadsmanifest.jsonand 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. - Prefect +
prefect-dbtrunsdbt buildas 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). - Kestra +
DbtCLIrunsdbt buildas 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. - 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
Step-by-step explanation.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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")
# 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/
# 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)
# 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 }}'"
# 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') }}"
Step-by-step explanation.
- 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. - 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 +
envFromon the code-server deployment or via a Vault Agent Injector sidecar. Cleaner than Airflow's per-DAG permission model for multi-team deployments. - 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. - Kestra Enterprise ships tenant + namespace + role. Namespaces enforce the RBAC boundary; a user with editor role on
prod.ingestcan edit flows in that namespace but not inprod.analytics.secret()expressions resolve through Vault at task runtime. - Mage Pro's RBAC is roles + per-pipeline permissions with Okta group sync. Secrets flow through
io_config.yamlreferencing 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
# 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",
}
# 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.
"""
# 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
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_assetsfactory 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
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.
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_assetsfactory; 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
@flowfunctions; 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
@taskdecorators; moveio_config.yamlcredentials 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.
Step-by-step explanation.
- 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.
- 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.
- 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.
- 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.
- 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}
Step-by-step explanation.
- 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.
- 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.
- Hard savings are the Snowflake backfill compute avoided by per-partition rematerialise (~$600/mo). Modest.
- 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.
- 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_assetsfactory; 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."
Step-by-step explanation.
- 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.
- 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.
- 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.
- 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.
- 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
==========================================
# 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']}")
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.
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
Design
Topic — design
Design problems on orchestrator migrations
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_assetsfactory, per-partition backfills, per-team code servers, Dagster+ Insights for cost + freshness per asset. Prefect is the flow-plus-work-pool choice —@flowdecorator, 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 thequeueargument; 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 viakey_prefixandgroup_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 viaorders_hourly.to_deployment(name='orders-hourly-k8s', work_pool_name='k8s-hourly', cron='0 * * * *'). Work pool created once viaprefect work-pool create --type kubernetes k8s-hourly --base-job-template k8s-hourly-template.json; worker started viaprefect 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-levelerrors: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 viametadata.yamlauto-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_assetsfactory, stand up per-team code servers. Airflow → Prefect ≈ 4 months; port@taskdecorators, 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; rundbt parseto generatetarget/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. Theselect=argument accepts any dbt selection syntax (tag:daily,+downstream,staging.*). Backfill viadagster 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@assetwhere the asset key = the table / file / model the task produces; the asset'sins=map = the upstream Airflow tasks. Snapshotted tables become unpartitioned assets; hourly / daily Airflow DAGs becomeHourlyPartitionsDefinition/DailyPartitionsDefinitionassets. 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.





Top comments (0)