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.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
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

Three panels: an overlay producing polygons plus an edge line, exploded parts, and the filtered polygonal result.
Two polygons sharing an edge legitimately intersect in a line β€” that is where the stray type comes from.

Step-by-step solution

Vertical steps: count types, explode, classify, filter or split, promote, write.
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())

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.

Grid comparing shapefile, GeoPackage, GeoJSON and Parquet on mixed geometry support.
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=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.
  • 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.

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.