isaaccorley/geospatial-skills/parallel-geospatial
Claude Code and agent skill by isaaccorley/geospatial-skills
- community
Checking that the SKILL.md file is still in the repository.
Parallelize large-scale geospatial operations on a single fat node — tile-edge dissolve, polygon elimination, polygonize, spatial joins, reprojection, simplification — at national/continental scale (10–100M polygons). Use when speeding up or designing a geospatial batch process, or as a pre-flight checklist for known failure modes (DuckDB GEOS segfaults, topology exceptions, int64 overflow, NFS thrashing).
Install
npx vskill@latest install isaaccorley/geospatial-skills/parallel-geospatialSKILL.md
Parallel Geospatial
Reference for designing and debugging large-scale parallel geospatial processing on a single fat node. You do not write geospatial code at this scale single-threaded. The defaults below come from real continental-scale (15M polygons, 6T-pixel rasters) failures.
The shape of every problem here is the same: partition along an axis where work is naturally independent, then run an embarrassingly parallel pool over the partitions. The skill is identifying that axis cheaply and avoiding the operation-specific footguns that crash GEOS or DuckDB at scale.
The decision tree (start here)
For a new geospatial operation at >1M-feature or >1B-pixel scale, ask in this order:
- Is there a natural disjoint partition? state, county, tile, hilbert-curve cell, raster window, time slice. If yes → ProcessPool over partitions. Stop, you're done thinking.
- Is the work output-disjoint but input-overlapping? (e.g. spatial join: each output row needs a small bbox query against an input). Build an STRtree once on inputs, partition over outputs. Workers share a read-only tree via fork-inherited memory or rebuild small per worker.
- Is it a global aggregation that can't be partitioned? Decompose into a tree-reduce: partition → reduce-per-partition → small final merge. Almost every real "global" op decomposes this way (union, dissolve, count-distinct, hilbert sort).
- Is it truly serial? Rare. Consider whether you actually need the operation, or whether a different formulation (e.g. raster mask AND vs. polygon difference) sidesteps it.
If you find yourself running anything covering a continent in a single thread, stop and reconsider — single-threaded for >1M features is almost always a design bug, not a "we'll optimize later" one.
Footguns that cost whole batch jobs (real failures)
These are operations whose textbook one-liner segfaults or silently corrupts at continental scale. Bake the workaround in from the start; do not "try the simple version first."
| Operation | Looks like | Fails how | Fix | |
|---|---|---|---|---|
DuckDB self-join with ST_Touches over 15M rows | SELECT a.id, b.id FROM t a JOIN t b ON ST_Touches(a.geom, b.geom) | Segfault during execution (exit 139), no error message | Materialise to Arrow, build shapely.STRtree, call tree.query(geoms, predicate="touches"). Vectorised, ~1000× faster, no GEOS-from-DuckDB crash. | |
DuckDB ST_Union_Agg GROUP BY over millions of rows | SELECT root_id, ST_Union_Agg(geom) FROM ... GROUP BY root_id | Segfault deep inside GEOS | Group rows in Python, ProcessPool dissolves each group with shapely.union_all. Use parallel_map. | |
DuckDB ST_Buffer on ST_Transform(geom, ...) lines (spatial 1.5.0) | ST_Buffer(ST_Transform(geom, 'EPSG:4326', 'EPSG:5070'), 15) | Segfault on first row (exit 139) | Do reprojection in DuckDB, fetch WKB, buffer in shapely.buffer(geoms, m). | |
DuckDB ST_Transform 4326 → projected without always_xy | ST_Transform(geom, 'EPSG:4326', 'EPSG:5070') | All output coords are Infinity (no error) | Always pass always_xy => true. EPSG:4326's authoritative axis order is lat,lon — DuckDB respects that, PROJ tries to swap, output becomes garbage. | |
DuckDB GROUP BY a, ALL (mixing named key + ALL) | GROUP BY root_oid, ALL | Parser error: "syntax error at or near ALL" | DuckDB's GROUP BY ALL doesn't compose with explicit keys. Restructure as a dissolve-then-join: aggregate geometry by root, then join one representative row's attributes back. | |
shapely.union_all on degenerate geoms | shapely.union_all([g1, g2, ...]) | GEOSException: TopologyException: side location conflict | Always geoms = shapely.make_valid(geoms) before union_all / intersection_all / etc. Cheap insurance. | |
| Polygon-elimination on int32 label ids when count × tile_count > 2³¹ | union-find over int32 ids | Silent collisions; output polygon count drops by ~50% with no error | Use int64 everywhere for labels and remaps. One real national-scale "polygon count gap" was a one-line int32→int64 fix that recovered ~7M polygons. | |
| Reading a 5GB GeoParquet from a network filesystem with 96 concurrent workers | pq.read_table(...) per worker | NFS saturates at ~70 MB/s aggregate; whole job stalls | Stage to local NVMe scratch (1–2 GB/s) before fanning out. Bash one-liner. | |
pyogrio.write_dataframe(..., driver="FlatGeobuf") with NULL geoms | gdf.to_file(...) | FeatureError: NULL geometry not supported with spatial index | Filter geom.notna() & ~geom.is_empty & geom.is_valid before write. Mandatory at FGB write. | |
| Tippecanoe single-process at 100M+ features | tippecanoe -o out.pmtiles in.fgb | Times out at 8h+ even on 96-core node | Sharded path: round-robin into N FGBs, N × tippecanoe TIPPECANOE_MAX_THREADS=1, then tile-join. See pmtiles-pipeline skill. |
When in doubt about whether a given DuckDB-spatial or GEOS operation will scale, assume it will segfault. Cost of bake-in workaround: 30 min. Cost of discovering the segfault on a real run: a 4h job, wasted allocation, plus the time to find the bug after the empty stderr.
Embarrassingly-parallel patterns
Pattern A — Per-tile / per-state ProcessPool (the default)
The 80% case. Used for: raster polygonize, postprocess spatial joins, tile-edge pair finding, multi-year raster combines.
The _worker_fn should:
- Take ONLY picklable args (Arrow buffers, numpy arrays, paths, primitives — not DuckDB connections, not open file handles).
- Open its own DuckDB connection / Rasterio handle / etc. inside the worker. Workers share forked memory but you cannot share an open driver state.
- Return small results. If the result is large (e.g. 1GB of polygons), write to disk in the worker and return only the path.
Pattern B — Tree-reduce (when "global" aggregation is needed)
Example — a national tile-edge dissolve:
Lesson: filter aggressively before the expensive step. The on_edge filter (c) drops 97% of work. Without it, even the parallel STRtree is too slow.
Pattern C — Output-disjoint, input-overlapping spatial join
Example — assigning each polygon its largest-overlap administrative boundary. STRtree of boundaries (small, ~3000 counties), partition over polygons:
Workers share boundary_tree via fork (Linux). On Windows / macOS spawn, rebuild per worker (cheap if input is small).
Always-applicable defaults
These are the "no thinking required" choices for a new geospatial pipeline. Deviate only with a reason.
- CRS: pick one and lock it (
EPSG:5070for CONUS,EPSG:6933for global equal-area). Never reproject silently mid-pipeline. - GeoParquet: 1.1 with full-PROJJSON CRS. The short
{id: {authority, code}}form breaks pyproj 3.x, geopandas, pyogrio, and GDAL readers. - Bbox columns: explicit
xmin/ymin/xmax/ymaxin addition to geometry. Lets DuckDB row-group statistics prune queries before any GEOS work runs. - Hilbert sort before write for any parquet that will be range-queried by bbox:
ORDER BY ST_Hilbert(geom, BOX_2D). Co-locates spatially-near features in the same row group. - Row group size: 50k–100k features. Smaller groups = better pruning, larger groups = better compression. 50k is a good default for ~30M-feature national datasets.
- Compression:
zstdalways. Never defaultsnappyfor vector — zstd is 30-50% smaller for similar speed. - Workers:
worker_count(0.95)of physical cores. The 5% headroom matters on shared nodes. - dtype:
int64for IDs and labels. Cost of int64 vs int32: nothing. Cost of int32 overflow: silent data loss. - Validation gate at every boundary:
geom.notna() & ~geom.is_empty & geom.is_validbefore any write that builds a spatial index (FGB, STRtree, DuckDB spatial parquet).
DuckDB-spatial-specific guidance
DuckDB-spatial is the right tool for a lot of this work but it has sharp edges. Treat these as load-bearing.
- Heavy GEOS ops segfault on big tables. ST_Union_Agg, ST_Touches self-joins, ST_Buffer over transformed lines. Materialise to Arrow + use shapely / numpy when you have >1M rows.
- Bbox stats are how you get pruning. Read parquet with explicit xmin/xmax/ymin/ymax; queries with
WHERE xmax >= ? AND xmin <= ? ...will skip whole row groups before decoding any geometry. - `always_xy => true` on every `ST_Transform` from `EPSG:4326`. Otherwise lat/lon swap → Infinity coords.
- Don't trust DuckDB's binder on wide self-joins with spatial predicates. Even when it doesn't crash, it produces "Failed to bind column reference" errors. Workaround: split into geometry-only spatial join CTE then attribute-join.
- `GROUP BY ALL` is great alone, broken with explicit keys. Use
GROUP BY ALLby itself, or list every key explicitly. Do not mix. - Use `BIND` over the parquet file, not over a TABLE you SELECTed into.
read_parquet('path')lets DuckDB use file statistics; an in-memoryCREATE TABLE ... AS SELECT ...discards them.
Shapely-specific guidance
- Vectorise everything.
shapely.from_wkb(list)andshapely.buffer(geoms, m)are 50–100× faster than per-geometry loops. Never write[g.buffer(m) for g in geoms]over more than 1000 geoms. - Always `make_valid` before union/intersect. Cheap, prevents 100% of TopologyException crashes.
- `shapely.coverage_union_all` for non-overlapping polygons; `union_all` for arbitrary. Coverage is ~10× faster but only correct on coverages (no overlaps, no gaps).
- STRtree is the only correct way to do bbox prefiltering at scale. Don't write your own grid index —
shapely.strtree.STRtreeis GEOS-backed and bulk-vectorized. - STRtree.query returns a (2, N) array of (input_idx, tree_idx) pairs. When self-querying, filter
a < bto dedupe. Predicate values:'intersects','touches','contains','within'.
Shared clusters and batch schedulers
This is the start of the file. Read the full SKILL.md on GitHub.
Security
- Security scanPassed, 100 of 100Pattern checks on the skill's files.
- Model reviewNot runA model review is scheduled for the next rescan.
Quality evaluation
- Task evaluationNot runNo evaluation yet. Run one in Skill Studio.
Source
- SKILL.md in the repositoryCheckingskills/parallel-geospatial/SKILL.md
Popularity
- 7-day trend score
- 0
Not refreshed yet.
Works with
Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.
- Claude Code
- Cursor
- GitHub Copilot
- Windsurf
- Codex
- Gemini CLI
- Cline
- Amp
- Roo Code
- Goose
- Continue
- Kilo Code
- Junie
- Trae
- OpenHands
- Qwen Code
- AI Agent
- Tabnine Chat
- Sourcegraph Cody
- Amazon Q
- Firebase Genkit
- Sweep
- Devin
- Phind
- Mentat
- Claude Desktop
- Aider
- GPT Pilot
- Plandex
- bolt.new
- v0
- Replit Agent
- Anse
- TypingMind
- BetterChatGPT
- LibreChat
- Warp AI
- Pieces
- Fig AI
Embed badge
[](https://verified-skill.com/skills/isaaccorley/geospatial-skills/parallel-geospatial)