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.concat of layers that are not the same geometry type
  • overlay() or intersection(), where two polygons can meet along an edge and produce a line
  • make_valid(), which returns a GeometryCollection when the repair yields several kinds of component
  • clip(), which can produce points where a boundary just touches
  • dissolve() mixing Polygon and MultiPolygon
  • 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:

  1. count the types: gdf.geom_type.value_counts()
  2. decide which type the output should be
  3. explode collections, then filter to the parts you want
  4. promote singles to multi-parts if the format needs consistency
  5. 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

An intersection producing a polygon and an edge line, then exploded and filtered. where the stray type comes fromintersectionshared edge β†’ lineexplode3 parts, 2 typesfilter to polygonsone type per layerTwo polygons meeting along an edge intersect in a line β€” that is correct geometry spatialworkflow.io
Two polygons sharing an edge legitimately intersect in a line β€” that is where the stray type comes from.

Explode, filter or split by type

Turning a mixed-geometry GeoDataFrame into a single-type layer. make the type a decision1geom_type.value_counts()see the mixture2explode(index_parts=False)collections β†’ parts3filter to one familypolygon, line, point4write the dropped parts tooauditable5promote to multi if requiredstrict consumers6write the layerGeoPackageWriting the discards to their own file is what makes the choice reviewable spatialworkflow.io
Six steps that end in a layer whose type is a decision rather than an accident.

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.

How much geometry-type mixing each common format tolerates. one type per layer, mostlyShapefileGeoPackageGeoJSONmixed types in a layerβœ•no!GEOMETRY typeβœ“yesPolygon + MultiPolygon!yes, as Polygonβœ“yesβœ“yesGeometryCollectionβœ•noβœ“yesβœ“yesmany layers per fileβœ•noβœ“yesβœ•noA write can succeed as GeoJSON and fail on the same data as a shapefile spatialworkflow.io
How much mixing each format tolerates β€” and where the write will refuse.

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.
  • Polygon plus MultiPolygon is usually fine: Most drivers accept the mixture and promote as needed, but strict consumers may not. Promote explicitly when a schema is contractual.
  • explode multiplies 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 Polygon reports geom_type == "Polygon". Filter on is_empty separately.
  • keep_geom_type is a filter, not a repair: It discards parts silently unless you check the counts before and after.
  • GEOMETRY layers 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.

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.