Optimizing Async Geometry Parsing with asyncio
Wrap each CPU-bound parse in loop.run_in_executor(ProcessPoolExecutor(), validate_geometry, payload) and await it behind an asyncio.wait_for timeout — the event loop keeps accepting webhooks while Shapely validation runs in a separate OS process.
This page sits under Async Processing for Heavy Geometries, part of the broader Spatial Payload Routing & Parsing section that covers how spatial payloads are ingested, validated, and forwarded to consumers.
When to Use This Pattern
Reach for an executor-backed parse — rather than parsing inline in the coroutine — when:
- Parsing is CPU-bound, not I/O-bound. Payloads carry dense
MultiPolygonrings, thousands of vertices, or fullFeatureCollectiondocuments, and most of the wall-clock cost is GEOS topology work (make_valid,is_valid,unary_union) rather than network or disk waits. - The same process must stay responsive to other webhooks. A single FastAPI/aiohttp worker handles many concurrent senders and cannot afford to freeze the loop while one large geometry is validated.
- You need a hard upper bound per geometry. A timeout must quarantine pathological inputs (degenerate rings, billion-coordinate spikes) without taking down the ingestion endpoint.
If the heavy step is instead a remote call — fetching a WFS tile or an elevation API — prefer asyncio.to_thread or a ThreadPoolExecutor, since threads release the GIL during I/O and avoid the pickling overhead of a process pool.
How the Loop Stays Free
The event loop never executes geometry code. It submits the work to a pool of worker processes and immediately returns to servicing sockets; only the originating coroutine suspends on the returned future.
Complete Runnable Example
The snippet below is self-contained. The worker is a plain module-level function so it can be pickled across the process boundary; the consumer offloads it with loop.run_in_executor and enforces a per-task ceiling with asyncio.wait_for. The worker normalizes any non-WGS84 input to EPSG:4326, mirroring the canonical-projection approach in CRS Normalization Strategies, and validates topology in line with Geometry Validation Pipelines.
import asyncio
import json
import logging
import os
from concurrent.futures import ProcessPoolExecutor
from typing import Any
from pyproj import CRS, Transformer
from shapely.geometry import mapping, shape
from shapely.ops import transform as shp_transform
from shapely.validation import make_valid
from shapely.errors import ShapelyError
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("async-geometry")
# ---- 1. CPU-bound worker (runs in a separate OS process) --------------------
# Must be a top-level def: lambdas and closures are not picklable and will
# raise PicklingError when submitted to a ProcessPoolExecutor.
def validate_geometry(payload: dict[str, Any]) -> dict[str, Any]:
try:
geom = shape(payload["geometry"])
# Topology repair before any measurement; make_valid keeps all vertices.
if not geom.is_valid:
geom = make_valid(geom)
# Normalize to EPSG:4326 (WGS 84). RFC 7946 mandates WGS84 when the
# source CRS is omitted, so default to it. always_xy=True keeps the
# (lon, lat) axis order PROJ 6+ would otherwise swap.
source_epsg = int(payload.get("source_epsg", 4326))
if source_epsg != 4326:
transformer = Transformer.from_crs(
CRS.from_epsg(source_epsg),
CRS.from_epsg(4326),
always_xy=True,
)
geom = shp_transform(transformer.transform, geom)
return {
"id": payload.get("id"),
"valid": True,
"crs": "EPSG:4326",
"geometry": mapping(geom), # JSON-serializable -> safe to pickle back
"bbox": list(geom.bounds),
}
except (ShapelyError, KeyError, TypeError, ValueError) as exc:
return {"id": payload.get("id"), "valid": False, "error": str(exc)}
# ---- 2. Async consumer: offload each payload, never block the loop ----------
async def process_geometry_queue(
queue: "asyncio.Queue[dict[str, Any]]",
executor: ProcessPoolExecutor,
timeout: float = 30.0,
) -> None:
loop = asyncio.get_running_loop()
while True:
payload = await queue.get()
try:
result = await asyncio.wait_for(
loop.run_in_executor(executor, validate_geometry, payload),
timeout=timeout,
)
if result["valid"]:
logger.info("geometry %s normalized, bbox=%s",
result["id"], result["bbox"])
# route `result` to PostGIS / Redis / a downstream service here
else:
logger.warning("geometry %s rejected: %s",
result["id"], result["error"])
# send to dead-letter queue here
except asyncio.TimeoutError:
# The worker process is still running; quarantine, do not retry inline.
logger.error("geometry %s timed out after %.0fs", payload.get("id"), timeout)
finally:
queue.task_done()
# ---- 3. Wire it together ----------------------------------------------------
async def main() -> None:
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=1000)
# One worker per core: oversubscribing increases context-switch + memory cost.
with ProcessPoolExecutor(max_workers=os.cpu_count() or 4) as executor:
consumers = [
asyncio.create_task(process_geometry_queue(queue, executor))
for _ in range(2) # several consumers feed the same pool
]
# Simulate an ingestion endpoint dropping raw payloads onto the queue.
sample = {
"id": "feat-001",
"source_epsg": 3857, # Web Mercator -> will be reprojected to 4326
"geometry": {
"type": "Polygon",
"coordinates": [[
[-13627361.0, 4544760.0],
[-13626000.0, 4544760.0],
[-13626000.0, 4546000.0],
[-13627361.0, 4546000.0],
[-13627361.0, 4544760.0],
]],
},
}
await queue.put(sample)
await queue.join() # wait until all queued geometries are processed
for c in consumers:
c.cancel()
if __name__ == "__main__":
asyncio.run(main())
Parameter Reference
| Argument | Type | Spatial constraint / note | Default |
|---|---|---|---|
executor (max_workers) |
int |
Set to os.cpu_count(); GEOS work is CPU-bound so oversubscribing degrades throughput and inflates RSS |
os.cpu_count() |
loop.run_in_executor(executor, fn, *args) |
callable + picklable args | fn must be a module-level def; args/return must be picklable (GeoJSON dicts are, Shapely objects are not by default) |
required |
asyncio.wait_for(..., timeout) |
float seconds |
30 s suits validation/simplify; dense union/intersection may need up to 120 s. Timeout cancels the await, not the OS process | None (no timeout) |
source_epsg |
int (EPSG code) |
Source CRS of incoming coords; reproject to EPSG:4326 before topology checks to avoid corrupt indexes |
4326 |
always_xy (pyproj) |
bool |
True enforces (lon, lat) order and prevents the PROJ 6+ axis swap |
True here |
queue (maxsize) |
int |
Bounded queue applies backpressure; 0 is unbounded and risks OOM under burst traffic |
1000 |
Gotchas & Spatial Edge Cases
- Pickling failures on geometry objects. A
ProcessPoolExecutorserializes args and results withpickle. Pass and return GeoJSON dicts (callmapping(geom)before returning), not raw Shapely objects, database connections, or closures — those raisePicklingErroror silently fail to round-trip. - Topology repair before measurement. Compute
bounds, area, or centroid only aftermake_valid. Self-intersecting rings produce meaningless extents, and an invalidPolygoncan yield aGeometryCollectionafter repair — assert the output type before persisting. - CRS mismatch on merge. Reproject to
EPSG:4326inside the worker before validating or unioning. MixingEPSG:3857(Web Mercator) coordinates with WGS84 silently shifts features hundreds of kilometres; resolve the source CRS first, as detailed in Handling Mixed CRS Payloads in Python Webhooks. - Coordinate ring orientation. RFC 7946 expects exterior rings counter-clockwise and holes clockwise.
make_validdoes not normalize winding; callshapely.geometry.polygon.orient(geom, sign=1.0)if a downstream consumer is winding-sensitive. - Precision loss after transformation. Reprojection introduces floating-point drift, so a transformed ring’s first and last vertex may no longer be bit-identical. Re-close the ring (or re-run
make_valid) after transforming to avoid spurious “unclosed ring” errors in PostGIS. - Timeout does not kill the process.
asyncio.wait_forcancels the await, but the worker process keeps grinding on the pathological geometry and holds a pool slot. For a hard kill, give each task its own short-lived pool or usepebble.ProcessPool, which cancels the underlying process. - Pool created at import time under spawn. On macOS and Windows (spawn start method) a module-level
ProcessPoolExecutor()re-imports the module in each child and can fork-bomb. Create the pool insideif __name__ == "__main__":or your app’s startup hook, never at module top level.
Verify It Works
Drop this into test_async_geometry.py and run pytest -q. It asserts the worker reprojects Web Mercator to WGS84 and that the offloaded call completes well under its timeout — proving the event loop is never blocked.
import asyncio
import os
from concurrent.futures import ProcessPoolExecutor
import pytest
from async_geometry import validate_geometry # the module above
def test_worker_reprojects_to_wgs84():
payload = {
"id": "t1",
"source_epsg": 3857,
"geometry": {
"type": "Point",
"coordinates": [-13627361.0, 4544760.0], # Web Mercator
},
}
out = validate_geometry(payload)
assert out["valid"] is True
assert out["crs"] == "EPSG:4326"
lon, lat = out["geometry"]["coordinates"]
assert -123.0 < lon < -122.0 and 37.0 < lat < 38.0 # San Francisco area
@pytest.mark.asyncio
async def test_offload_does_not_block_loop():
loop = asyncio.get_running_loop()
payload = {"id": "t2", "geometry": {"type": "Point", "coordinates": [0, 0]}}
with ProcessPoolExecutor(max_workers=os.cpu_count() or 2) as ex:
result = await asyncio.wait_for(
loop.run_in_executor(ex, validate_geometry, payload),
timeout=5.0,
)
assert result["valid"] is True
assert result["bbox"] == [0.0, 0.0, 0.0, 0.0]
A passing run confirms the geometry is parsed and reprojected inside a child process and the future resolves before the timeout — exactly the non-blocking behaviour the pattern guarantees.
Related
- Async Processing for Heavy Geometries — the full four-layer ingestion-to-persistence pipeline this offloading pattern plugs into
- Handling Mixed CRS Payloads in Python Webhooks — resolving and normalizing source CRS before geometry work
- Spatial Payload Routing & Parsing — how spatial payloads are ingested, validated, and routed to consumers