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.

Which writers accept which mixture of geometry types A matrix of five output targets against four column contents. Shapefile writes single polygons and a Polygon plus MultiPolygon mixture, which it merges into one shape type, but errors on a polygon mixed with lines or points and cannot store a GeometryCollection. GeoJSON and GeoParquet accept all four mixtures because they impose no layer-level type. GeoPackage writes single polygons, promotes a mixture of Polygon and MultiPolygon, and needs an explicitly Unknown layer geometry type for mixed dimensions or collections. A PostGIS column typed as MultiPolygon rejects every mixture that still contains a single-part Polygon, a line, or a collection. The mix only matters where the target has one type slot per layer Polygon only single-part Polygon + MultiPolygon Polygon + Line or Point GeometryCollection mixed inside a row ESRI Shapefile (.shp) GeoJSON (.geojson) GeoPackage (.gpkg) GeoParquet (.parquet) PostGIS column typedgeometry(MultiPolygon) writes cleanly merged to one type driver error not representable writes cleanly writes cleanly writes cleanly writes cleanly writes cleanly promoted to multi needs Unknown type needs Unknown type writes cleanly writes cleanly writes cleanly writes cleanly type mismatch fails on the singles type mismatch type mismatch GeoJSON and GeoParquet never complain — which is why a mixed column can travel a whole pipeline unnoticed. Audit the column at the point it is produced, not at the point it finally fails.
Only the last column of a pipeline usually enforces a type — so the mixture is created early and discovered late.

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

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.

Choosing between explode, promote-to-multi and dropping parts A decision tree starting from an audit that reports more than one geometry type in the column. The first question asks whether all the parts share one dimension. If they do not, because a collection or a stray line is present, the answer is to split by dimension, keeping only the areal parts with keep_geom_type on overlay or an isin filter on geom_type. If they do share a dimension, a second question asks whether one row must keep meaning one feature. If yes, promote every single-part geometry to its multi equivalent so identifiers and row counts stay intact, which is also mandatory for a typed PostGIS column. If no, explode with index_parts equals False so every part becomes its own row for per-part measurement. geom_type.value_counts() > 1 row Do all the parts share one dimension? (every part areal, or every part linear) Drop the odd parts overlay(..., keep_geom_type=True) geom_type.isin(["Polygon", ...]) boundary lines are noise, not features Must one row keep meaning exactly one feature? Explode to parts .explode(index_parts=False) per-part area, one type per row row count grows · ids repeat Promote to multi promote_to_multi=True row count and ids unchanged required by a typed PostGIS column no · a collection or a stray line yes no yes
Explode when parts are the unit of analysis; promote when the row is the unit of record; drop when the extra parts were never features to begin with.

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.

Row-level before and after view of GeoDataFrame explode Two tables side by side. Before exploding, three rows: parcel P-07 holding a MultiPolygon of two parts, parcel P-08 holding a single Polygon, and parcel P-09 holding a GeometryCollection of a polygon and a line. After explode with index_parts equal to False, five rows: P-07 appears twice as two separate Polygons, P-08 is unchanged, and P-09 appears twice, once as a Polygon and once as a LineString highlighted in red because it must still be filtered out by dimension. The footer notes the row count went from three to five and that parcel_id is no longer unique, so a later attribute join keyed on it would fan out. One row per feature becomes one row per part before · 3 rows after · .explode(index_parts=False) · 5 rows idxparcel_idgeometry idxparcel_idgeometry 0P-07 MULTIPOLYGON · 2 parts 1P-08 POLYGON 2P-09 GEOMETRYCOLLECTION part count is invisible from the table — and from every attribute-only summary 0P-07 POLYGON 1P-07 POLYGON 2P-08 POLYGON 3P-09 POLYGON 4P-09 LINESTRING explode Exploding fixes the type but breaks the key: parcel_id is no longer unique, so the next attribute join fans out. The LineString survives the explode — only a geom_type filter removes it.
Exploding trades one problem for another: every row now holds a single-part geometry, but the identifier column no longer identifies a row.

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.

Reading a geom_type audit on a production parcel layer A console panel shows the output of geom_type.value_counts on a real parcel layer: Polygon 1942, MultiPolygon 318, GeometryCollection 7, LineString 2. Each line is annotated to its right. Polygon rows are single-part and safe for every writer. MultiPolygon rows are islands or split parcels that must be promoted or exploded. The seven GeometryCollection rows are overlay leftovers that must be filtered before export. The two LineString rows should never exist in a parcel layer at all and should be dropped. A footer notes the layer holds 2269 rows that become about 2704 single-part polygons after exploding. >>> parcels.geom_type.value_counts() Polygon 1942 MultiPolygon 318 GeometryCollection 7 LineString 2 Name: count, dtype: int64 single-part · accepted by every writer as-is islands and river-split parcels · promote or explode overlay leftovers · filter before any typed export never a parcel · drop by dimension, then investigate 2 269 rows in the layer become roughly 2 704 single-part polygons once exploded — plan the key change first.
Four lines of output tell you which of the three strategies each part of the layer needs.
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

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.