Dagster Asset Versioning & Staleness for ML Pipelines
code_version is a manually assigned string on the @asset decorator or AssetSpec. Dagster does not automatically hash your function source code.
import dagster as dg
@dg.asset(code_version="1.0.0")
def trained_model(features):
...There is an open feature request (dagster-io/dagster#15242) for automatic source-code hashing (e.g. CodeVersion.auto()), but it was not implemented. Dagster maintainers intentionally chose not to build this because:
- Simple
inspect.getsource()hashing is fragile — it misses imported functions, package version changes, and external dependencies. - False negatives: a real code change in an imported utility wouldn't bump the version.
- False positives: a cosmetic change (whitespace, comment) would invalidate all downstream assets.
We provide a utility module auto_code_version.py with three strategies of increasing coverage. Each trades off convenience against false-positive/false-negative risk.
Hashes raw function source via inspect.getsource() — the simplest approach.
from auto_code_version import auto_code_version
def _train_impl(features):
model = fit(features)
return model
@dg.asset(code_version=auto_code_version(_train_impl))
def trained_model(features):
return _train_impl(features)| Triggers a change | Does NOT trigger a change |
|---|---|
| Any edit to the function body | Changes to imported helpers (from mylib import preprocess) |
| Whitespace / comment changes (false positive) | Package version upgrades (sklearn 1.4→1.5) |
| Renamed variables | Environment variable / config changes |
| External schema changes |
Parses the function into an AST, strips docstrings and whitespace, then hashes the canonical tree. Avoids false positives from formatting-only changes.
from auto_code_version import auto_code_version_normalized
@dg.asset(code_version=auto_code_version_normalized(my_func))
def my_asset(): ...| Triggers a change | Does NOT trigger a change |
|---|---|
| Logic changes, new/removed statements | Whitespace, comment, docstring edits |
| Renamed variables | Changes to imported helpers |
| Changed constants/literals | Package upgrades |
Hashes the function plus explicitly listed dependency callables and an arbitrary salt string. This is the closest analog to ZenML's full cache key.
from auto_code_version import auto_code_version_deep
from mylib import preprocess, build_features
import sklearn
@dg.asset(
code_version=auto_code_version_deep(
_train_impl,
deps=[preprocess, build_features],
extra=sklearn.__version__,
)
)
def trained_model(features):
return _train_impl(features)| Triggers a change | Does NOT trigger a change |
|---|---|
| Edit to main function body | Unlisted helper changes (false negative) |
Edit to any listed deps function |
C-extension functions (falls back to repr()) |
Change in extra string (e.g. package version) |
Env vars, config, external schemas |
- Evaluated at import time — the hash is computed when the module loads, not at materialization time.
inspect.getsource()requires source files — won't work on functions defined in a REPL, compiled.pyc-only distributions, or C extensions.- Dynamic functions (e.g. generated inside a factory loop) may produce unstable hashes across interpreter restarts.
- For production, Dagster maintainers recommend manual
code_versionstrings (semver or date-based) as the most reliable approach — see dagster-io/dagster#15242 for the full rationale.
Dagster has two version concepts:
| Concept | Who Sets It | What It Represents |
|---|---|---|
code_version |
You (manually) | Version of the computation logic |
data_version |
Dagster (auto-generated) or you (via DataVersion) |
Fingerprint of the asset's output value |
Dagster auto-generates a data version (also called "logical version") by hashing together:
- The asset's
code_version - The
data_versionof each input (upstream asset)
This means: if neither your code nor your inputs changed, the data version stays the same, and Dagster knows the output would be identical — enabling caching/skip behavior.
An asset is marked Unsynced in the Dagster UI when any of these are true:
- Code version changed — the
code_versionon the asset definition differs from thecode_versionrecorded during its last materialization. - Dependencies changed — an upstream dependency was added or removed since the last materialization.
- Upstream data changed — a direct parent asset was re-materialized (producing a new data version) after the downstream asset's last materialization.
As of Dagster 1.8.0, Unsynced is NOT transitive. This is a deliberate design choice:
A (code_version changed → Unsynced)
└── B (direct child → Unsynced, because A was re-materialized after B)
└── C (grandchild → NOT Unsynced, until B is actually re-materialized)
- Only direct children of a changed/re-materialized asset show as Unsynced.
- Grandchildren and deeper descendants do not cascade to Unsynced automatically.
- This prevents "Unsynced label fatigue" across large graphs and improves UI performance.
The Dagster UI provides a "Materialize Unsynced" button that selects all assets currently labeled Unsynced and launches a materialization run for them. This is the manual workflow for selective re-computation:
- Change
code_versionon Step 4 (e.g."1.0.0"→"1.1.0") - Redeploy your code location
- Open the asset graph in the Dagster UI
- Step 4 shows the Unsynced label; Steps 1–3 do not
- Click "Materialize Unsynced" — only Step 4 runs
For external data sources (files, APIs, databases you don't control), use observable source assets to report data versions:
import dagster as dg
import hashlib
@dg.observable_source_asset
def raw_training_data():
"""Observe an external CSV file and report its data version."""
content = open("/data/training.csv", "rb").read()
version = hashlib.sha256(content).hexdigest()[:16]
return dg.DataVersion(version)- Click "Observe Sources" in the UI (or run via a sensor/schedule) to check for new data versions.
- If the returned
DataVersiondiffers from the last observation, downstream assets are marked Unsynced. - This is how Dagster detects upstream data changes for external sources without re-materializing them.
Declarative Automation isn't the only option. You can use traditional jobs + schedules with the stale_assets_only parameter on RunRequest:
import dagster as dg
ml_pipeline_job = dg.define_asset_job(
"ml_pipeline_job",
selection=[raw_data, features, trained_model, evaluation],
)
@dg.schedule(
cron_schedule="0 2 * * *", # Daily at 2 AM
job=ml_pipeline_job,
)
def nightly_ml_refresh():
"""Only re-materialize assets that are actually stale."""
return dg.RunRequest(stale_assets_only=True)- The schedule fires on the cron tick as usual.
- Dagster evaluates which assets in the job's selection are stale (Unsynced).
- Only stale assets are included in the run. Non-stale assets are skipped entirely.
- If no assets are stale, no run is launched.
- If passed without an asset selection, all stale assets in the job are materialized.
For a full ML pipeline with external data detection:
@dg.observable_source_asset
def training_data_source():
version = compute_hash_of_external_data()
return dg.DataVersion(version)
@dg.asset(code_version="1.0.0")
def features(training_data_source): ...
@dg.asset(code_version="1.0.0")
def trained_model(features): ...
@dg.asset(code_version="1.0.0")
def evaluation(trained_model): ...
# Observe sources on a schedule, then materialize stale downstream
observe_job = dg.define_asset_job(
"observe_sources", selection=dg.AssetSelection.all_asset_checks()
)
ml_job = dg.define_asset_job(
"ml_pipeline", selection=[features, trained_model, evaluation]
)
@dg.schedule(cron_schedule="0 1 * * *", job=observe_job)
def observe_schedule():
return dg.RunRequest()
@dg.schedule(cron_schedule="0 2 * * *", job=ml_job)
def ml_schedule():
return dg.RunRequest(stale_assets_only=True)ZenML:
Run pipeline →
Step 1: cache HIT (same code + same inputs) → skip, reuse output
Step 2: cache HIT → skip, reuse output
Step 3: cache HIT → skip, reuse output
Step 4: cache MISS (source code hash changed) → execute
All four steps are attempted but three are skipped via cache lookup. The pipeline run still appears with all four steps, three marked "cached."
Dagster (with stale_assets_only):
Schedule fires →
Dagster checks staleness: only Step 4 is Unsynced
Run is launched with ONLY Step 4 in the selection
Steps 1–3 are not part of the run at all
Steps 1–3 never enter a run. The run contains only Step 4.
The gap between Dagster and ZenML boils down to two missing defaults:
- No auto code hashing — you must set
code_versionmanually - No auto propagation — the Unsynced label doesn't cascade transitively
We can close both gaps with the @cached_asset decorator (see auto_code_version.py), which combines auto-hashed code_version with AutomationCondition.eager():
import dagster as dg
@dg.asset(code_version="1.0.0") # must remember to bump
def features(raw_data): ...
@dg.asset(code_version="1.0.0")
def trained_model(features): ...
@dg.asset(code_version="1.1.0") # forgot to bump? silent staleness
def evaluation(trained_model): ...from auto_code_version import cached_asset
@cached_asset
def features(raw_data):
return engineer_features(raw_data)
@cached_asset
def trained_model(features):
return fit_model(features)
@cached_asset
def evaluation(trained_model):
return evaluate(trained_model)That's it. No manual version strings. Under the hood, each asset gets:
code_version= AST-normalized hash of the function sourceautomation_condition=AutomationCondition.eager()
| ZenML behavior | How @cached_asset replicates it |
|---|---|
| Code change → cache miss | Source hash changes → code_version changes → asset marked Unsynced |
| Upstream reruns → downstream cache miss | eager() fires when any upstream re-materializes → transitive propagation |
| No change → skip | Code hash unchanged + no upstream update → asset stays synced, not executed |
| Zero config | Just @cached_asset — no version strings to manage |
The Unsynced UI label is non-transitive (stops at direct children). But eager() Declarative Automation IS effectively transitive because it works in a chain:
A changes → A re-materializes
→ B sees upstream update → eager() fires → B re-materializes
→ C sees upstream update → eager() fires → C re-materializes
Each hop takes ~30 seconds (the automation sensor evaluation interval), so a 4-step pipeline cascades in ~90 seconds. This is slower than ZenML (which resolves within a single run), but the result is the same: only changed assets and their true downstream dependents re-execute.
from auto_code_version import cached_asset
from mylib import preprocess, build_features
import sklearn
@cached_asset(deps=[preprocess, build_features], extra=sklearn.__version__)
def trained_model(features):
preprocessed = preprocess(features)
X = build_features(preprocessed)
return sklearn.ensemble.RandomForestClassifier().fit(X, y)Now changes to preprocess, build_features, or an sklearn upgrade all trigger re-materialization.
- Enable the automation sensor: Toggle on
default_automation_condition_sensorin the Dagster UI under Automation → Sensors. Without this,eager()conditions are never evaluated. - Assets must be in the same code location for same-run grouping. Cross-location assets cascade via separate runs.
Even with @cached_asset, Dagster still differs from ZenML in these ways:
| Gap | Why it exists | Workaround |
|---|---|---|
| Parameters not in hash | Dagster code_version is static at import time; ZenML hashes runtime params |
Pass config values in the extra salt |
| ~30s delay per hop | eager() evaluates on a sensor tick interval |
Acceptable for ML pipelines (training takes minutes/hours anyway) |
inspect.getsource limitations |
Can't hash C extensions, dynamic code | Use extra for package __version__ strings |
| No artifact-store-aware isolation | ZenML scopes cache to workspace + artifact store | Dagster's Unsynced is scoped to the instance; use separate deployments for isolation |
- Asset Versioning and Caching — Dagster Docs
- Unsynced Status Propagation Discussion (dagster-io/dagster#25248)
- Auto Code Versioning Feature Request (dagster-io/dagster#15242)
- Materialize Stale Assets on Schedule (dagster-io/dagster#14755)
- Support Rematerialization of Stale Assets in Schedules (dagster-io/dagster#10726)
- Partitioned Assets and code_version (dagster-io/dagster#22704)
- Declarative Scheduling Blog Post