Mixed Geometry Types Error When Saving a GeoDataFrame: How to Fix It
On this page
Problem statement
The frame is fine in memory. The write is not. This is HM Land Registry's 21,253 Rutland parcels overlaid on Rutland's 15 wards (set up under Code examples), written to a shapefile with each of GeoPandas' two engines:
import pyogrio
print(overlay_result.geom_type.value_counts().to_dict())
for engine in ["pyogrio", "fiona"]:
path = Path(f"data/out/overlay_{engine}.shp")
try:
overlay_result.to_file(path, engine=engine)
except Exception as exc:
print(f"{engine}: {type(exc).__name__}: {str(exc)[:110]}")
if path.exists():
print(f" ...and it left {path.name} with {pyogrio.read_info(path)['features']:,} "
f"of {len(overlay_result):,} features")
{'Polygon': 21448, 'MultiPolygon': 334, 'GeometryCollection': 46, 'Point': 8, 'LineString': 4, 'MultiLineString': 2, 'MultiPoint': 1}
pyogrio: FeatureError: Could not add feature to layer at index 1815: Attempt to write non-polygon (LINESTRING) geometry to POLYGON ty
...and it left overlay_pyogrio.shp with 1,815 of 21,843 features
fiona: RuntimeError: GDAL Error: Attempt to write non-polygon (LINESTRING) geometry to POLYGON type shapefile.. Failed to write rec
...and it left overlay_fiona.shp with 1,815 of 21,843 features
The error is the good outcome. The file it leaves behind is the bad one: a readable, valid-looking shapefile with the first 1,815 features and nothing to say the other 20,000 are missing.
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
gdf = overlay_result
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")
Polygon 21448
MultiPolygon 334
GeometryCollection 46
Point 8
LineString 4
MultiLineString 2
MultiPoint 1
Name: count, dtype: int64
kept 23322 of 23621 parts
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
Explode, filter or split by type
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())
from collections import Counter
collections = gdf[gdf.geom_type == "GeometryCollection"]
for geom in collections.geometry.head(3):
print(dict(Counter(g.geom_type for g in geom.geoms)))
Polygon 21448
MultiPolygon 334
GeometryCollection 46
Point 8
LineString 4
MultiLineString 2
MultiPoint 1
Name: count, dtype: int64
null geometries : 0
empty geometries: 0
{'Polygon': 72, 'LineString': 15, 'Point': 7}
{'Polygon': 45, 'LineString': 15, 'Point': 5}
{'Polygon': 10, 'LineString': 6, 'Point': 1}
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())
Polygon 23322
LineString 243
Point 56
Name: count, dtype: int64
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
dropping 299 non-polygonal parts:
LineString 243
Point 56
Name: count, dtype: int64
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")
polygons: 23322 features
lines: 243 features
points: 56 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())
MultiPolygon 23322
Name: count, dtype: int64
GDAL also offers this at write time:
singles = flat[flat.geom_type.isin(POLYGONAL)]
for promote_flag in (False, True):
singles.to_file("data/out/singles.gpkg", layer=f"promote_{promote_flag}", driver="GPKG",
engine="pyogrio", promote_to_multi=promote_flag)
print(f"promote_to_multi={promote_flag}:",
pyogrio.read_info("data/out/singles.gpkg", layer=f"promote_{promote_flag}")["geometry_type"])
promote_to_multi=False: Polygon
promote_to_multi=True: MultiPolygon
Avoid the mixture at the source
Overlays are the usual origin, and keep_geom_type handles it there.
import warnings
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
default = gpd.overlay(parcels, wards, how="intersection")
print("default:", default.geom_type.value_counts().to_dict())
print(" ", str(caught[0].message))
result = gpd.overlay(parcels, wards, how="intersection", keep_geom_type=True)
print("keep_geom_type=True:", result.geom_type.value_counts().to_dict())
default: {'Polygon': 21462, 'MultiPolygon': 366}
`keep_geom_type=True` in overlay resulted in 396 dropped geometries of different geometry types than df1 has. Set `keep_geom_type=False` to retain all geometries
keep_geom_type=True: {'Polygon': 21462, 'MultiPolygon': 366}
Left at its default, keep_geom_type behaves as True and warns with a count when it drops anything; set explicitly to True, it drops the same parts without the warning. Either way it discards result parts whose type differs from the first input's. That is usually right β a point where a parcel corner touches a ward boundary is not a parcel β but an explicit True 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")
dropped 0 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.
Saving a Rutland parcels-and-wards overlay
The data used on this page
HM Land Registry's INSPIRE Index Polygons for Rutland (21,253 parcels) and the ONS December 2024 ward boundaries for Rutland at full resolution. overlay_result is their intersection with keep_geom_type=False β the setting that keeps every piece the intersection produces, which is where mixed types come from.
import io
import zipfile
from pathlib import Path
import geopandas as gpd
import pyogrio
import requests
pyogrio.set_gdal_config_options({"GML_DOWNLOAD_SCHEMA": "NO"})
Path("data/out").mkdir(parents=True, exist_ok=True)
if not Path("data/rutland_parcels.gpkg").exists():
reply = requests.Session().get("https://use-land-property-data.service.gov.uk/datasets/inspire/download/"
"Rutland.zip", timeout=600)
reply.raise_for_status()
zipfile.ZipFile(io.BytesIO(reply.content)).extract("Land_Registry_Cadastral_Parcels.gml", "downloads")
gpd.read_file("downloads/Land_Registry_Cadastral_Parcels.gml").to_file("data/rutland_parcels.gpkg")
if not Path("data/rutland_wards.gpkg").exists():
reply = requests.get("https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
"Wards_December_2024_Boundaries_UK_BFC/FeatureServer/0/query",
params={"where": "LAD24NM='Rutland'", "outFields": "WD24CD,WD24NM",
"outSR": 27700, "f": "geojson"}, timeout=120)
reply.raise_for_status()
gpd.read_file(io.BytesIO(reply.content)).to_file("data/rutland_wards.gpkg")
parcels = gpd.read_file("data/rutland_parcels.gpkg")[["INSPIREID", "geometry"]]
wards = gpd.read_file("data/rutland_wards.gpkg")
overlay_result = gpd.overlay(parcels, wards, how="intersection", keep_geom_type=False)
print(f"{len(parcels):,} parcels x {len(wards)} wards -> {len(overlay_result):,} pieces")
21,253 parcels x 15 wards -> 21,843 pieces
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")
parcels_lines: 243 features
parcels_points: 56 features
parcels_polygons: 23322 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"))
dropping 299 parts: {'LineString': 243, 'Point': 56}
{'written': 23322, 'dropped': 299}
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", "INSPIREID", "WD24NM"]].head())
print("features with >1 part:", (parts.groupby("feature_id").size() > 1).sum())
feature_id part_index INSPIREID WD24NM
0 0 0 32383481 Ketton
1 0 1 32383481 Ketton
2 0 2 32383481 Ketton
3 0 3 32383481 Ketton
4 0 4 32383481 Ketton
features with >1 part: 383
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")
try:
assert_single_geom_type(overlay_result, ["Polygon", "MultiPolygon"], "after overlay, keep_geom_type=False")
except ValueError as exc:
print(exc)
after overlay, keep_geom_type=False: unexpected geometry types {'Point', 'MultiPoint', 'LineString', 'GeometryCollection', 'MultiLineString'} (counts: {'Polygon': 21448, 'MultiPolygon': 334, 'GeometryCollection': 46, 'Point': 8, 'LineString': 4, 'MultiLineString': 2, 'MultiPoint': 1})
Asserting after each spatial operation localises the problem to the step that caused it, rather than to the write at the end.
Why a layer holds one geometry type
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 filters the result down to the input's type, which is what most workflows want; GeoPandas warns about what it dropped only when the argument is left at its default.
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.
Formats that accept a mixture, and ones that do not
- 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.
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.