skills/geopandas/references/spatial-analysis.md
Spatial operations can multiply, split, or merge records. Define geometry semantics and expected cardinality before running them, then audit both.
For each input:
GeoPandas operations are planar and ignore Z. Geographic longitude/latitude is not suitable for distance/nearest work.
Call merge from the spatial object so geometry dtype and CRS are retained:
result = zones.merge(
attributes,
on="zone_id",
how="left",
validate="one_to_one",
indicator=True,
)
Use pandas validate= whenever the key contract is known. Before merging, audit
null/duplicate keys on both sides. Afterward, count _merge categories and
verify row count. A pandas index is not a feature key.
Stable GeoPandas 1.1.4 signature:
joined = left.sjoin(
right,
how="inner",
predicate="intersects",
distance=None,
on_attribute=None,
)
predicate is evaluated directionally from each left geometry against right
geometries. Query available values from
left.sindex.valid_query_predicates.
Common choices:
within;covers,
or explicitly test the intended boundary behavior;intersects;touches;dwithin.dwithin requires distance. It may be a scalar or a one-dimensional array
with one value per left row; values are CRS units:
joined = left.sjoin(
right,
predicate="dwithin",
distance=500,
)
on_attribute="category" (or a list/tuple) adds equality on columns present in
both frames after the spatial predicate. It is a restriction, not a substitute
for checking nulls, normalization, and duplicates in the attribute.
how="left" keeps left keys and only left geometry.how="right" keeps right keys and only right geometry.how="inner" keeps matching pairs and only left geometry.index_right. Do not hard-code that name as a
permanent ID.Every qualifying pair produces a row. One left feature intersecting three right features yields three rows. Empty/null geometries do not produce predicate matches.
Add internal unique row numbers before joining; never expose user IDs in a public report:
left_work = left.reset_index(drop=True).assign(_left_row=lambda x: range(len(x)))
right_work = right.reset_index(drop=True).assign(_right_row=lambda x: range(len(x)))
pairs = left_work[["_left_row", left_work.geometry.name]].sjoin(
right_work[["_right_row", right_work.geometry.name]],
predicate="intersects",
how="inner",
)
left_multiplicity = pairs.groupby("_left_row").size()
right_multiplicity = pairs.groupby("_right_row").size()
Report pair count, matched/unmatched counts on both sides, and counts with
multiple matches. Compare these to the declared contract. The bundled
scripts/spatial_join_audit.py implements this with redacted aggregate output.
nearest = left.sjoin_nearest(
right,
how="left",
max_distance=1_000,
distance_col="distance_crs_units",
exclusive=False,
)
Key semantics:
max_distance use CRS units;max_distance > 0 can substantially reduce work and limits accepted matches;exclusive=True excludes geometrically equal nearest candidates;sjoin_nearest has no k= argument. For k-nearest behavior use a
separately designed spatial-index/neighbor workflow and define tie handling.Never silently keep the first tie. Report tie counts and specify a deterministic domain rule if one result must be selected.
result = left.overlay(
right,
how="intersection",
keep_geom_type=False,
make_valid=False,
)
Modes:
how | Result |
|---|---|
intersection | Areas/parts shared by both |
union | All partitioned parts with attributes from either/both |
identity | All left parts split by right |
difference | Left minus right |
symmetric_difference | Parts in exactly one input |
Constraints and choices:
make_valid=True repairs invalid inputs and may change types; pre-audit repair
is more traceable. With False, invalid inputs raise.keep_geom_type=None behaves as True and warns when dropping other result
types. Set it explicitly and count dropped/type-changed results.NaN in attributes absent from one side.Always keep source IDs from both inputs. Overlay can split one source feature into many derived records.
clipped = gpd.clip(
features,
mask,
keep_geom_type=False,
sort=False,
)
(minx, miny, maxx, maxy) mask activates a fast rectangle
path. It is possibly dirty, does not guarantee valid output, and can omit a
line that collapses to a point.keep_geom_type=False retains mixed-dimensional outputs; set deliberately.sort=False does not promise source order. Preserve IDs and sort explicitly
if order is part of the contract.Validate clip output. Use overlay intersection when mask attributes or more auditable topology are required.
dissolve performs groupby.agg on attributes and union_all on geometry:
dissolved = parcels.dissolve(
by="region_id",
aggfunc={
"population": "sum",
"source_date": "max",
},
as_index=False,
dropna=False,
method="unary",
grid_size=0.01,
)
Avoid the default aggfunc="first" for meaningful attributes. Specify every
aggregation and its units. Decide whether null group keys should be dropped
(dropna=True) or retained as a group (False).
Union methods:
unary: robust general default; supports grid_size;coverage: fast only for proven non-overlapping, edge-matched polygons and
may produce invalid output otherwise;disjoint_subset: Shapely >=2.1, useful for disjoint partitions.For coverage mode, run is_valid_coverage() first. After dissolve, compare
group counts, summed attributes, validity, empty output, and area in a suitable
projected CRS.
GeoPandas uses Shapely's spatial index automatically for joins, clip, and overlay. Direct queries are candidate/predicate operations:
predicate_names = gdf.sindex.valid_query_predicates
indices = gdf.sindex.query(query_geometry, predicate="intersects")
GeoPandas 1.0 removed sindex.query_bulk; use query. GeoPandas 1.1 supports
indices, dense boolean, and optional SciPy sparse boolean output formats. Do not
assume the shape/orientation of an undocumented output; set output_format
explicitly and test.
Spatial indexing does not fix CRS, invalid geometry, distance units, predicate direction, or cardinality.
For planar metrics:
if gdf.crs is None or gdf.crs.is_geographic:
raise ValueError("Use a justified projected CRS")
area = gdf.geometry.area
length = gdf.geometry.length
distance = gdf.geometry.distance(reference_geometry)
Confirm axis unit and conversion factor before labeling values. For large/global or cross-zone work, use a geodesic design instead.
Record: