Mapping Tasks Over a Tile Grid in Dagster

Dagster’s answer to a tile fan-out is not a loop but a partition set: declare the grid as partitions, write one asset that materialises a single tile, and let the framework keep the record of which tiles exist. That inversion is the whole point — a Prefect-style map produces N task runs and forgets them, whereas a partitioned asset produces N materialisations Dagster can query, so “which tiles are missing for last Tuesday” becomes a question the system answers rather than one you write a script for.

When to Use This Pattern

  • Output is a grid whose cells are addressable — a tile pyramid, a map-sheet series, a fixed set of municipalities.
  • Backfills are routine, and re-running only the gaps matters more than raw throughput.
  • Coverage is a question people ask, so the record of what exists needs to outlive the run that produced it.
  • Tiles and dates are independent axes, which is exactly what a multi-dimensional partition set models.

Complete Working Example

The grid is a static partition dimension, the schedule is a daily one, and the asset materialises the intersection of the two.

from __future__ import annotations

import dagster as dg

# The spatial axis. Enumerated once, from the footprint, so the partition set is
# the manifest — there is no second list to keep in step with it.
TILES = dg.StaticPartitionsDefinition(
    [f"{z}_{x}_{y}" for z, x, y in tiles_intersecting(SERVICE_AREA_WKT, zoom=12)]
)

GRID = dg.MultiPartitionsDefinition(
    {"date": dg.DailyPartitionsDefinition(start_date="2026-01-01"), "tile": TILES}
)


@dg.asset(
    partitions_def=GRID,
    # One tile at a time per source; the pool is what protects the upstream server.
    pool="scene-reads",
    retry_policy=dg.RetryPolicy(max_retries=2, delay=15, backoff=dg.Backoff.EXPONENTIAL),
    metadata={"unit": "tile"},
)
def warped_tile(context: dg.AssetExecutionContext) -> dg.MaterializeResult:
    keys = context.partition_key.keys_by_dimension
    z, x, y = (int(v) for v in keys["tile"].split("_"))
    dest = tile_path(keys["date"], z, x, y)

    stats = warp_window(z, x, y, on=keys["date"], dest=dest)
    context.log.info("tile %d/%d/%d built in %.1f s", z, x, y, stats.seconds)

    return dg.MaterializeResult(
        metadata={
            "path": dg.MetadataValue.path(str(dest)),
            "valid_px_pct": dg.MetadataValue.float(stats.valid_fraction * 100),
            "seconds": dg.MetadataValue.float(stats.seconds),
        }
    )


@dg.asset(partitions_def=GRID.get_partitions_def_for_dimension("date"), deps=[warped_tile])
def daily_mosaic(context: dg.AssetExecutionContext) -> dg.MaterializeResult:
    """One node depending on every tile of that date — the reduction."""
    present = list_built_tiles(context.partition_key)
    coverage = len(present) / len(TILES.get_partition_keys())
    if coverage < 0.995:
        raise dg.Failure(f"coverage {coverage:.3f} below threshold — not assembling")
    build_vrt_and_translate(present, mosaic_path(context.partition_key))
    return dg.MaterializeResult(metadata={"coverage": dg.MetadataValue.float(coverage)})
The partition grid is the recordDates run along one axis and tiles along the other. Materialised cells are filled; the gaps are what a backfill targets, and they are visible without running anything.date ↓ tile →08-0408-0508-0608-07materialised17 of 20 cellsmissingthe backfill targets theseThe 6th failed for three tiles. Dagster already knows which three, so the backfill is a selection rather than a full re-run.A map-and-forget fan-out would re-run all twenty to recover three.
The grid is not a picture of the run; it is the queryable state the run leaves behind, which is what makes a partial recovery cheap.

The metadata returned from each materialisation is worth attaching even when nothing reads it today. Dagster stores it against the partition, so valid_px_pct becomes a per-tile time series you can plot without instrumenting anything — a tile whose valid-pixel fraction drops from 0.98 to 0.4 on one date has a source problem, and that is visible in the asset catalogue rather than requiring a metric, a dashboard and an alert to have been set up in advance.

Parameter & Option Reference

Setting Value Spatial notes
StaticPartitionsDefinition tile keys Enumerate from the footprint. Keep it under a few thousand keys; beyond that the partition set becomes unwieldy and a coarser zoom is the right answer.
MultiPartitionsDefinition date × tile Two independent axes. A backfill can select a date, a tile, or a rectangle of both.
pool "scene-reads" Caps concurrent materialisations touching one upstream source, independently of how many runs are in flight.
RetryPolicy 2, exponential Per-partition. A failing tile retries without re-running its neighbours.
MaterializeResult metadata path, coverage Stored per partition, so it doubles as a per-tile time series.
deps=[warped_tile] asset dep Makes the reduction wait on the whole date’s tiles rather than merely following them.
What a partial failure costs each modelWith a mapped fan-out, recovering three failed tiles out of four hundred means re-running four hundred. With partitions, the backfill materialises three.recovering 3 failed tiles out of 400mapped fan-out400 tasks re-run — 52 minpartitioned asset3 materialisations — 24 sThe difference is not speed of execution. It is that one system recorded which units succeededand the other recorded that a run failed.
The gap widens with grid size, which is why the mapped form feels fine in testing and expensive in production.

Verification & Testing

Partition definitions are ordinary Python objects, so the grid can be tested without materialising anything.

import dagster as dg


def test_grid_covers_the_footprint() -> None:
    keys = set(TILES.get_partition_keys())
    expected = {f"12_{x}_{y}" for x, y in tiles_intersecting(SERVICE_AREA_WKT, 12)}
    assert keys == expected
    assert 500 < len(keys) < 5_000, "partition set is the wrong order of magnitude"


def test_one_tile_materialises(tmp_path) -> None:
    key = dg.MultiPartitionKey({"date": "2026-08-07", "tile": "12_2147_1398"})
    result = dg.materialize(
        [warped_tile], partition_key=key, resources={"scratch": tmp_path}
    )
    assert result.success
    meta = result.asset_materializations_for_node("warped_tile")[0].metadata
    assert meta["valid_px_pct"].value > 0


def test_reduction_refuses_an_incomplete_date(monkeypatch) -> None:
    monkeypatch.setattr("mymodule.list_built_tiles", lambda _: ["12_1_1"] * 10)
    with pytest.raises(dg.Failure, match="coverage"):
        dg.materialize([daily_mosaic], partition_key="2026-08-07")

The first test is the one that earns its place over time. A static partition set is computed from the footprint at import time, and footprints change — a service area gains a municipality, a coastline is re-cut. When that happens the partition set silently grows or shrinks, and every historical coverage figure changes meaning with it. Asserting the set against a freshly computed footprint makes the change fail in CI, where it can be reviewed, rather than appearing as an unexplained step in a coverage chart.

How large a partition set can usefully getA few hundred keys is comfortable. A few thousand is workable. Tens of thousands makes the asset catalogue and backfill selection unusable, and a coarser zoom is the fix.partition keyscataloguewhat to do< 500comfortablenothing500 to 5 000workableselect by rectangle, not by hand> 20 000unusablepartition a coarser zoom, tile insideThe bottom row is the common shape at high zoom: partition at z10 and let each materialisation build its 256 z14 children.
Partitioning by the deliverable rather than by the smallest unit keeps the catalogue legible and still gives a per-partition record.

There is a second, quieter reason to prefer partitions for anything that runs on a schedule. A mapped fan-out records its history as runs, and a run is a poor unit to ask questions of: “did we build the coastal tiles on the fourth” turns into a search through logs, because the run knows it processed four hundred items but not which of them. A partitioned asset records its history as cells, so the same question is a lookup. Over a year of nightly builds that difference compounds into the whole difference between a pipeline whose past can be inspected and one whose past has to be reconstructed from artefacts in object storage — and reconstruction is exactly the work nobody has time for during an incident.

The cost side is worth stating plainly, because partitions are not free. Every materialisation writes an event to Dagster’s storage, so a grid of four thousand tiles times three hundred and sixty-five days is a million and a half rows a year in the event log. That is a small database by any standard, but it is not nothing, and it argues for partitioning at the deliverable rather than at the smallest addressable output — the same conclusion the catalogue-usability argument reaches from the other direction.

Common Pitfalls

  • Partitioning at the output zoom. A z14 national grid is millions of keys; partition at z10 and build the children inside one materialisation instead.
  • Deriving the partition set from a hard-coded list. It drifts from the footprint, and the drift shows up as an unexplained coverage change months later.
  • Using a run-level concurrency limit as the source protection. Runs and materialisations are different counters; the pool is the one that bounds simultaneous reads of an upstream service.
  • Reducing with a plain dependency on the asset rather than the partitions. A date-partitioned reduction must actually wait on that date’s tiles, or it assembles whatever happens to exist.
  • Returning no metadata. The materialisation record then says only “it happened”, which throws away the cheapest per-tile observability available.
  • Treating a backfill as a re-run. Selecting the failed partitions is the whole advantage; a full backfill of a healthy grid is the mapped fan-out with extra steps.

Frequently Asked Questions

Is this better than Prefect's `map`?

Different, and better for grids that persist. Prefect’s map is the lighter tool when the fan-out is transient and the run is the unit people care about; Dagster’s partitions win when the grid is the unit people care about, because the record of which cells exist is maintained for you. Prefect vs Dagster for GIS workloads compares the two properly, and fanning out Prefect tasks over a tile manifest shows the other side.

How do dynamic partitions fit in?

Use DynamicPartitionsDefinition when the grid is discovered rather than declared — a set of delivered scene identifiers, for instance. Add keys from a sensor as deliveries arrive. For a fixed service area the static definition is preferable because the set is then reviewable in code.

What happens when the footprint changes?

Add or remove keys and let the CI assertion flag the diff. Historical materialisations for removed keys stay in the catalogue, which is correct: they happened. What changes is the denominator of any coverage figure, and that is precisely why the change should be visible in a pull request.

How does a sensor fit with a static grid?

The sensor watches for a new delivery and requests a run for the affected partitions rather than for the whole grid. That keeps the schedule declarative — the grid does not change — while making the work event-driven, and it is the shape most spatial pipelines end up wanting, because sources arrive when publishers feel like publishing rather than at a time anyone chose.

Can one materialisation write several tiles?

Yes, and it is the recommended shape at high zoom: one z10 partition materialises its 256 z14 children as a unit. The partition then names a deliverable rather than an output file, and the per-child detail belongs in the materialisation metadata.

DAG Design Principles for Spatial ETL