Migrating From Prefect to Dagster for Spatial Pipelines

The migration that works is not a rewrite; it is an extraction followed by a series of small cutovers. Pull the spatial work out of the task decorators into plain functions, wrap those in Dagster assets, seed the partition record from your existing ledger, and move one layer at a time with both control planes running. Every step leaves a working pipeline, and any of them can be the last one if you change your mind.

When to Use This Pattern

  • The ledger has become the largest part of the codebase, which is the usual signal that the model is fighting the work.
  • Backfills happen weekly and each one needs a hand-written manifest filter.
  • Non-engineers operate the pipeline and need a partition view rather than a run list.
  • The grid is stable — a service area, a national pyramid — rather than decided per run.

Complete Working Example

Step one is the one that does the real work, and it involves neither tool. The spatial logic moves out of the task body into a function that knows nothing about orchestration:

# domain/tiles.py — no orchestrator imports anywhere in this module.
from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class TileResult:
    path: str
    valid_fraction: float
    seconds: float
    source_digest: str


def build_tile(tile: Tile, source: SourceSpec, dest_root: str) -> TileResult:
    """Everything that was inside the @task body, and nothing else."""
    window = fetch_window(source, tile)
    stats = warp_and_write(window, tile, dest_root)
    return TileResult(stats.path, stats.valid_fraction, stats.seconds, source.digest)

Both orchestrators then become thin wrappers. The Prefect one shrinks to a decorator plus the ledger it already maintained:

@task(retries=2, timeout_seconds=900, tags=["tile"])
def build_tile_task(tile: Tile, source: SourceSpec, dest: str) -> TileResult:
    key = work_key(tile, source.digest, RECIPE)
    if ledger_has(key):
        return ledger_get(key)
    result = build_tile(tile, source, dest)          # the extracted function
    ledger_mark(key, tile, result)
    return result

and the Dagster one calls the same function, with the materialisation replacing the ledger write:

import dagster as dg

TILES = dg.StaticPartitionsDefinition(
    [f"{z}_{x}_{y}" for z, x, y in tiles_intersecting(SERVICE_AREA_WKT, 12)]
)


@dg.asset(partitions_def=TILES, pool="scene-reads",
          retry_policy=dg.RetryPolicy(max_retries=2, delay=15))
def warped_tile(context: dg.AssetExecutionContext) -> dg.MaterializeResult:
    z, x, y = (int(v) for v in context.partition_key.split("_"))
    result = build_tile(Tile(z, x, y), SOURCE, DEST)   # the same function
    return dg.MaterializeResult(metadata={
        "path": dg.MetadataValue.path(result.path),
        "valid_px_pct": dg.MetadataValue.float(result.valid_fraction * 100),
        "source_digest": dg.MetadataValue.text(result.source_digest),
    })

Seeding the partition record from the existing ledger is what avoids rebuilding everything on the first Dagster run:

def seed_materialisations(instance: dg.DagsterInstance, rows) -> int:
    """One AssetMaterialization per ledger row, so day one is not a full backfill."""
    for row in rows:                                   # SELECT z, x, y, ... FROM tile_ledger
        instance.report_runless_asset_event(
            dg.AssetMaterialization(
                asset_key=dg.AssetKey("warped_tile"),
                partition=f"{row.z}_{row.x}_{row.y}",
                metadata={"seeded_from": "tile_ledger",
                          "source_digest": dg.MetadataValue.text(row.source_digest)},
            )
        )
    return len(rows)
Five steps, each one reversibleExtract the domain code, stand up the new control plane, seed the record from the ledger, cut over one layer, then decommission. Every step ends with a working pipeline.1 extractplain functions2 stand upside by side3 seedfrom the ledger4 cut overone layer5Step 1 is most of the value and is worth doing whether or not the migration continues: it is the step thatmakes the pipeline testable without a control plane.Steps 4 and 5 repeat per layer, so a rollback is one layer wide rather than the whole product.
Nothing here is a flag day. If step four goes badly for one layer, that layer moves back and the others carry on.

Seeding deserves emphasis because skipping it is the commonest way a migration becomes expensive. Without seeded materialisations, Dagster’s view of the world on day one is that nothing exists, so the first scheduled run treats a fully-built national pyramid as a complete backfill. That is days of compute and a very large bill for information you already had in a table. The seeding script is thirty lines and turns the cutover into a no-op run.

Parameter & Option Reference

Step Reversible? Spatial notes
Extract domain functions yes, trivially No orchestrator imports. This is the step that makes the code testable in either world.
Stand up the new control plane yes Point it at a separate database. Sharing one with the old plane saves nothing and complicates rollback.
Seed materialisations yes, they can be deleted Map ledger rows to partition keys exactly; a mismatch here becomes a phantom backfill.
Cut over one layer yes, per layer Disable the old schedule, enable the new one. Keep the old deployment for a fortnight.
Dual-write the ledger during transition Keep writing the old ledger for one cycle so a rollback has current data.
Decommission no Only after a full cycle including a backfill and a real recovery.
What skipping the seeding step costsWithout seeded materialisations the first run rebuilds the entire pyramid. With seeding it rebuilds only what has genuinely changed.first run after cutover — 180 000 tiles already builtnot seeded180 000 rebuilt — 3 days, and the outputs are identicalseeded1 400 rebuilt — the tiles that actually changedThe rebuilt tiles are byte-identical to the ones already there, so nothing downstream improves.The only observable effect is the bill and three days of saturated concurrency limits.
A migration that produces a large unexplained rebuild tends to be remembered as the migration that went wrong, whatever else it achieved.

Verification & Testing

The tests that matter compare the two implementations rather than testing either alone.

def test_domain_function_has_no_orchestrator_imports() -> None:
    src = Path("domain/tiles.py").read_text()
    assert "prefect" not in src and "dagster" not in src


def test_both_wrappers_produce_identical_output(tmp_path) -> None:
    a = run_prefect_tile(TILE, dest=tmp_path / "p")
    b = run_dagster_tile(TILE, dest=tmp_path / "d")
    assert sha256_of(a) == sha256_of(b), "the wrappers are not calling the same code"


def test_seeding_maps_every_ledger_row(db, instance) -> None:
    rows = db.all("SELECT z, x, y, source_digest FROM tile_ledger WHERE layer='ortho'")
    seed_materialisations(instance, rows)
    seen = instance.get_materialized_partitions(dg.AssetKey("warped_tile"))
    assert seen == {f"{r.z}_{r.x}_{r.y}" for r in rows}


def test_partition_keys_match_the_ledger_shape(db) -> None:
    # A single mismatched key format is a full backfill nobody asked for.
    sample = db.one("SELECT z, x, y FROM tile_ledger LIMIT 1")
    assert f"{sample.z}_{sample.x}_{sample.y}" in TILES.get_partition_keys()

The last test is small and prevents the most expensive failure in the list. A ledger storing 12/2147/1398 and a partition set keyed 12_2147_1398 will seed nothing at all, silently, because every reported partition falls outside the definition — and the first run then rebuilds the world. Asserting one real row against the real partition set costs a millisecond and catches the whole class.

The shape during the transitionBoth orchestrators call the same extracted domain functions and write the same outputs. Only the schedule decides which one is in charge of a given layer.Prefect deploymentlayers: roads, hydroDagster code locationlayers: orthodomain/tiles.pyone implementationobject storagesame paths, same keysBecause both write the same paths from the same code, a layer can move back without moving any data.
The single shared implementation is what makes each cutover a scheduling change rather than a deployment of new logic.

The dual-write window is worth planning rather than improvising. For one full cycle after a layer cuts over, keep writing the old ledger from the Dagster wrapper as well — a few extra lines in the asset body — so that rolling that layer back means re-enabling a schedule and nothing else. Without it, a rollback after three days means the old ledger is three days stale, and the Prefect deployment’s first run rebuilds everything it thinks is missing. That is the same phantom backfill the seeding step exists to prevent, arriving from the other direction and at a worse moment.

The other thing worth doing during the window is comparing the two records rather than trusting either. A nightly query that diffs the ledger’s built set against Dagster’s materialised partitions will surface a key-format mismatch, a missed seeding batch or a layer that is quietly being built by both planes — all of which are cheap to fix while both systems are running and awkward afterwards. The diff should be empty every night; the day it is not is the day the migration told you something.

Common Pitfalls

  • Rewriting the spatial code during the migration. Two variables change at once and any output difference becomes unattributable. Extract first, migrate second.
  • Skipping the seeding step. The first run becomes a full backfill of work already done.
  • Mismatched partition key format. Seeding silently does nothing and the same full backfill follows.
  • Cutting over every layer at once. A rollback then means the whole product, at the worst possible moment.
  • Decommissioning early. Keep the old deployment until the new one has survived a backfill and a real recovery, not merely a week of green runs.
  • Sharing one database between the control planes. It saves nothing and makes a clean rollback impossible.

Frequently Asked Questions

How long does this take for a real pipeline?

The extraction is the bulk of it — a week or two for a substantial codebase, and it is worth doing regardless. Each layer’s cutover after that is a day, most of which is watching. Teams that report month-long migrations have usually combined it with a rewrite.

Can I keep the ledger?

Yes, and during the transition you should, dual-written. Afterwards it is redundant for completeness but often still useful for spatial coverage queries, since Dagster’s record is keyed by partition string rather than geometry. Plenty of pipelines keep a slimmed-down ledger indefinitely for exactly that.

What about in-flight runs during a cutover?

Let them finish. Disable the old schedule, wait for the last run to complete, then enable the new one. Overlapping the two produces two writers for one layer, which is safe if the writes are idempotent and confusing regardless.

Which layer should move first?

The least important one that is still representative. A layer nobody notices for a day is the wrong first choice if it has no fan-out and no backfill history, because it exercises none of the machinery that will actually be hard. Pick something with a real grid, real retries and a real schedule, but whose staleness for a day is an inconvenience rather than an incident — a derived product rather than a published basemap.

Does this work in the other direction?

The same five steps, and the third is easier — Prefect has no record to seed, so the new ledger starts by scanning the output store. Migrating away from Dagster is rarer, and when it happens it is usually about operational weight rather than the model.

Should the migration change the partitioning?

No. Change one thing at a time. A migration that also re-partitions from z12 to z10 cannot tell a modelling problem from a partitioning problem when something looks wrong, and something always looks wrong on the first backfill.

Prefect vs Dagster for GIS Workloads