Mixed Geometry Types Error When Saving a GeoDataFrame: How to Fix It
Problem statement
The frame is fine in memory. The write is not:
ValueError: Record's geometry type does not match collection schema's geometry type:
'LineString' != 'Polygon'
RuntimeError: Failed to write record: unable to write feature β geometry type
GeometryCollection not supported by driver ESRI Shapefile
Or the write succeeds and the file is wrong: a shapefile that should hold polygons contains only some of them, or a "polygon" layer whose points vanished.
Almost every file format stores one geometry type per layer. A GeoDataFrame has no such rule β it is a pandas column of Shapely objects, and nothing stops it holding polygons, lines and a GeometryCollection at once. The mismatch surfaces at write time, which is usually long after the step that introduced it.
Where the mixture comes from:
pd.concatof layers that are not the same geometry typeoverlay()orintersection(), where two polygons can meet along an edge and produce a linemake_valid(), which returns aGeometryCollectionwhen the repair yields several kinds of componentclip(), which can produce points where a boundary just touchesdissolve()mixingPolygonandMultiPolygon- a source dataset that genuinely contains more than one type
- empty or null geometries mixed in with real ones
Quick answer
To write a layer with a single geometry type:
- count the types:
gdf.geom_type.value_counts() - decide which type the output should be
- explode collections, then filter to the parts you want
- promote singles to multi-parts if the format needs consistency
- write to GeoPackage, which is far more tolerant than shapefile
import geopandas as gpd
print(gdf.geom_type.value_counts())
# 1. flatten any GeometryCollection into its component geometries
flat = gdf.explode(index_parts=False, ignore_index=True)
# 2. keep only the polygonal parts
polys = flat[flat.geom_type.isin(["Polygon", "MultiPolygon"])].copy()
print(f"kept {len(polys)} of {len(flat)} parts")
# 3. write
polys.to_file("data/out/parcels.gpkg", layer="parcels", driver="GPKG")
explode turns a multi-part or collection geometry into one row per part while keeping the attributes, so nothing is lost silently β you choose what to discard.
Where the mixture appears
Step-by-step solution
Find out what you actually have
import geopandas as gpd
print(gdf.geom_type.value_counts(dropna=False))
print("null geometries :", gdf.geometry.isna().sum())
print("empty geometries:", gdf.geometry.is_empty.sum())
collections = gdf[gdf.geom_type == "GeometryCollection"]
for geom in collections.geometry.head(3):
print([g.geom_type for g in geom.geoms])
Looking inside the collections is worth the two lines: a collection of [Polygon, LineString] from an overlay means something different from [Point, Point] left by a repair.
Explode collections and multi-parts
flat = gdf.explode(index_parts=False, ignore_index=True)
print(flat.geom_type.value_counts())
explode expands MultiPolygon into Polygon rows and GeometryCollection into one row per member, duplicating the attributes. index_parts=False keeps a flat index; set it to True when you need to know which part came from which original feature.
Filter to the type you want
POLYGONAL = ["Polygon", "MultiPolygon"]
LINEAR = ["LineString", "MultiLineString", "LinearRing"]
PUNCTUAL = ["Point", "MultiPoint"]
polys = flat[flat.geom_type.isin(POLYGONAL)].copy()
dropped = flat[~flat.geom_type.isin(POLYGONAL)]
if len(dropped):
print(f"dropping {len(dropped)} non-polygonal parts:")
print(dropped.geom_type.value_counts())
dropped.to_file("data/out/dropped_parts.gpkg", driver="GPKG") # keep for review
Writing the discarded parts to their own file makes the decision auditable. In an overlay result, those slivers of line are usually noise β but you want to have looked.
Split into several layers instead of discarding
A GeoPackage holds many layers, so you rarely have to throw anything away.
from pathlib import Path
import geopandas as gpd
GROUPS = {"polygons": POLYGONAL, "lines": LINEAR, "points": PUNCTUAL}
dest = Path("data/out/result.gpkg")
for layer, types in GROUPS.items():
part = flat[flat.geom_type.isin(types)]
if part.empty:
continue
part.to_file(dest, layer=layer, driver="GPKG")
print(f"{layer}: {len(part)} features")
For shapefiles, the same split means separate files β result_polygons.shp, result_lines.shp β because the format allows one type per file.
Promote singles to multi-parts when a schema demands it
Some consumers require every feature to be MultiPolygon, and some drivers are stricter than others about a layer that mixes Polygon with MultiPolygon.
from shapely.geometry import MultiPolygon, MultiLineString, MultiPoint
PROMOTE = {"Polygon": MultiPolygon, "LineString": MultiLineString, "Point": MultiPoint}
def promote(geom):
cls = PROMOTE.get(geom.geom_type)
return cls([geom]) if cls else geom
polys["geometry"] = polys.geometry.map(promote)
print(polys.geom_type.value_counts())
GDAL also offers this at write time:
polys.to_file("data/out/parcels.shp", driver="ESRI Shapefile",
engine="pyogrio", promote_to_multi=True)
Avoid the mixture at the source
Overlays are the usual origin, and keep_geom_type handles it there.
import geopandas as gpd
result = gpd.overlay(parcels, zones, how="intersection", keep_geom_type=True)
print(result.geom_type.value_counts())
keep_geom_type=True (the default in recent versions, with a warning when it drops anything) discards result parts whose type differs from the first input's. That is usually right β an edge-contact line between two parcels is not a parcel β but be aware it is a silent filter, so check the count.
The same applies to clip:
clipped = gpd.clip(parcels, boundary, keep_geom_type=True)
Handle nulls and empties before writing
before = len(gdf)
gdf = gdf[gdf.geometry.notna() & ~gdf.geometry.is_empty].copy()
print(f"dropped {before - len(gdf)} null/empty geometries")
An empty geometry has a type β Polygon with no coordinates β and some drivers accept it while others reject the record. Removing them explicitly avoids a difference in behaviour between formats.
Code examples
Example 1: a writer that splits by type automatically
from pathlib import Path
import geopandas as gpd
FAMILY = {
"Point": "points", "MultiPoint": "points",
"LineString": "lines", "MultiLineString": "lines", "LinearRing": "lines",
"Polygon": "polygons", "MultiPolygon": "polygons",
}
def write_by_geom_type(gdf: gpd.GeoDataFrame, dest, base_layer="data") -> dict:
dest = Path(dest)
dest.parent.mkdir(parents=True, exist_ok=True)
flat = gdf.explode(index_parts=False, ignore_index=True)
flat = flat[flat.geometry.notna() & ~flat.geometry.is_empty]
written = {}
for family, part in flat.groupby(flat.geom_type.map(FAMILY)):
if family is None:
print(f"skipping {len(part)} features of unmapped type")
continue
layer = f"{base_layer}_{family}"
part.to_file(dest, layer=layer, driver="GPKG")
written[layer] = len(part)
return written
for layer, n in write_by_geom_type(gdf, "data/out/result.gpkg", "parcels").items():
print(f"{layer}: {n} features")
Example 2: a strict single-type writer
import geopandas as gpd
FAMILIES = {
"polygon": ["Polygon", "MultiPolygon"],
"line": ["LineString", "MultiLineString", "LinearRing"],
"point": ["Point", "MultiPoint"],
}
def write_single_type(gdf, dest, family="polygon", layer="data", report_dropped=True):
wanted = FAMILIES[family]
flat = gdf.explode(index_parts=False, ignore_index=True)
flat = flat[flat.geometry.notna() & ~flat.geometry.is_empty]
keep = flat[flat.geom_type.isin(wanted)].copy()
drop = flat[~flat.geom_type.isin(wanted)]
if len(drop) and report_dropped:
print(f"dropping {len(drop)} parts: {drop.geom_type.value_counts().to_dict()}")
if keep.empty:
raise ValueError(f"no {family} geometries left to write")
keep.to_file(dest, layer=layer, driver="GPKG")
return {"written": len(keep), "dropped": len(drop)}
print(write_single_type(overlay_result, "data/out/parcels.gpkg", "polygon", "parcels"))
Example 3: keep the mapping from part back to feature
gdf = gdf.reset_index(drop=True)
gdf["feature_id"] = gdf.index
parts = gdf.explode(index_parts=True).reset_index()
parts = parts.rename(columns={"level_1": "part_index"})
print(parts[["feature_id", "part_index", "geometry"]].head())
print("features with >1 part:", (parts.groupby("feature_id").size() > 1).sum())
Keeping feature_id means you can aggregate the parts back later, or trace a suspicious sliver to the feature it came from.
Example 4: a pipeline assertion
def assert_single_geom_type(gdf, allowed, label=""):
types = set(gdf.geom_type.dropna().unique())
unexpected = types - set(allowed)
if unexpected:
counts = gdf.geom_type.value_counts().to_dict()
raise ValueError(f"{label}: unexpected geometry types {unexpected} (counts: {counts})")
assert_single_geom_type(result, ["Polygon", "MultiPolygon"], "after overlay")
Asserting after each spatial operation localises the problem to the step that caused it, rather than to the write at the end.
Explanation
The Simple Features model that GIS formats implement defines a layer as a set of features sharing one schema, and the geometry type is part of that schema. A shapefile stores its type in the header β Polygon, PolyLine, Point β and writes every record against it. A GeoPackage records the type in gpkg_geometry_columns, though it accepts GEOMETRY as a permissive catch-all. GeoJSON, uniquely among the common formats, has no layer-level type at all.
A GeoDataFrame does not follow that model. Its geometry column is a GeoSeries of Shapely objects, and Shapely has no opinion about consistency between rows. That flexibility is genuinely useful during analysis β an intermediate result can hold whatever the operation produced β but it means the constraint is only enforced at the boundary, when the frame meets a format.
Overlay operations are the usual origin of a mixture, and for a good geometric reason: the intersection of two polygons is a polygon where their interiors overlap, but it is a line where they merely share an edge, and a point where they touch at a corner. All three are correct answers. keep_geom_type=True filters the result down to the input's type, which is what most workflows want, and modern GeoPandas warns when it drops something.
make_valid produces collections for a similar reason: repairing a self-intersecting polygon can leave both a polygonal area and a collapsed line, and returning both is more honest than picking one. Which means the correct handling in a pipeline is always the same shape β explode into parts, classify the parts, keep what belongs to the output's type, and record what was discarded. Doing it explicitly costs three lines and turns a write-time error into a documented decision.
Edge cases or notes
- GeoJSON accepts anything: A mixed FeatureCollection is valid GeoJSON, so a write can succeed there and fail on the same data as a shapefile.
PolygonplusMultiPolygonis usually fine: Most drivers accept the mixture and promote as needed, but strict consumers may not. Promote explicitly when a schema is contractual.explodemultiplies attributes: Each part inherits the full attribute row, so sums over an attribute column will double-count. Aggregate back by the original id.- Empty geometries have a type: An empty
Polygonreportsgeom_type == "Polygon". Filter onis_emptyseparately. keep_geom_typeis a filter, not a repair: It discards parts silently unless you check the counts before and after.GEOMETRYlayers in GeoPackage: Writing a truly mixed layer is possible by declaring the generic type, but many consumers β including some desktop tools β handle it poorly.- Z and M dimensions count too: A layer mixing 2D and 3D geometries can be rejected even when the base types agree. Use
shapely.force_2d()to normalise.
Internal links
- Overlay Operations in GeoPandas: Union, Intersection, Difference Explained
- How to Remove Null and Empty Geometries in GeoPandas
- How to Fix Invalid Geometries in Python (GeoPandas)
- buffer(0) Does Not Fix My Invalid Geometry: What to Do Instead
- How to Read and Write GeoPackage Files in Python
- GeoPandas to_file() Fails on a Column Type: How to Fix It
FAQ
Why does my GeoDataFrame have mixed geometry types at all?
Because a GeoSeries imposes no constraint between rows. Overlays, clips, repairs and concatenation can all introduce a second type, and nothing complains until you write.
What is the difference between explode() and filtering?
explode() splits multi-part geometries and collections into one row per part; filtering then selects the parts you want. You almost always need both β filtering alone leaves collections intact.
Should I drop the non-matching parts or keep them?
Drop them from the main output, but write them to a review file first. Line and point fragments from an overlay are usually edge-contact artefacts, and a quick look confirms that before you commit.
How do I stop overlays producing extra types?
Pass keep_geom_type=True to overlay() and clip(). It filters the result to the first input's type. Check the feature count afterwards, since the filter is silent.
Can I store mixed geometries in one file?
GeoJSON allows it, and GeoPackage allows it with a generic GEOMETRY type. Shapefile does not. Even where it is allowed, separate layers are usually easier for consumers.
Why does a layer of Polygon and MultiPolygon sometimes fail?
Some drivers and consumers require a single declared type. Promote everything to the multi-part form, or pass promote_to_multi=True with the pyogrio engine.
How do I trace a stray fragment back to its source feature?
Add an id column before exploding, then use explode(index_parts=True). The resulting index tells you which original feature and which part each row came from.