Handling Mixed Geometry Types in a GeoDataFrame
A geometry column has no type schema: nothing stops one GeoDataFrame from holding a Polygon in row 0, a MultiPolygon in row 1 and a GeometryCollection in row 2, and GeoPandas will happily plot, buffer and join all three — right up to the moment you write the layer out. This guide is for anyone whose export, dissolve or area total broke without an obvious cause. It sits under GeoPandas DataFrames Explained in Mastering Core Geospatial Python Libraries.
Why This Approach / What Goes Wrong
Mixed types are not corruption — they are the normal output of ordinary operations. A parcel that spans a river arrives from the source registry as a MultiPolygon while its neighbours are plain Polygons. dissolve() unions each group and returns whichever type the union happens to produce. A repair step promotes a self-intersecting ring into two parts, as described in Topology Validation & Repair. An intersection between two layers that touch along an edge returns a polygon plus a stray boundary line, wrapped in a GeometryCollection. In every case the column dtype stays geometry, no warning is emitted, and the frame keeps working.
The break happens at a boundary where something else insists on one type per layer. An ESRI Shapefile has a single shape type in its header, so a layer containing both polygons and a leftover line fails with Attempt to write non-polygon (LINESTRING) geometry to POLYGON type shapefile. A PostGIS column declared geometry(MultiPolygon, 25832) rejects a single-part Polygon with Geometry type (Polygon) does not match column type (MultiPolygon). A vector tile builder silently skips what it cannot classify. GeoJSON and GeoParquet accept anything, which is precisely why mixed types survive a long pipeline undetected and only surface at the last hop.
Analysis breaks more quietly than export. .area on a GeometryCollection sums only its polygonal members, so a line fragment inside the collection contributes nothing and no error; a per-feature mean area computed over a table where some rows are two-part parcels and others are single parts is measuring two different populations. And any explode() you run later multiplies rows, which turns a previously unique parcel_id into a duplicated key that fans out the next attribute join.
Prerequisites
geopandas>=1.0—explode(index_parts=...),set_geometry(), and pyogrio as the default I/O engineshapely>=2.0—get_parts,force_2d, and the vectorizedgeom_typedispatch behind the column accessorpyogrio>=0.8— exposespromote_to_multiandgeometry_typeon writepandas>=2.0—value_counts()on the audit,isin()on the type filter
conda install -c conda-forge "geopandas=1.0.*" "shapely=2.0.*" "pyogrio=0.8.*" "pandas=2.2.*"
Step-by-Step Implementation
1. Audit the column before anything else. geom_type is a vectorized accessor returning one string per row; value_counts() on it is the entire diagnosis. Run it immediately after every read, repair, overlay and dissolve.
import geopandas as gpd
from shapely.geometry import Polygon, MultiPolygon, LineString, GeometryCollection
# ETRS89 / UTM zone 32N — a metric CRS, so .area is in square metres
block_a = Polygon([(500000, 5700000), (500100, 5700000), (500100, 5700080), (500000, 5700080)])
block_b = Polygon([(500140, 5700000), (500200, 5700000), (500200, 5700080), (500140, 5700080)])
parcels = gpd.GeoDataFrame(
{"parcel_id": ["P-07", "P-08", "P-09"], "land_use": ["residential", "civic", "industrial"]},
geometry=[
MultiPolygon([block_a, block_b]), # split by a river
Polygon([(500240, 5700000), (500300, 5700000), (500300, 5700060), (500240, 5700060)]),
GeometryCollection([ # overlay leftovers
Polygon([(500340, 5700000), (500400, 5700000), (500400, 5700050), (500340, 5700050)]),
LineString([(500400, 5700000), (500440, 5700000)]),
]),
],
crs="EPSG:25832",
)
print(parcels.geom_type.value_counts())
# MultiPolygon 1
# Polygon 1
# GeometryCollection 1
# Name: count, dtype: int64
The CRS here is a projected one on purpose. EPSG:25832 measures in metres, so the area checks later in this guide mean something; run the same code on EPSG:4326 and every area is in square degrees. Use parcels.estimate_utm_crs() when the source CRS is geographic, and never substitute Web Mercator, whose area distortion grows with latitude — the mechanics are in Coordinate Systems with PyProj.
2. Pick one of three strategies — and pick it deliberately. There are only three honest answers to a mixed column: split every multi-part row into its parts, promote every single-part row to its multi equivalent, or drop the parts that do not belong in this layer at all. The decision hinges on whether a row must keep meaning exactly one real-world feature.
3. Normalise by exploding — and explode until nothing multi-part is left. explode(index_parts=False) splits each multi-part geometry into one row per part, copying the attributes down and repeating the original index value. It splits exactly one level, so a GeometryCollection that itself contains a MultiPolygon still holds a multi-part geometry after the first pass. Loop until the audit is clean.
MULTI = {"MultiPoint", "MultiLineString", "MultiPolygon", "GeometryCollection"}
parts = parcels.explode(index_parts=False, ignore_index=True)
while parts.geom_type.isin(MULTI).any(): # collections can nest one level deeper
parts = parts.explode(index_parts=False, ignore_index=True)
areal = parts[parts.geom_type == "Polygon"].copy() # discard the boundary LineString
print(len(parcels), "rows in ->", len(parts), "parts ->", len(areal), "areal")
# 3 rows in -> 5 parts -> 4 areal
ignore_index=True renumbers the result 0..n-1; leave it off and you get the parent index repeated once per part, which is useful when you want to groupby(level=0) back to the original features. Either way parcel_id is now duplicated — P-07 occupies two rows — so any downstream merge keyed on it will fan out unless you dissolve back first, the pattern covered in dissolving and aggregating features by attribute.
4. Or keep the rows and promote to multi on write. When the row is the unit of record — one row per parcel, one row per catchment — do not explode. Let pyogrio promote instead: promote_to_multi=True converts every single-part geometry to its multi equivalent as it writes, leaving the frame in memory untouched. This is the one-argument fix for a Shapefile or GeoPackage that must carry a uniform type.
# pyogrio promotes Polygon -> MultiPolygon at write time; the frame itself is unchanged
areal.to_file("parcels.gpkg", layer="parcels", engine="pyogrio", promote_to_multi=True)
# Mixed dimensions that must survive intact need an explicitly untyped layer
parcels.to_file("parcels_raw.gpkg", layer="raw", engine="pyogrio", geometry_type="Unknown")
For PostGIS the promotion has to happen in the frame, because the column type is checked server-side on insert. Build the promoted GeoSeries explicitly and swap it in with set_geometry() so the CRS travels with it — connection setup is covered in connecting GeoPandas to PostGIS with SQLAlchemy.
def as_multipolygon(geom):
"""Single-part in, multi-part out; anything non-areal is a bug worth raising on."""
if geom is None or geom.is_empty:
return geom
if geom.geom_type == "Polygon":
return MultiPolygon([geom])
if geom.geom_type == "MultiPolygon":
return geom
raise TypeError(f"non-areal geometry reached the writer: {geom.geom_type}")
promoted = gpd.GeoSeries(
[as_multipolygon(g) for g in areal.geometry], index=areal.index, crs=areal.crs
)
parcels_multi = areal.set_geometry(promoted)
print(parcels_multi.geom_type.unique()) # ['MultiPolygon']
Promotion changes the container, never the dimension: promote_to_multi turns a Polygon into a one-part MultiPolygon and a Point into a MultiPoint, but it will not convert a line into a polygon or unwrap a GeometryCollection. Anything that survives step 3's dimension filter is safe to promote; anything that does not was never a feature of this layer. Raising inside as_multipolygon rather than returning None keeps that assumption honest — a silent None becomes a null geometry on disk, which is a far harder bug to trace back than a TypeError at the write call.
5. Filter collections at the source: the overlay itself. Most GeometryCollection rows are born in an overlay where two layers share an edge, so the intersection returns a sliver polygon plus the shared boundary line. gpd.overlay takes keep_geom_type=True, which discards any output part whose dimension differs from the left frame's — it is far cheaper than cleaning up afterwards, and it makes the intent explicit instead of relying on the default's warning. The wider set of overlay operations is covered in computing overlay union and difference with GeoPandas.
floodplain = gpd.GeoDataFrame(
{"zone": ["flood-100y"]},
geometry=[Polygon([(500050, 5699980), (500360, 5699980), (500360, 5700040), (500050, 5700040)])],
crs="EPSG:25832",
)
exposed = gpd.overlay(areal, floodplain, how="intersection", keep_geom_type=True)
print(exposed.geom_type.unique()) # ['Polygon'] — no collections, no boundary lines
Note that keep_geom_type compares against the left frame's dimension, so the order of arguments matters when the two layers differ — overlaying a line network onto polygons keeps lines, and the reverse keeps polygons. If you need the discarded parts for a data-quality report rather than for the output layer, run the overlay once with keep_geom_type=False, explode, and split on geom_type yourself; the rejected fragments usually point at a shared boundary that ought to be snapped upstream instead.
Verification
The audit that opened the guide is also the gate that closes it. Assert the type set, assert the row count against whichever strategy you chose, and assert that area was conserved — exploding and filtering must not change the total areal measure, because the parts of a multi-part polygon are disjoint and the discarded line had zero area.
import numpy as np
# 1. Areas are only meaningful in a projected CRS
assert parcels.crs.is_projected, "reproject before measuring; degrees are not metres"
# 2. The normalised frame holds exactly one geometry type
assert set(areal.geom_type.unique()) == {"Polygon"}, areal.geom_type.value_counts()
# 3. No feature was lost — every input id still appears at least once
assert set(areal["parcel_id"]) == set(parcels["parcel_id"])
# 4. Area is conserved: multi-parts are disjoint, and the dropped line had none
assert np.isclose(areal.area.sum(), parcels.area.sum(), rtol=1e-9)
print(f"{len(parcels)} features -> {len(areal)} parts, {areal.area.sum():,.0f} m2")
# 3 features -> 4 parts, 19,400 m2
Check three, the identifier set, is the one people skip. An empty multi-part geometry has no parts, so it contributes no rows to the exploded frame and disappears without a warning — comparing the id sets is what catches it.
Edge Cases & Debugging
Attempt to write non-polygon (LINESTRING) geometry to POLYGON type shapefile. A line or point is still in the column. Filter by dimension withgdf[gdf.geom_type.isin(["Polygon", "MultiPolygon"])]before writing —promote_to_multiwill not save you here, because promotion cannot change a dimension.Geometry type (Polygon) does not match column type (MultiPolygon)from PostGIS. The frame still contains single-part rows. Promote in the frame withas_multipolygonas in step 4, or relax the column togeometry(Geometry, 25832)if the layer is genuinely mixed.- Explode ran but multi-part rows remain. A
GeometryCollectioncontaining aMultiPolygonneeds two passes; use thewhileloop from step 3 rather than a single call. - Row count exploded and a later join duplicated everything.
parcel_idstopped being unique the moment you exploded. Either promote instead, ordissolve(by="parcel_id")back to one row per feature before the join. - The centroid of a two-part parcel falls in the river between its parts.
centroidis the area-weighted mean of the whole multi-part geometry and need not lie inside it — userepresentative_point()for a guaranteed-inside label point. - Shapefile complains about Z coordinates on some rows. A layer mixes 2D and 3D geometries; flatten with
shapely.force_2d(parcels.geometry.to_numpy())and rebuild theGeoSeriesbefore writing.
Frequently Asked Questions
Why does GeoPandas allow a mixed geometry column at all?
Because the geometry dtype stores references to GEOS objects, not a declared schema — the column's type is geometry, and each element's class is discovered per row. That flexibility is what lets overlay, dissolve and make_valid return whatever the topology genuinely produces instead of failing or lying. The type contract belongs to the format you export to, and GeoPandas deliberately does not impose it early. The consequence is that the check has to be yours: geom_type.value_counts() after every operation that can change dimensionality.
Does mixing types slow GeoPandas down?
Not measurably. Predicates and measurements dispatch on the geometry's type inside GEOS in C, so a mixed column costs about the same as a uniform one for .area, .intersects or an sjoin. The real cost is downstream and human: branching logic, defensive if geom.geom_type == ... code, and the row-count changes an explode forces on your keys. If throughput is the concern, the win is in I/O rather than geometry types — see speeding up GeoPandas with pyogrio and Arrow.
Should I explode before or after a dissolve?
After, if at all. dissolve() unions each group and returns a single geometry per group, which is usually a MultiPolygon — exploding beforehand only inflates the row count that dissolve then collapses again. Explode afterwards when the question is about parts rather than groups: "how many disconnected pieces does each land-use zone have?" is an explode-then-groupby(...).size() question, while "what is the total zone area?" needs no explode at all, because .area on a MultiPolygon already sums its parts.
How do I keep single and multi geometries in the same file on purpose?
Use a format without a layer-level type contract. GeoJSON and GeoParquet accept any mixture with no configuration. GeoPackage will accept it too, but only if the layer is created with an untyped geometry column — pass geometry_type="Unknown" to to_file() with the pyogrio engine, otherwise the driver infers a concrete type from the first rows and rejects, or silently promotes, everything that disagrees with it.