Dynamic Task Mapping for LiDAR Tiles
TL;DR: In Airflow 2.3+ (and Airflow 3), a @task that returns the list of tile keys can feed process_tile.partial(out_prefix=...).expand(key=tiles), which creates one mapped task instance per tile at run time. Cap parallelism with a pool or max_active_tis_per_dag, and add a downstream @task that receives the list of mapped results to write a manifest.
# Context and Motivation
This guide is part of Airflow DAG Orchestration. Before dynamic task mapping, fanning out over LiDAR tiles in Airflow meant either generating a DAG with thousands of static tasks — slow to parse and impossible to change without redeploying — or hiding the loop inside a single task, which threw away Airflow’s per-task retries, logs and visibility. Dynamic mapping solves both: the DAG stays small, the tile list is decided when the run starts, and each tile still becomes a first-class task instance with its own state, retries and log.
It is the natural shape for recurring LiDAR processing: a nightly run that picks up newly delivered tiles, or an on-demand run over a project’s tile index.
# Prerequisites and Assumptions
- Airflow 2.3 or newer (dynamic task mapping), including Airflow 3.x.
- Workers — Celery, Kubernetes executor or a container-running operator — with PDAL available, or tasks that launch PDAL in containers.
- Tiles in object storage and an index or listing to enumerate them.
# Step-by-Step Implementation
# Step 1 — List tiles in a task
A @task returns a list of tile keys, for example from an S3 prefix or a tile index filtered to new deliveries.
# Step 2 — Define the per-tile task
A @task taking one key runs the PDAL pipeline and returns a small dict.
# Step 3 — Expand
process_tile.partial(out_prefix=...).expand(key=list_tiles()) — partial fixes shared arguments, expand maps over the list.
# Step 4 — Limit concurrency
Assign the task to a pool sized to the worker capacity, and set max_active_tis_per_dag on the mapped task so one run cannot occupy every slot.
# Step 5 — Reduce
A downstream @task receiving the mapped output as a list writes the manifest; set trigger_rule="all_done" so it runs even if some tiles failed.
# Complete Working Example
"""Airflow DAG: one mapped task per LiDAR tile, with a manifest at the end."""
from __future__ import annotations
import json
from datetime import datetime
import boto3
from airflow.decorators import dag, task
from airflow.utils.trigger_rule import TriggerRule
BUCKET_IN, BUCKET_OUT = "lidar-in", "lidar-out"
@dag(schedule="@daily", start_date=datetime(2026, 1, 1), catchup=False,
max_active_runs=1, tags=["lidar"])
def lidar_dtm_daily():
@task
def list_tiles(prefix: str = "deliveries/2026/") -> list[str]:
s3 = boto3.client("s3")
keys = []
for page in s3.get_paginator("list_objects_v2").paginate(Bucket=BUCKET_IN, Prefix=prefix):
keys += [o["Key"] for o in page.get("Contents", []) if o["Key"].endswith(".laz")]
return sorted(keys)
@task(pool="pdal_slots", max_active_tis_per_dag=64, retries=2)
def process_tile(key: str, out_prefix: str) -> dict:
import pdal
stem = key.rsplit("/", 1)[-1].removesuffix(".laz")
spec = {"pipeline": [
f"/vsis3/{BUCKET_IN}/{key}",
{"type": "filters.range", "limits": "Classification![7:7],Classification![18:18]"},
{"type": "filters.smrf", "slope": 0.15, "window": 18, "threshold": 0.5},
{"type": "filters.range", "limits": "Classification[2:2]"},
{"type": "writers.gdal", "filename": f"/vsis3/{BUCKET_OUT}/{out_prefix}/{stem}.tif",
"resolution": 1.0, "output_type": "idw", "window_size": 6, "data_type": "float32"},
]}
n = pdal.Pipeline(json.dumps(spec)).execute()
return {"tile": stem, "ground_points": n}
@task(trigger_rule=TriggerRule.ALL_DONE)
def write_manifest(results: list[dict | None]) -> int:
done = [r for r in results if r]
boto3.client("s3").put_object(Bucket=BUCKET_OUT, Key="dtm/manifest.json",
Body=json.dumps(done).encode())
return len(done)
results = process_tile.partial(out_prefix="dtm").expand(key=list_tiles())
write_manifest(results)
lidar_dtm_daily()Failed mapped instances contribute nothing to results, so the manifest lists successes only; failures are visible per instance in the grid view and can be cleared and retried individually, as described in retrying failed tiles in Airflow.
# Mapping Over Several Arguments
Sometimes each tile needs more than its key — a per-tile CRS override, a buffer geometry, or an output name that does not derive from the input. Two options exist. expand_kwargs maps over a list of dicts, so list_tiles can return [{"key": ..., "epsg": ...}, ...] and each instance receives its own keyword arguments. Alternatively, expand over several lists produces the cross product, which is almost never what you want for tiles — mapping key and epsg separately would pair every tile with every EPSG code. For tile work, build explicit records upstream and use expand_kwargs.
Keep the records small and serialisable. Put shapes and large per-tile metadata in object storage or a tile index, and pass only an identifier through XCom; the mapped task can then fetch what it needs at run time. This also keeps the rendered DAG grid readable, because each instance’s map index can be labelled with the tile name through the map_index_template option in Airflow 2.9 and later.
# Key Parameter Table
| Setting | Where | Typical | Purpose |
|---|---|---|---|
.partial(...) |
mapped task | shared args | Fixed across instances |
.expand(key=...) |
mapped task | list from upstream | One instance per element |
pool |
task | pdal_slots |
Limit by worker capacity |
max_active_tis_per_dag |
task | 32–128 | Cap per DAG across runs |
max_map_length |
airflow.cfg [core] |
default 1024 | Raise for large tile lists |
trigger_rule |
reduce task | all_done |
Run even if some tiles fail |
# Verification
- Mapped count. The grid view shows as many mapped instances as tiles listed; compare with the listing size.
- Manifest vs outputs. Manifest entries match the objects under the output prefix.
- Concurrency. During a run, running instances never exceed the pool size.
# Gotchas and Edge Cases
max_map_length. Airflow limits how many instances a single expand may create (1024 by default). Statewide tile lists exceed it; raise the setting, or batch tiles into groups and map over groups.
Large XCom payloads. The tile list and every mapped result pass through XCom, stored in the metadata database by default. Keep results tiny, and for very large lists store the list in object storage and pass its key.
Scheduler load. Tens of thousands of mapped instances per run stress the scheduler and database. Grouping tiles — for example 10 per task — keeps instance counts manageable while retaining useful granularity.
Heavy PDAL in Airflow workers. Running PDAL directly in Celery workers ties Airflow’s workers to PDAL’s dependencies. KubernetesPodOperator or AWS Batch operators, mapped the same way, keep Airflow light and PDAL in its own image.
# Frequently Asked Questions
What is dynamic task mapping in Airflow?
A feature, introduced in Airflow 2.3, that creates one task instance per element of a list produced at run time. For LiDAR, the list is the set of tiles to process, so each tile becomes its own task with retries and logs.
How do I limit how many tiles process at once?
Put the mapped task in an Airflow pool sized to your worker capacity, and set max_active_tis_per_dag on the task. Instances beyond the limit wait in the scheduled state.
What if I have more tiles than the maximum map length?
Raise max_map_length in the core configuration, or group tiles into batches and map over the batches. Grouping also reduces load on the scheduler and metadata database.
Can the reduce task run if some tiles fail?
Yes. Set its trigger rule to all_done. Failed mapped instances contribute no value to the collected results, so the reduce task sees only successful outputs.
# Related
- Airflow DAG Orchestration — Airflow for LiDAR pipelines
- Orchestrating PDAL Workflows with Airflow — the base DAG
- Retrying Failed Tiles in Airflow — handling failed instances
- Array Jobs for LiDAR Tiles in AWS Batch — fan-out inside AWS Batch
- Building a Tile Index with pdal tindex — where tile lists come from