← Về thư mục
📄 / / profiles / r-and-d / raw_evidence / scientific-agent-skills / skills / geopandas / references / spatial-analysis.md

Spatial joins, overlay, clip, and dissolve

Spatial operations can multiply, split, or merge records. Define geometry semantics and expected cardinality before running them, then audit both.

Shared preflight

For each input:

  1. preserve a non-null stable feature-ID column;
  2. count duplicate IDs and duplicate pandas indices;
  3. require CRS and reproject to a common, justified CRS;
  4. count null, empty, invalid, mixed, and Z/M geometries;
  5. validate precision/accuracy compatibility;
  6. state whether boundary contact should count;
  7. state the expected one-to-one, one-to-many, many-to-one, or many-to-many relationship.

GeoPandas operations are planar and ignore Z. Geographic longitude/latitude is not suitable for distance/nearest work.

Attribute joins

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.

Binary-predicate spatial joins

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:

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.

Geometry and index retention

Every qualifying pair produces a row. One left feature intersecting three right features yields three rows. Empty/null geometries do not produce predicate matches.

Cardinality audit

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 spatial joins

nearest = left.sjoin_nearest(
    right,
    how="left",
    max_distance=1_000,
    distance_col="distance_crs_units",
    exclusive=False,
)

Key semantics:

Never silently keep the first tie. Report tie counts and specify a deterministic domain rule if one result must be selected.

Overlay

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:

Always keep source IDs from both inputs. Overlay can split one source feature into many derived records.

Clip

clipped = gpd.clip(
    features,
    mask,
    keep_geom_type=False,
    sort=False,
)

Validate clip output. Use overlay intersection when mask attributes or more auditable topology are required.

Dissolve

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:

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.

Spatial index

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.

Area and distance checks

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.

Provenance checklist

Record:

Sources (verified 2026-07-23)