Mapping Mixed Geometry Types to One Parquet Column

A GeoParquet geometry column may legally hold points, lines and polygons together — and almost every downstream consumer assumes it does not. The failures are quiet: an area sum that silently ignores the lines, a style rule that renders nothing, an encoder that falls back to its generic path. This page covers the three ways to resolve a mixed source and how to pick between them, extending schema mapping for legacy to modern formats.

Quick Reference

Source mixture Resolution Information lost Primary Use Case
Polygon + MultiPolygon Promote all to MultiPolygon None The common accidental mixture
LineString + MultiLineString Promote all to MultiLineString None Network and route layers
Point + Polygon + LineString Split into three tables None; gains clarity Genuine multi-purpose layers
Mostly polygons, a few stray points Split, then investigate the strays None; usually reveals a data bug Suspect sources
Deliberate heterogeneous collection Keep mixed, declare the types None, but readers must branch Annotation and markup layers

Profiling Before Deciding

The word “mixed” covers two very different situations, and the counts tell them apart immediately.

A layer that is 96% Polygon and 4% MultiPolygon is not really mixed — it is a polygon layer whose exporter emitted the simplest representation for each feature. Promoting every geometry to MultiPolygon produces a clean single-type column and loses nothing, because a one-part multipolygon is exactly equivalent to the polygon it wraps.

A layer that is 60% Polygon, 30% LineString, and 10% Point is genuinely mixed, and no promotion unifies it: a point is not a degenerate polygon in any useful sense. Those features are answering different questions and belong in different tables.

A layer that is 99.98% Polygon with 40 stray Point features is a third case, and the right first response is not a conversion decision but an investigation — stray types in an otherwise uniform layer are usually a data-entry defect and the counts are what surface it.

The type histogram tells you which of the three problems you have Three stacked bar profiles of geometry type counts. The first is dominated by polygons with a modest multipolygon share and implies promotion to a single multipolygon type. The second is split roughly evenly between polygons, lines and points and implies splitting into three tables. The third is almost entirely polygons with a barely visible sliver of points, which implies investigating those forty features as a probable data defect rather than making a conversion decision at all. Count features by type first — the shape of the histogram is the decision Polygon + MultiPolygon Polygon 96% → promote all to MultiPolygon; nothing is lost Genuine dimensional mix Polygon 60% Line 30% Pt 10% → split into three tables; they answer different questions Uniform with strays Polygon 99.98% → investigate the 40 stray points before converting anything The third case is a data-quality finding disguised as a schema question.

Implementation

python
# Requires: geopandas>=1.0, shapely>=2.0, pyarrow>=16  (Python 3.10+)
from __future__ import annotations

import json
from dataclasses import dataclass
from pathlib import Path

import geopandas as gpd
from shapely.geometry import MultiLineString, MultiPolygon

PROMOTABLE = {
    "Polygon": ("MultiPolygon", MultiPolygon),
    "LineString": ("MultiLineString", MultiLineString),
}
DIMENSION = {
    "Point": 0, "MultiPoint": 0,
    "LineString": 1, "MultiLineString": 1,
    "Polygon": 2, "MultiPolygon": 2,
}


@dataclass(frozen=True)
class TypeProfile:
    counts: dict[str, int]

    @property
    def total(self) -> int:
        return sum(self.counts.values())

    @property
    def dimensions(self) -> set[int]:
        return {DIMENSION[name] for name in self.counts if name in DIMENSION}

    def recommendation(self, *, stray_threshold: float = 0.001) -> str:
        if len(self.counts) == 1:
            return "single type — nothing to do"
        smallest = min(self.counts.values()) / max(self.total, 1)
        if len(self.dimensions) == 1:
            return "promote singles to their multi-part counterpart"
        if smallest < stray_threshold:
            return "investigate the stray features before converting"
        return "split by dimension into separate tables"


def profile(frame: gpd.GeoDataFrame) -> TypeProfile:
    if frame.empty:
        raise ValueError("nothing to profile")
    counts = frame.geometry.geom_type.value_counts().to_dict()
    unknown = set(counts) - set(DIMENSION)
    if unknown:
        raise ValueError(f"unhandled geometry types: {sorted(unknown)}")
    return TypeProfile({str(k): int(v) for k, v in counts.items()})


def promote(frame: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    """Wrap single-part geometries so the column has one declared type."""
    present = set(frame.geometry.geom_type.unique())
    if len({DIMENSION[t] for t in present}) != 1:
        raise ValueError(f"cannot promote across dimensions: {sorted(present)}")

    out = frame.copy()
    for single, (multi_name, multi_type) in PROMOTABLE.items():
        mask = out.geometry.geom_type == single
        if mask.any():
            # A one-part multi is exactly equivalent to the single it wraps.
            out.loc[mask, out.geometry.name] = out.loc[mask, out.geometry.name].apply(
                lambda g: multi_type([g])
            )
    return out


def split_by_dimension(
    frame: gpd.GeoDataFrame, out_dir: Path, stem: str
) -> dict[str, Path]:
    """One table per dimension, each with a single declared geometry type."""
    out_dir.mkdir(parents=True, exist_ok=True)
    written: dict[str, Path] = {}
    labels = {0: "points", 1: "lines", 2: "polygons"}

    for dimension, label in labels.items():
        mask = frame.geometry.geom_type.map(DIMENSION) == dimension
        subset = frame[mask]
        if subset.empty:
            continue
        target = out_dir / f"{stem}_{label}.parquet"
        promote(subset).to_parquet(target, compression="zstd", compression_level=3)
        written[label] = target
    return written

Validation

Assert a single declared type per output, and assert that no features were lost across the split. Both are one-liners and both catch real mistakes.

python
# Requires: pyarrow>=16, geopandas>=1.0 — one type per file, nothing dropped
import json
import geopandas as gpd
import pyarrow.parquet as pq

source_count = len(gpd.read_parquet("mixed.parquet"))
total = 0
for path in ("layer_points.parquet", "layer_lines.parquet", "layer_polygons.parquet"):
    frame = gpd.read_parquet(path)
    total += len(frame)
    types = set(frame.geometry.geom_type.unique())
    assert len(types) == 1, f"{path} still holds mixed types: {sorted(types)}"

    geo = json.loads(pq.read_schema(path).metadata[b"geo"])
    declared = geo["columns"]["geometry"]["geometry_types"]
    assert set(declared) == types, f"{path} declares {declared} but holds {sorted(types)}"

assert total == source_count, f"split lost {source_count - total} feature(s)"

Expected results: each output holds exactly one geometry type, the declared geometry_types list matches what is present, and the feature counts sum to the source exactly.

Splitting a mixed layer, with the count assertion that proves nothing was dropped A mixed source layer of two million features flows into a dimension split, producing three tables: polygons, lines and points, each with a single declared geometry type and its own feature count. An assertion below checks that the three counts sum to the source count exactly, which catches the common failure of a geometry type that no branch handled being silently discarded. mixed source 2,041,882 features 3 geometry types split by dimension layer_polygons — MultiPolygon 1,225,129 features layer_lines — MultiLineString 612,564 features layer_points — Point 204,189 features 1,225,129 + 612,564 + 204,189 = 2,041,882 ✓ The sum assertion is what catches a geometry type no branch handled being silently dropped. The type that falls through every split A GeometryCollection holding a point, a line and a polygon is shown with three handling options. Exploding it produces three features with a shared parent key, which most consumers handle. Keeping it intact preserves the grouping and is understood by almost nothing downstream. Rejecting it at ingest forces the decision upstream where the data was created. Doing nothing loses the feature silently in any dimension-based split. GeometryCollection — decide explicitly or lose it GeometryCollection point + line + polygon explode into 3 features shared parent key; most consumers cope keep intact grouping preserved; almost nothing reads it reject at ingest forces the decision upstream, where it belongs do nothing the feature vanishes from every output, silently

Edge Cases and Caveats

GeometryCollection. A collection nests heterogeneous parts inside one value, so splitting by top-level type does not resolve it. Decide explicitly: explode the collection into separate features, keep it and accept that most consumers will not handle it, or reject it at ingest. Whichever you choose, record the decision — an unhandled collection is the type that gets silently dropped by a map-based split.

Empty and null geometries. Neither has a type, so they fall through every branch and vanish from all outputs while the source count says they existed. Handle them explicitly before the split, in the same way the null handling rules require.

Splitting a layer whose attributes only make sense together. If the points are inspection sites for the polygons, splitting the geometry without carrying a join key breaks the relationship. Keep a stable feature key in every output — the same key the change-detection fingerprint relies on — so the tables can be rejoined.

Frequently Asked Questions

Is a mixed-geometry column valid GeoParquet?

Yes. The geometry column stores WKB and each value carries its own type code, so a single column can legally hold points, lines and polygons, and the geo metadata declares the list of types present. Validity is not the issue — what suffers is everything downstream: styling rules must branch on type, spatial predicates behave differently per dimension, and native Arrow encodings cannot use their most efficient typed representation.

When should I promote singles to multi-part instead of splitting?

When the mixture is only between a single and its multi-part counterpart — Polygon with MultiPolygon, LineString with MultiLineString. Promotion wraps each single geometry in a one-part collection, loses nothing, and yields a column with one declared type. Splitting is for genuine dimensional mixtures, where a point, a line and a polygon in the same table are answering different questions and no promotion unifies them.

What breaks if I leave a mixed column alone?

Nothing immediately, which is why mixed columns persist. Over time: area aggregations silently skip the non-polygon rows, length aggregations silently skip the non-line rows, tile styling needs a filter per layer, native Arrow encoding falls back to the generic representation, and any consumer that assumes a single type — which most do — produces plausible but incomplete answers. The failures are all quiet.


← Back to Schema Mapping for Legacy to Modern Formats