Async Execution for Heavy GIS Tasks

In short: asyncio is the right tool for the parts of a spatial pipeline that wait — fetching four hundred tiles, calling a geocoder, listing an object store — and the wrong tool for the parts that compute. geopandas, shapely and GDAL do their work in C, and a single overlay() call blocks the whole event loop for as long as it runs. The design question is not “async or not” but “where is the boundary between the waiting and the computing, and what carries work across it”.

Most spatial pipelines are a mixture: an I/O-heavy fetch phase followed by a CPU-heavy transform phase, repeated per partition. Running the whole thing synchronously wastes most of the fetch phase; running the whole thing asynchronously means the transform phase blocks every concurrent fetch and the pipeline is slower than the synchronous version. Getting real benefit means being explicit about which phase is which.

It is worth naming why this comes up so often in geospatial work specifically. Spatial pipelines sit at an awkward intersection: they are far more I/O-heavy than a typical analytics job, because the data lives in remote tiles, scenes and services rather than in one warehouse; and they are far more CPU-heavy than a typical web backend, because every byte fetched is then resampled, reprojected or intersected. Advice written for either world alone lands badly here, which is how pipelines end up either fetching four hundred tiles one at a time or running every reprojection on the event loop.

Prerequisites & Architecture Baseline

Core Principles

1. Async buys concurrency on waiting, never on computing. A hundred concurrent HTTP fetches on one thread is a large win because ninety-nine of them are idle at any moment. A hundred concurrent shapely.union calls on one thread is exactly as slow as doing them one after another, plus scheduling overhead. The event loop interleaves waiting, not work.

2. Any blocking call inside a coroutine blocks everything. This is the failure that produces the most confusion, because nothing errors: the pipeline simply runs at synchronous speed while looking asynchronous. A gpd.read_file() in the middle of an async function stalls every other in-flight request for its whole duration, and the symptom is a throughput graph that flatlines regardless of how high you set the concurrency.

3. Push CPU work to processes, not threads. The GIL means a thread pool gives no parallelism for pure-Python work. GDAL and shapely release the GIL during their C calls, so threads do help there — but unevenly, and a process pool is the predictable choice. run_in_executor with a ProcessPoolExecutor is the standard bridge, and offloading GDAL work to a process pool covers its details.

4. Bound concurrency per resource, not globally. One semaphore for “the pipeline” is meaningless when a task touches three services with three different limits. Give each endpoint its own semaphore sized from its published capacity, and let a task acquire all the ones it needs. This is the same idea that makes exponential backoff work: the limit belongs to the resource.

5. Geometry objects cross process boundaries badly. Pickling a GeoDataFrame to a worker process and back can cost more than the computation. Pass file paths and ranges rather than objects, and let each process read what it needs — the same discipline that makes ETL chains use files between steps.

6. Async complicates cancellation, so decide early. A cancelled coroutine raises CancelledError at its next await point, which is clean — unless it is blocked in a C call, in which case it is not cancellable at all. That interacts directly with timeout budgets and cancellation, and it is another argument for keeping heavy work out of process.

Where each execution model actually helpsSynchronous execution serialises fetch and transform for every tile. Async overlaps the fetches but serialises the transforms. Async fetches with a process pool overlaps both.SYNCHRONOUS — 8 tiles, one after another…4 moreASYNC FETCH ONLY — waiting overlaps, computing does not8 fetchesASYNC FETCH + PROCESS POOL — both overlap8 fetches4 workers × 2 roundsBlue = waiting on the network · amber = computing on the event loop · green = computing in a separate process
The middle row is where most pipelines stop, and it is where the disappointment comes from: the fetches got faster and the total barely moved, because the transforms were always the larger half.

The middle row of that chart also explains a common and misleading benchmark result. Teams frequently test an async rewrite on a small extent, where the tiles are few and the transforms are quick, and see a large speed-up — because at that size the pipeline really is dominated by network round trips. Scaled up to a national run, the transform time grows with the data while the fetch time grows more slowly, the ratio inverts, and the same code delivers a few per cent. Benchmark the rewrite on the largest partition you actually run, not on the one that fits in a test fixture.

The I/O share shrinks as the partition growsFor a small test extent, waiting is seventy per cent of runtime. For a national run it is twelve per cent, with computing taking the rest.partitionwaiting (async helps here)computingtest extent70%one municipality40%one region23%national12%An async rewrite validated on the top row is optimising twelve per cent of the bottom row.
Nothing here says async is wrong — it says the measurement has to come from the workload you actually run, because the ratio it depends on is not scale-invariant.

Production Implementation

The flow below fetches concurrently with a per-endpoint semaphore, then hands the CPU-bound work to a process pool. The two phases are visibly separate, which is the point.

from __future__ import annotations

import asyncio
from concurrent.futures import ProcessPoolExecutor
from dataclasses import dataclass
from pathlib import Path
from typing import Sequence

import httpx


@dataclass(frozen=True)
class TileJob:
    tile_id: str
    source_url: str
    scratch: Path


# One semaphore per endpoint, sized from that endpoint's published capacity.
LIMITS = {
    "tiles.example.org": asyncio.Semaphore(16),
    "wfs.example.org": asyncio.Semaphore(2),
}


async def fetch(client: httpx.AsyncClient, job: TileJob) -> Path:
    """I/O only. Nothing here touches shapely, geopandas or GDAL."""
    host = httpx.URL(job.source_url).host
    async with LIMITS[host]:
        response = await client.get(job.source_url, timeout=httpx.Timeout(10.0, read=120.0))
        response.raise_for_status()
    raw = job.scratch / f"{job.tile_id}.tif"
    # Write with a thread, not inline: a large file write blocks the loop too.
    await asyncio.to_thread(raw.write_bytes, response.content)
    return raw


def transform(raw_path: Path, dst_crs: str) -> Path:
    """CPU-bound, and deliberately a plain function: it runs in another process.

    It takes and returns PATHS. Pickling a GeoDataFrame across the process
    boundary routinely costs more than the computation it was meant to speed up.
    """
    import rasterio
    from rasterio.warp import calculate_default_transform, reproject, Resampling

    out_path = raw_path.with_suffix(".proj.tif")
    with rasterio.open(raw_path) as src:
        transform_, width, height = calculate_default_transform(
            src.crs, dst_crs, src.width, src.height, *src.bounds
        )
        profile = src.profile | {
            "crs": dst_crs, "transform": transform_,
            "width": width, "height": height,
            "nodata": src.nodata if src.nodata is not None else -9999,
        }
        with rasterio.open(out_path, "w", **profile) as dst:
            for band in range(1, src.count + 1):
                reproject(
                    source=rasterio.band(src, band),
                    destination=rasterio.band(dst, band),
                    resampling=Resampling.bilinear,
                )
    return out_path


async def run(jobs: Sequence[TileJob], dst_crs: str = "EPSG:3857",
              pool_size: int = 4) -> list[Path]:
    loop = asyncio.get_running_loop()
    results: list[Path] = []

    with ProcessPoolExecutor(max_workers=pool_size) as pool:
        async with httpx.AsyncClient() as client:
            async with asyncio.TaskGroup() as group:
                fetch_tasks = [group.create_task(fetch(client, job)) for job in jobs]

            # Transforms are submitted to the pool; awaiting them yields the loop,
            # so further fetches (if any) keep making progress.
            async with asyncio.TaskGroup() as group:
                transform_tasks = [
                    group.create_task(loop.run_in_executor(pool, transform, task.result(), dst_crs))
                    for task in fetch_tasks
                ]
            results = [task.result() for task in transform_tasks]

    return results

Step-by-Step Walkthrough

  1. Separate the phases before optimising either. The fetch coroutine touches no geospatial library, and transform touches no network. That separation is what makes it possible to reason about which one benefits from which kind of concurrency.
  2. Give each host its own semaphore. LIMITS is keyed by hostname because the capacity being protected belongs to the host. A single global semaphore either starves the fast endpoint or overruns the slow one.
  3. Write files with asyncio.to_thread. A 200 MB write_bytes on the event loop blocks every other coroutine for its duration. Moving it to a thread is one line and removes a stall that is otherwise almost impossible to see.
  4. Make the CPU-bound function a plain, importable function. ProcessPoolExecutor pickles the callable by reference, so a closure or a lambda fails at submission with a confusing error. Module-level functions work; nested ones do not.
  5. Pass paths across the process boundary. The transform takes a Path and returns a Path. Nothing large is pickled, so the boundary costs microseconds instead of seconds.
  6. Size the pool from memory, not from cores. Each process holds its own GDAL buffers. Four processes each peaking at 3 GB will not fit on a 8 GB worker regardless of how many cores it has.
  7. Use TaskGroup rather than gather. On failure, a TaskGroup cancels its siblings and raises an ExceptionGroup that names every failure, which is far more useful than gather’s first-exception-wins behaviour when eight tiles fail for three different reasons.

Edge Cases & Failure Recovery

A blocking call that nobody notices. The classic symptom is that raising concurrency from 8 to 64 changes nothing. The cause is almost always a synchronous call inside a coroutine — requests instead of httpx, open().read() on a large file, or a geopandas call that slipped into the fetch path. asyncio.run(..., debug=True) logs any callback that occupies the loop for more than 100 ms, which finds these in one run.

Process pool workers that inherit a broken state. GDAL configuration set with gdal.SetConfigOption in the parent is not reliably inherited by pool workers, and neither are environment variables set after the pool was created. Configure inside the worker function, or create the pool after all configuration is done.

Memory multiplied by pool size. A transform that peaks at 3 GB is fine alone and fatal at four concurrent copies. This is the most common way an async rewrite makes a pipeline less reliable: throughput rises, memory rises with it, and the OOM killer arrives. Size the pool by measuring one transform’s peak and dividing available memory by it.

Cancellation that does not reach the pool. Cancelling the coroutine awaiting run_in_executor does not stop the worker process; it only stops waiting for it. A long transform continues to completion. For work that must be cancellable, run it as a subprocess whose process group you control rather than in a pool — the reasoning in cancelling in-flight tile jobs cleanly.

Exceptions that arrive as a group. With TaskGroup, eight failures raise one ExceptionGroup. Code that catches a specific exception type will miss it unless it uses except* or unwraps the group. This is a small syntax change that is easy to forget and produces a confusing “unhandled exception” when the handler was right there.

Fetches that outrun the transforms. Fetching 400 tiles concurrently while the pool processes 4 at a time fills scratch storage with 396 waiting files. Bound the fetch phase by the pool’s appetite — a queue with a maximum size, or simply chunking the job list — so disk usage stays proportional to concurrency rather than to the batch.

Choosing the execution model per stepIf the step waits on the network or disk, use asyncio. If it computes in Python or a C extension, use a process pool. If it computes in an external binary, use a subprocess so it can be cancelled.what does thisstep spend time on?waiting — HTTP, S3, disk99% idle per callcomputing in Pythonshapely, geopandasan external binarygdalwarp, ogr2ograsyncio + semaphoreprocess poolsubprocess + killpg
The third branch exists because cancellability, not speed, is often the deciding property — a pool worker cannot be stopped, and a subprocess can.

A semaphore acquired inside the pool. Semaphores belong to one event loop in one process; a ProcessPoolExecutor worker has neither. Code that tries to rate-limit from inside a pool worker either fails outright or, worse, creates a fresh unshared semaphore per process and silently multiplies the concurrency by the pool size. Keep all rate limiting on the async side of the boundary, where the semaphore actually exists.

Logging that interleaves unreadably. Eight concurrent coroutines writing to one logger produce a stream in which no two consecutive lines belong to the same tile. Attach the tile id to every record — a logging.LoggerAdapter or a contextvar set at the top of each coroutine — or the logs become unusable exactly when concurrency is highest and you most need them. Structured logging for geospatial flows covers the shape.

Configuration Reference

Setting Default Spatial context
per-host semaphore published limit One per endpoint. A tile server at 16 and a WFS at 2 are both correct simultaneously.
pool_size memory ÷ peak Not core count. Four GDAL processes at 3 GB peak need 12 GB before anything else.
file writes asyncio.to_thread A large synchronous write stalls every coroutine on the loop.
pool payload paths only Pickling a GeoDataFrame across a process boundary often costs more than the work.
TaskGroup preferred Cancels siblings and reports every failure, unlike gather(return_exceptions=False).
debug mode on in CI asyncio.run(..., debug=True) names any callback blocking the loop over 100 ms.
fetch/transform ratio measured Determines whether async is worth anything here at all. Measure before rewriting.

That last row is the one to act on first. If profiling shows a partition spends 15% of its time waiting and 85% computing, an async rewrite has at most 15% to give and will cost days — the same effort spent on the process pool, or on making the computation cheaper, returns far more. Async is worth it when the ratio runs the other way, which for tile-fetching and API-heavy pipelines it very often does. Measure the split once, write it in the flow’s docstring, and let the number rather than the fashion decide.

Frequently Asked Questions

Does Prefect or Dagster support async task bodies?

Both do, and both handle awaiting them correctly. What neither does is make a synchronous call inside an async task non-blocking, so the same rules apply. The orchestrator’s own concurrency limits compose with your semaphores rather than replacing them: the limit bounds how many task runs exist, the semaphore bounds how many requests are in flight within each.

Is a thread pool ever the right answer for spatial work?

Sometimes. GDAL and shapely release the GIL inside their C calls, so a thread pool does give real parallelism for those specific operations, and it avoids the pickling and memory duplication of processes. The catch is that any Python-level work around the C call — building the geometry list, formatting the output — is still serialised, and the mix varies by operation. Processes are the predictable choice; threads are the optimisation once you have measured.

How do I profile where the time actually goes?

Run one partition with asyncio.run(main(), debug=True) and watch for slow-callback warnings, which identify blocking calls precisely. For the CPU/I-O split, py-spy record on a synchronous run of the same partition gives a flame graph that separates network waits from libgdal frames without any code changes. Twenty minutes of this usually settles the design question that a week of rewriting would not.

Should the whole pipeline be async, or just parts of it?

Just the parts that wait. An async flow that calls into a synchronous library through to_thread at three points is easier to reason about, easier to test and easier to debug than a codebase where every function is a coroutine because one of them needed to be. The boundary between the two worlds is a good place to put a clear name — fetch_* for coroutines, plain verbs for the synchronous side — so a reviewer can see which world a function belongs to without reading its body.

What happens to an async flow when a worker is cancelled?

The coroutines receive CancelledError at their next await, which is clean and fast. Anything currently inside a C call or a pool worker is not interrupted and will run to completion, holding its memory. This asymmetry — the async parts stop instantly, the heavy parts do not — is why cancellation design and execution-model design have to be done together.

Spatial Task Design & Dependency Mapping