skills/geopandas/references/data-structures.md
GeoPandas 1.1.4 extends pandas with a geometry extension dtype backed by
Shapely 2. A GeoSeries is one geometry-valued pandas Series; a GeoDataFrame
is a DataFrame with one active geometry column and may contain additional
geometry columns.
Always assign CRS at construction when it is known from authoritative metadata. Do not infer it from coordinate ranges.
import geopandas as gpd
import pandas as pd
from shapely import Point, box
points = gpd.GeoSeries(
[Point(0, 0), Point(1, 1), None],
index=["feature-a", "feature-b", "feature-c"],
crs="EPSG:3857",
name="location",
)
gdf = gpd.GeoDataFrame(
{
"feature_id": ["feature-a", "feature-b"],
"value": [10, 20],
"geometry": [Point(0, 0), Point(1, 1)],
},
geometry="geometry",
crs="EPSG:3857",
)
table = pd.DataFrame({"x": [0.0, 1.0], "y": [0.0, 1.0]})
from_xy = gpd.GeoDataFrame(
table,
geometry=gpd.points_from_xy(table["x"], table["y"]),
crs="EPSG:3857",
)
points_from_xy interprets arguments as x then y. For geographic data, that is
normally longitude then latitude in the coordinate array, even though the
authority definition of EPSG:4326 advertises latitude-first axes.
Frame-level spatial methods act on one active geometry column:
gdf["buffered"] = gdf.geometry.buffer(10)
gdf = gdf.set_geometry("buffered")
assert gdf.active_geometry_name == "buffered"
assert gdf.geometry.name == "buffered"
gdf = gdf.rename_geometry("analysis_geometry")
Important distinctions:
gdf.geometry always returns the active geometry, not necessarily a column
literally named "geometry".rename_geometry() updates the active-column bookkeeping. A plain pandas
rename(columns=...) must be followed by set_geometry().GeoDataFrame can hold multiple geometry columns with different CRS
metadata. Switching the active column switches the CRS exposed as gdf.crs.set_geometry(named_series): the Series name becomes
the active column name and the old geometry column is preserved. Avoid the
deprecated drop= parameter; rename/drop explicitly.Check every geometry column independently:
geometry_columns = [
name for name, dtype in gdf.dtypes.items() if str(dtype) == "geometry"
]
column_crs = {name: gdf[name].crs for name in geometry_columns}
Treat these states separately:
| State | Test | Meaning |
|---|---|---|
| Missing | series.isna() | Unknown geometry, represented by None |
| Empty | series.is_empty | A geometry object with no coordinates |
| Invalid | ~series.is_valid after excluding missing | Coordinates violate topology rules |
missing = gdf.geometry.isna()
empty = gdf.geometry.is_empty
invalid = (~missing) & (~empty) & (~gdf.geometry.is_valid)
usable = ~(missing | empty | invalid)
Missing values generally propagate through element-wise operations and are
ignored by reductions such as union_all(). Empty geometries participate as
geometries: they may have area 0.0 and remain empty after intersection.
Never use only dropna() to remove unusable geometries.
Binary geometry methods are row-wise, not all-pairs operations. With a
GeoSeries argument, align=None defaults to label alignment:
left = gpd.GeoSeries([Point(0, 0), Point(1, 1)], index=["a", "b"])
right = gpd.GeoSeries([Point(1, 1), Point(0, 0)], index=["b", "a"])
by_label = left.intersects(right, align=True)
by_position = left.intersects(right, align=False)
Use align=False only after proving equal lengths and intended row order.
GeoPandas 1.0 raises on some unaligned pandas Series method arguments to avoid
ambiguous automatic alignment. For all-pairs matching use a spatial join or
spatial-index query.
Assignment also aligns by index:
result = gdf.copy()
derived = result.geometry.buffer(10)
result.loc[:, "buffered"] = derived # label-aligned
Reset or preserve indices deliberately before positional work. Never assume the pandas index is a stable feature identifier.
Keep a non-null, stable feature-ID column across reads, joins, explode, overlay, dissolve, and exports:
ids = gdf["feature_id"]
if ids.isna().any() or ids.duplicated(keep=False).any():
raise ValueError("feature_id must be non-null and unique for this workflow")
Cardinality-changing operations need explicit provenance:
explode(ignore_index=False, index_parts=False) defaults to no part-level
MultiIndex in GeoPandas 1.0+. Create a part number if parts need identity.pd.concat requires compatible geometry-column CRS and can preserve duplicate
indices unless ignore_index=True.geom_type, has_z, and (with Shapely 2.1) has_m describe different
properties. Mixed geometry types are valid in memory but can break overlay or
export contracts. Z and M ordinates are not used by GeoPandas' planar topology:
summary = {
"types": gdf.geometry.geom_type.value_counts(dropna=False).to_dict(),
"has_z": int(gdf.geometry.has_z.sum()),
"has_m": int(gdf.geometry.has_m.sum()),
}
Do not silently drop Z/M. If a target format or operation is 2D-only, record the loss and create a new derived artifact.
gdf.copy() before replacing an active geometry.merge() from the GeoDataFrame side; plain_df.merge(gdf, ...) can
return a non-spatial DataFrame.read_file() returns a pandas DataFrame in
GeoPandas 1.0+.np.asarray(gdf.geometry) or gdf.geometry.to_numpy() replaces removed
access to GeometryArray.data.Record, without emitting coordinates or identifiers:
The bundled scripts/vector_inventory.py emits a redacted metadata inventory;
scripts/geometry_validity_report.py adds bounded geometry-state counts.