Orchestration with Dagster
Software-defined assets, partitions and multi-tenant scale
Why orchestration and why Dagster
After this you can position orchestration as the platform control plane and justify Dagster against the alternatives.
Orchestration is the layer that decides what runs, in what order, when and what to do when a step fails. On a lakehouse processing billions of events a day, it is the platform's nervous system.
Strip the buzzwords and an orchestrator does four things across the whole pipeline DAG: it sequences work so a transform never reads a half-written table, it schedules on a cadence or in response to events, it retries and recovers when a step dies and it observes every run so you know what is fresh, stale or broken. Without one you get a graveyard of cron jobs that nobody can reason about.
Dagster's distinctive bet is that you declare the data, not just the steps. In a task-centric tool like Airflow you write operators that run in an order you wired by hand; the framework has no idea those tasks produce a bronze_events table or that gold_daily_active depends on it. Dagster inverts this with software-defined assets: you declare the table or file each function produces and what it reads and the dependency graph is the data graph.
- Concern
- Unit you declare
- Task-centric (Airflow)
- A task / operator to run
- Asset-centric (Dagster)
- An asset (a table or file) to produce
- Concern
- Dependencies express
- Task-centric (Airflow)
- Run order between tasks
- Asset-centric (Dagster)
- Data lineage between objects
- Concern
- “What is stale?”
- Task-centric (Airflow)
- You compute it yourself
- Asset-centric (Dagster)
- Derived from the asset graph
- Concern
- Selective rerun
- Task-centric (Airflow)
- Manual task selection
- Asset-centric (Dagster)
- Materialize one asset + its dependents
- Concern
- Local dev / typing
- Task-centric (Airflow)
- Bolted on
- Asset-centric (Dagster)
- First-class Python, typed I/O
| Concern | Task-centric (Airflow) | Asset-centric (Dagster) |
|---|---|---|
| Unit you declare | A task / operator to run | An asset (a table or file) to produce |
| Dependencies express | Run order between tasks | Data lineage between objects |
| “What is stale?” | You compute it yourself | Derived from the asset graph |
| Selective rerun | Manual task selection | Materialize one asset + its dependents |
| Local dev / typing | Bolted on | First-class Python, typed I/O |
The shift from “what ran” to “what exists” is the whole pitch - and it maps cleanly onto a medallion lakehouse.
Interactive diagram. Tab through its regions; each focused region shows its detail in the panel below.
The same lakehouse pipeline, declared two ways - step the rows to feel why asset-centric wins here.
That fit with a lakehouse is why this matters for Cursor. Your assets span Databricks Spark jobs, dbt models and ingestion connectors and an asset graph gives you one control plane with lineage across all of them. You can stand in front of gold_retention and trace it back through silver to the raw Kafka topic without reading five repos.
Dagster vs the fieldbe ready to defend a choice, not recite features
Ubiquitous, huge connector ecosystem
Task-centric: order, not data
Lineage and data-awareness are add-ons
Asset-centric, lineage + staleness built in
Strong local dev, typing, partitions
Control plane that delegates compute out
Native to the lakehouse, zero extra infra
Weaker cross-tool DAG and dev ergonomics
Locks orchestration inside one vendor
The JD names Dagster as a strong plus and cites scaling Dagster as an example project, so expect a direct “why Dagster over Airflow?” Answer with the asset model and a trade-off, not a feature list: “Airflow is fine if all I need is ordered tasks, but on a lakehouse I want lineage and staleness as first-class properties and I want one graph spanning Spark, dbt and connectors. Dagster gives me that. The cost is a smaller ecosystem and a team that has to learn the asset mental model.”
Do not pitch Dagster as a Spark replacement. It is a control plane, not a compute engine. If you imply Dagster will crunch billions of rows itself you will lose the room - the heavy lifting belongs on Databricks and Dagster orchestrates it.
Takeaway. An orchestrator sequences, schedules, retries and observes the pipeline; Dagster's edge is declaring assets (the data) rather than tasks (the steps), which gives lineage and staleness for free across a lakehouse.
Self-check
QWhat is the core conceptual difference between Dagster's model and a task-centric orchestrator like Airflow?
Software-defined assets & dependencies
After this you can model a medallion pipeline as assets with declared dependencies, I/O managers and inline checks.
An asset is a persisted data object plus the function that produces it and a declaration of what it reads. Once you think in assets, the pipeline stops being a pile of scripts and becomes a graph you can query.
The mechanics are small. You decorate a function with @asset, name its upstream dependencies as parameters and return the data. Dagster records that this function produces, say, silver_events and consumes bronze_events. Materializing an asset means running its function and persisting the result.
from dagster import asset
@asset
def bronze_events(context) -> None:
# land raw product telemetry as-is into Delta (schema-on-read)
...
@asset(deps=[bronze_events])
def silver_events(context) -> None:
# clean, dedupe, enforce schema; one row per real event
...
@asset(deps=[silver_events])
def gold_daily_active(context) -> None:
# business aggregate read by BI and reverse-ETL
...Because the edges are declared, the asset graph hands you three things you would otherwise build by hand: end-to-end lineage, selective materialization (rerun silver_events and everything downstream, nothing else) and a computed answer to “what is stale?” when an upstream changes.
Medallion layers map straight onto assetsthe dependency edges are the data flow
- Layer
- Bronze
- Asset role
- Raw landing, append-only
- Typical transform
- Ingest events / CDC as-is, minimal parsing
- Layer
- Silver
- Asset role
- Cleaned, conformed, deduped
- Typical transform
- Schema enforcement, dedupe, type casting, joins
- Layer
- Gold
- Asset role
- Business-ready aggregates
- Typical transform
- Metrics, dimensional models, feature tables
| Layer | Asset role | Typical transform |
|---|---|---|
| Bronze | Raw landing, append-only | Ingest events / CDC as-is, minimal parsing |
| Silver | Cleaned, conformed, deduped | Schema enforcement, dedupe, type casting, joins |
| Gold | Business-ready aggregates | Metrics, dimensional models, feature tables |
Each layer is one (or many) assets; the arrows between them are exactly the deps you declare.
Interactive diagram. Tab through its regions; each focused region shows its detail in the panel below.
Each step is an @asset; the gate is the asset check that stops bad data before gold reads it.
I/O managers: decouple compute from storagewhere data lands is config, not code
An I/O manager handles reading inputs and writing outputs so the asset function never hard-codes a path or a format. Your silver_events logic computes a DataFrame; a Delta I/O manager decides it gets written to a specific Delta table with the right options. Swap the manager and the same logic writes somewhere else, which keeps test, staging and prod clean.
- Compute logic stays pure: transform inputs, return outputs.
- Storage details (Delta path, partition columns, write mode) live in the I/O manager config.
- Different environments bind different managers - local Parquet in tests, governed Delta in prod.
Asset checks: quality at materialization timefail the run, not the dashboard
An asset check is a data-quality assertion attached to an asset and evaluated when it materializes: not-null on a key, row count within a band, no duplicate event ids. A failing check can block downstream materialization, so bad data stops at silver instead of surfacing as a wrong number in a gold dashboard three hops later.
from dagster import asset_check, AssetCheckResult
@asset_check(asset=silver_events)
def no_duplicate_event_ids(context) -> AssetCheckResult:
dupes = count_duplicate_ids("silver_events")
return AssetCheckResult(
passed=dupes == 0,
metadata={"duplicate_ids": dupes},
)When the design deep dive asks where you put data-quality checks, name asset checks at the silver boundary and explain the blocking behavior: “I assert uniqueness and freshness on silver before gold reads it and I let the check failure block downstream materialization. That turns a silent wrong-number incident into a loud, scoped run failure with the bad metric attached.”
Takeaway. Declare each table as an @asset with its upstream deps and you get lineage, selective rerun and staleness for free; I/O managers keep storage out of your logic and asset checks stop bad data at the layer boundary.
Self-check
QAn asset check on silver_events fails (duplicate event ids found). What is the desired effect on the pipeline?
Partitions, backfills and incremental processing
After this you can operate large partitioned asset graphs correctly and reprocess history without recomputing everything.
At billions of events a day you never reprocess a whole table on a whim. You slice it into partitions and touch only the slices that changed.
A partition is a named slice of an asset. Dagster gives you three flavors and choosing the right one is most of the battle.
- Partition type
- Time-window
- Keyed by
- A date/hour bucket
- Example use
- Hourly bronze ingest, daily gold rollups
- Partition type
- Static
- Keyed by
- A fixed known set
- Example use
- Per-region or per-product-line tables
- Partition type
- Dynamic
- Keyed by
- A set discovered at runtime
- Example use
- Per-customer or per-new-source partitions
| Partition type | Keyed by | Example use |
|---|---|---|
| Time-window | A date/hour bucket | Hourly bronze ingest, daily gold rollups |
| Static | A fixed known set | Per-region or per-product-line tables |
| Dynamic | A set discovered at runtime | Per-customer or per-new-source partitions |
Time-window partitions carry most telemetry pipelines; static and dynamic cover the fan-out cases.
Partitions turn “process the table” into “materialize 2026-06-15, hour 14.” That single shift is what makes incremental materialization possible - each run computes one slice, so you never recompute billions of historical rows to add today's data.
Backfills: replaying history safelythe move you make after a logic or schema change
A backfill re-materializes a range of historical partitions, typically after you fix a bug, change a transform or evolve a schema. The non-negotiable property is idempotency: re-running a partition must overwrite that slice cleanly, not append duplicates. Get this wrong and a backfill doubles your data.
- 1Scope the range. Identify exactly which partitions the change affects - a date window, a region, a set of customers - not the whole asset.
- 2Confirm idempotent writes. Each partition must overwrite-by-partition (Delta
replaceWhereor a partition-scoped overwrite), never blind append. - 3Stage and verify. Run a small slice first, diff row counts and key metrics against the old output before committing the full range.
- 4Throttle concurrency. Launch the backfill with capped parallelism so it does not starve the daily production runs sharing the cluster.
- 5Watch downstream. Let the asset graph re-materialize dependents of the backfilled slices and confirm gold metrics move as expected.
Partition mapping across grainshourly bronze feeding daily gold
Upstream and downstream assets often live at different grains. A partition mapping tells Dagster how a downstream partition depends on upstream ones - a single daily gold partition depends on the 24 hourly bronze partitions for that day. Declare the mapping and a backfill of one gold day pulls exactly the right 24 upstream slices, with no manual bookkeeping.
The expensive mistake is an accidental full-graph rerun. A non-partitioned asset wedged between partitioned ones or a check that touches the whole table, can force Dagster to recompute everything on every run. At PB scale that is a five-figure cluster bill from one careless edge. Keep the graph partitioned end to end and verify what a run actually selects before you launch it.
Cost control on this platform is mostly one discipline: never recompute a partition that did not change. Partitions plus partition mappings plus idempotent writes are the machinery that lets you enforce it. If you can explain how a logic change to one transform results in re-materializing only the affected slices and their dependents, you have demonstrated the cost instinct the role screens for.
Takeaway. Partition assets so each run touches one slice; make writes idempotent (overwrite-by-partition) so backfills replay history without duplicates and use partition mappings to bridge grains like hourly bronze to daily gold.
Self-check
QWhy is idempotency the critical property of a backfill and how do you achieve it on Delta?
Sensors, schedules and event-driven runs
After this you can trigger work reliably from time and events and express freshness SLAs declaratively.
Something has to pull the trigger. Dagster gives you two: schedules for the clock, sensors for the world.
- Trigger
- Schedule
- Fires on
- A cron-like cadence
- Use it for
- Predictable batch: hourly ingest, nightly rollups
- Trigger
- Sensor
- Fires on
- An external signal it polls for
- Use it for
- New files in S3, upstream freshness, a queue message
- Trigger
- Auto-materialize
- Fires on
- Upstream changing / policy
- Use it for
- Keep an asset current when its inputs move
| Trigger | Fires on | Use it for |
|---|---|---|
| Schedule | A cron-like cadence | Predictable batch: hourly ingest, nightly rollups |
| Sensor | An external signal it polls for | New files in S3, upstream freshness, a queue message |
| Auto-materialize | Upstream changing / policy | Keep an asset current when its inputs move |
Schedules answer “when?”; sensors answer “did something happen?”; auto-materialization answers “is this stale?”
A schedule is the familiar case: run bronze_events at the top of every hour. A sensor is the event-driven one - it polls a source on an interval and launches a run when it sees a new file land, an upstream asset go fresh or an external system signal readiness. Sensors are how you stop polling-by-cron-and-hope and start reacting to actual data arrival.
Freshness as a declared SLAstop writing alerting glue by hand
A freshness policy (and the freshness checks that enforce it) lets you state an SLA on the asset itself: “gold_daily_active must be no more than 90 minutes behind its data.” Dagster evaluates it and flags the asset as overdue, so the SLA lives next to the definition instead of in a separate monitoring system that drifts out of sync.
Auto-materialization closes the loop: declare a policy and Dagster materializes an asset when its upstreams change or its freshness window is about to lapse, without a hand-written schedule for every node. You describe the desired state of the graph and let the system keep it true.
Protecting shared computeone backfill must not take down the cluster
- Retries with backoff so a transient Spark or network blip self-heals instead of paging someone.
- A run queue and concurrency limits so a flood of triggered runs does not all hit Databricks at once.
- Concurrency pools that cap how many runs touch a given resource (a cluster, a rate-limited API) in parallel.
- Idempotent assets, so a retried run is safe to replay (this is why the partition discipline from the last section pays off here).
“I replace fragile cron jobs with an observable, dependency-aware control plane. Schedules cover predictable cadence, sensors react to data actually arriving and freshness policies make the SLA a declared property of the asset. When something runs late, I see which asset is overdue and why, instead of discovering it from a stakeholder asking why the dashboard is stale.”
Sensors poll, so a chatty sensor on a tight interval against a slow source becomes its own load problem and can miss or double-fire if it is not cursor-based. Track a cursor so each tick only considers new work and tune the interval to the source - not every sensor should run every five seconds.
Takeaway. Schedules trigger on the clock, sensors trigger on events and freshness policies turn SLAs into declared properties of the asset; retries, run queues and concurrency limits keep triggered work from overwhelming shared compute.
Self-check
QWhen would you reach for a sensor instead of a schedule and what is the operational gotcha with sensors?
Scaling Dagster for many technical users
After this you can design the multi-tenant orchestration the JD names as an example project: isolation, fairness, self-serve and observability.
The JD's example project is scaling Dagster for a growing population of technical users. That is a multi-tenant platform problem and it is the design they will hand you.
As the data team grows, one shared bag of definitions stops working. Teams step on each other's deploys, one team's backfill starves another's daily run and onboarding a new engineer means pulling you into the loop. The fix is structure on four axes.
A code location per team or domain
Each deploys independently, owns its assets
A bad deploy is blast-radius-limited to one location
Run concurrency limits + a run queue
Concurrency pools per shared resource (cluster, API)
One team's backfill cannot starve another's SLA run
Scaffolding + conventions for new assets
Guardrails: required checks, naming, partition defaults
The team adds assets without you in the loop
Run health, asset freshness, lineage in one UI
Alerting on failed/overdue assets
A growing audience can self-diagnose
Code locations isolate ownership and deploysthe multi-tenant primitive
A code location is an independently loaded set of Dagster definitions, usually one per team or domain. It gives each team its own deploy cycle and its own slice of the asset graph, while a single Dagster instance stitches them into one global view. A syntax error or a bad dependency in one location does not take down everyone else's pipelines.
The control-plane / compute splitthe single most important scaling decision
Run Dagster as the control plane and delegate heavy compute to Databricks and Spark. Dagster decides what to run, tracks lineage and freshness, enforces concurrency and submits work; the Spark cluster does the actual byte-crunching. If you try to execute billions of rows inside Dagster's own process you will fall over and you will also have coupled orchestration to compute so neither scales independently.
Interactive diagram. Tab through its regions; each focused region shows its detail in the panel below.
Top to bottom: what users touch, what the control plane owns and where the bytes actually move.
- Concern
- Decides what runs
- Dagster (control plane)
- Yes - asset graph, partitions, triggers
- Databricks / Spark (compute)
- No
- Concern
- Tracks lineage / freshness
- Dagster (control plane)
- Yes
- Databricks / Spark (compute)
- No
- Concern
- Enforces concurrency / queue
- Dagster (control plane)
- Yes
- Databricks / Spark (compute)
- Cluster-level only
- Concern
- Crunches the data
- Dagster (control plane)
- No - submits the job
- Databricks / Spark (compute)
- Yes - the shuffle, joins, writes
- Concern
- Scales by
- Dagster (control plane)
- Adding code locations / capacity
- Databricks / Spark (compute)
- Cluster sizing, autoscaling, Photon
| Concern | Dagster (control plane) | Databricks / Spark (compute) |
|---|---|---|
| Decides what runs | Yes - asset graph, partitions, triggers | No |
| Tracks lineage / freshness | Yes | No |
| Enforces concurrency / queue | Yes | Cluster-level only |
| Crunches the data | No - submits the job | Yes - the shuffle, joins, writes |
| Scales by | Adding code locations / capacity | Cluster sizing, autoscaling, Photon |
Keep the two layers separate so orchestration scale and compute scale are independent decisions.
Self-serve is the multiplierturning recurring pain into a platform primitive
The role is judged on converting recurring data challenges into self-serve, scalable solutions. For orchestration that means scaffolding a new asset is a templated, guard-railed action: a generator that wires the I/O manager, requires an owner and a freshness policy and enforces a default partition scheme. The data team ships assets that are correct-by-construction and you are not the bottleneck on every new pipeline.
When you get the scaling prompt, structure the answer on these four axes - isolation, fairness, self-serve, observability - and lead with the control-plane / compute split. Then make it concrete to Cursor: “Code location per data-team pod, a concurrency pool sized to our Databricks budget so backfills can't starve SLA runs, a scaffolding CLI that bakes in checks and freshness and the Dagster UI as the single place anyone can see run health and lineage. Dagster orchestrates; Databricks computes.”
Self-serve without guardrails is how you get an accidental full-graph backfill that bankrupts the month's compute budget. Free-for-all is not self-serve. The platform must make the cheap, correct path the default - partitioned, idempotent, concurrency-capped - and make the dangerous path require explicit intent.
Takeaway. Scale Dagster on four axes - code locations for isolation, concurrency pools for fairness, scaffolding for self-serve and one UI for observability - and always keep Dagster as the control plane that delegates heavy compute to Databricks.
Self-check
QWhen scaling Dagster for many teams, why is it essential to keep Dagster as a control plane and delegate compute to Databricks/Spark?