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.

Geometry-safe attribute flattening flow The feature collection is split into two parallel tracks: a geometry column pinned to a stable feature id, and a properties-only frame. Only the properties are flattened. The result is joined back to the pinned geometry on the same key, the original CRS is re-declared, and row alignment plus topology are validated before writing. Feature geometry + nested props Pinned geometry keyed by feature_id Properties only flatten dot-notation Flat attributes by feature_id Re-attach + CRS join on key · validate geometry untouched

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.

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

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

python
# 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.”

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

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