Preserve Geometry While Flattening Nested Attributes Jump to heading
Flattening turns a nested GeoJSON properties object — owner.contact.email, assessment.history[0].value — into flat columns a relational schema can hold. The operation that ruins geospatial datasets is doing this to the whole feature: a json_normalize over the feature dict silently drops the geometry, or a reindex during the flatten quietly misaligns rows so parcel 12’s attributes end up on parcel 13’s polygon. The corruption is invisible until a downstream spatial join produces nonsense. This procedure sits inside Nested JSON/GeoJSON Flattening, which owns the dot-notation mapping contract; this page owns the narrower, higher-stakes job of getting the properties flat while the geometry stays byte-identical, row-aligned, correctly projected, and valid.
The governing rule is simple and load-bearing: never flatten geometry as data — detach it, flatten around it, re-attach it by a stable key. Geometry is not a property to normalize; it is a parallel column that must survive the operation untouched and land back on exactly the row it started on. Everything below enforces that invariant.
Prerequisites checklist Jump to heading
Step 1: Detach and pin geometry to a stable key Jump to heading
Before touching attributes, separate geometry into its own indexed structure bound to a stable key. Do not rely on positional row order to survive the flatten — an ordering operation deep in a normalize call will break it silently. Capture the CRS now, because a bare geometry array carries none.
# geopandas >=0.14, pyproj >=3.6 — Python 3.10+
import geopandas as gpd
from pyproj import CRS
def detach_geometry(gdf: gpd.GeoDataFrame, key: str) -> tuple[gpd.GeoSeries, CRS]:
"""Pin geometry to a stable key and capture the CRS before flattening."""
if key not in gdf.columns:
raise KeyError(f"stable key {key!r} missing — cannot guarantee alignment")
if gdf[key].duplicated().any():
raise ValueError(f"key {key!r} is not unique — re-attach would fan out rows")
if gdf.crs is None:
raise ValueError("input has no CRS — refusing to flatten geometry blind")
source_crs = CRS.from_user_input(gdf.crs)
geom = gpd.GeoSeries(gdf.geometry.values, index=gdf[key].values, crs=source_crs)
return geom, source_crs
The uniqueness check is not defensive padding: a duplicate key turns the re-attach join in Step 3 into a fan-out that multiplies rows and scrambles the geometry-to-attribute mapping. Fail loud here rather than corrupt silently later.
Step 2: Flatten only the properties Jump to heading
Run the flatten on an attribute-only frame that still carries the key but no geometry. Because geometry is not present, no operation in the flatten — reindex, sort, explode of a nested list — can drop or reorder it. Preserve the key column verbatim; it is the only link back to the right polygon.
# pandas >=2.1 — Python 3.10+
import pandas as pd
def flatten_properties(records: list[dict], key: str) -> pd.DataFrame:
"""Flatten nested properties to dot-notation columns; geometry is absent."""
# Each record is a feature's 'properties' plus its stable key — no geometry.
flat = pd.json_normalize(records, sep=".")
if key not in flat.columns:
raise KeyError(f"flatten dropped the stable key {key!r}")
if flat[key].duplicated().any():
# A nested-array explode can multiply rows; collapse or route to review.
raise ValueError(f"flatten produced duplicate {key!r} rows — nested list exploded")
return flat
If the nesting genuinely contains one-to-many arrays (an assessment history, multiple owners) and you intend to explode them, that is a schema-shape decision the parent Nested JSON/GeoJSON Flattening contract owns — and it means one geometry maps to many attribute rows, which you must model explicitly rather than let happen by accident.
Step 3: Re-attach geometry and re-declare CRS Jump to heading
Join the flattened attributes back to the pinned geometry on the stable key, then rebuild a GeoDataFrame and explicitly re-declare the captured CRS. A common corruption is a merge that reorders rows relative to the geometry array; joining on the key (not on position) is what makes the re-attach order-independent. Re-declaring the CRS is mandatory because geometry stripped to a bare array loses its CRS, and a GeoDataFrame with crs=None will later reproject from the wrong assumption.
# geopandas >=0.14, pyproj >=3.6 — Python 3.10+
import geopandas as gpd
from pyproj import CRS
def reattach(flat_df, geom: gpd.GeoSeries, key: str, source_crs: CRS) -> gpd.GeoDataFrame:
"""Rejoin flattened attributes to pinned geometry by key; restore CRS."""
geom_df = geom.rename("geometry").rename_axis(key).reset_index()
merged = flat_df.merge(geom_df, on=key, how="inner", validate="one_to_one")
out = gpd.GeoDataFrame(merged, geometry="geometry", crs=source_crs)
# Re-declaring is not the same as reprojecting: assert we did not move points.
assert out.crs == source_crs, "CRS drifted during re-attach"
return out
The validate="one_to_one" guard makes pandas raise if the join is not a clean row-for-row match — the earliest, cheapest place to catch a misalignment. Note that this restores the original CRS; any actual reprojection is a separate, later step governed by the CRS normalization workflow, not something to smuggle into a flatten.
Step 4: Validate alignment and topology Jump to heading
Flattening should not change a single coordinate, so prove it. Assert row-for-row alignment, that every geometry is still valid (repair with make_valid if a serialization round-trip nicked a ring), and that the CRS is exactly what you detached. This is the gate that turns “looks fine” into “verified intact.”
# geopandas >=0.14, shapely >=2.0 — Python 3.10+
import geopandas as gpd
from shapely import make_valid
def validate_preserved(out: gpd.GeoDataFrame, geom_before: gpd.GeoSeries,
key: str, source_crs) -> gpd.GeoDataFrame:
# 1. No rows gained or lost.
assert len(out) == len(geom_before), "row count changed during flatten"
# 2. Geometry landed on the right key (compare by key, not position).
aligned = out.set_index(key).geometry
ref = geom_before.reindex(aligned.index)
assert aligned.geom_equals_exact(ref, tolerance=0.0).all(), \
"geometry misaligned or altered — re-attach broke the mapping"
# 3. Topology intact; repair only if a round-trip introduced invalidity.
invalid = ~out.geometry.is_valid
if invalid.any():
out.loc[invalid, "geometry"] = out.loc[invalid, "geometry"].apply(make_valid)
# 4. CRS unchanged.
assert out.crs == source_crs, "CRS not preserved"
return out
geom_equals_exact with tolerance=0.0 is deliberately strict: flattening attributes must not move coordinates at all, so any deviation is a bug, not rounding. Route any feature that fails these checks to the dead-letter queue for failed feature transforms rather than writing a corrupted row, and record the flatten in your append-only lineage manifests.
Verification Jump to heading
Run the full detach → flatten → re-attach → validate cycle and confirm geometry emerged untouched.
geom, src_crs = detach_geometry(gdf, key="feature_id")
flat = flatten_properties(records, key="feature_id")
out = reattach(flat, geom, key="feature_id", src_crs)
out = validate_preserved(out, geom, key="feature_id", source_crs=src_crs)
# 1. Geometry column survived and is populated.
assert "geometry" in out.columns and out.geometry.notna().all(), "geometry dropped"
# 2. CRS is exactly the source — no silent reprojection.
assert out.crs.to_epsg() == src_crs.to_epsg(), "CRS changed during flatten"
# 3. Nested properties are now flat columns.
assert any("." in c for c in out.columns), "properties were not flattened"
A healthy run raises none of the guard errors, validate_preserved returns the frame unchanged (or with only genuinely repaired geometries), and ogrinfo -so out.gpkg layer reports the same SRS and feature count as the input.
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
| Geometry column missing from output | json_normalize run over the whole feature dict, dropping geometry |
Detach geometry first (Step 1); flatten only properties. |
| Attributes attached to the wrong polygon | Re-attach relied on row order, which a flatten reordered | Join on the stable key with validate="one_to_one", never on position. |
Output has crs=None, later reprojects wrong |
CRS not re-declared after rebuilding the GeoDataFrame | Capture CRS in Step 1; pass it to GeoDataFrame(..., crs=source_crs). |
| Row count exploded after flatten | A nested one-to-many array was normalized, fanning out rows | Decide the shape explicitly; model one-geometry-to-many-rows or collapse the array. |
geom_equals_exact fails on unchanged data |
Serialization round-trip nicked a ring or reordered coordinates | Inspect with make_valid; if genuinely altered, route the feature to the DLQ. |
Related Jump to heading
- Nested JSON/GeoJSON Flattening — the parent contract that defines the dot-notation mapping this procedure protects geometry through
- Flattening Deeply Nested GeoJSON Feature Collections Safely — handling deep nesting and one-to-many arrays in the properties themselves
- pyarrow vs pandas for Type Coercion at Scale — coercing the flattened attribute columns while keeping geometry out of the cast
- Building a Dead-Letter Queue for Failed Feature Transforms — where features that fail the alignment or validity gate are captured
- Lineage Manifest Generation — recording the flatten and its geometry-preservation checks as audit evidence