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.
Implementation
# 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.
# 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.
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.
Related
- Schema Mapping for Legacy to Modern Formats — parent guide: the wider mapping problem
- Handling Null Values in Spatial Schema Mapping — the geometries that have no type at all
- WKB vs Native Arrow Geometry Encoding — why a single declared type unlocks the efficient encoding
- Converting GeoParquet to PMTiles with Tippecanoe — where a mixed layer becomes a styling problem