Hashing Spatial Fixtures for Content-Addressed Storage
A content hash turns a fixture from a filename into an identity. This guide sits beneath fixture versioning and provenance and shows how to compute one for spatial data with Shapely 2.x and GeoPandas 0.14+ that is stable across formats, drivers, machines and library versions — which is the whole requirement, and the reason hashing the file itself does not work.
The property to aim for is precise: two fixtures should hash the same if and only if they would produce identical test outcomes. Anything that changes the bytes without changing the outcome must be normalised away; anything that changes the outcome must be inside the digest.
Root cause: why hashing the file fails
Serialising the same GeoDataFrame twice can produce different bytes. Drivers embed timestamps and version strings, compression is not always deterministic, feature order can depend on an index, and coordinate precision follows a creation option that may not be pinned. Hash the file and the identifier changes when none of those affect a single assertion.
The reverse failure is worse. Two genuinely different fixtures can share a file hash if the difference lives somewhere the format does not record — a CRS that one tool wrote and another omitted, an attribute dtype that a driver widened on write. The identifier then claims two datasets are the same when a test would distinguish them.
Normalisation reference
| Component | Normalisation | Why |
|---|---|---|
| Geometry | shapely.normalize, then WKB |
Fixes ring winding and coordinate order |
| Precision | set_precision at the declared grid |
Two snappings are different data |
| Feature order | Sort by a stable key, then hash in order | Row order is not part of the data |
| CRS | crs.to_epsg(), an integer |
WKT varies by PROJ release |
| Column order | Sort column names | Reordered columns are the same data |
| Dtypes | Include the dtype string per column | A widened dtype is different data |
| Null representation | Canonicalise to one sentinel | None and NaN are the same absence |
Step-by-step implementation
The digest targets Shapely 2.x, GeoPandas 0.14+ and hashlib.
Step 1 — Normalise the geometry before serialising
normalize puts rings and coordinate sequences into a canonical order, so two representations of the same shape produce identical bytes.
import hashlib
import geopandas as gpd
import shapely
def geometry_digest(gdf: gpd.GeoDataFrame, grid_size: float) -> bytes:
geoms = shapely.set_precision(gdf.geometry.values, grid_size=grid_size)
geoms = shapely.normalize(geoms) # canonical winding + vertex order
h = hashlib.sha256()
for wkb in shapely.to_wkb(geoms, flavor="iso", include_srid=False):
h.update(wkb)
return h.digest()
Excluding the SRID from the WKB is deliberate: the CRS enters the digest once, as an authority code, rather than repeated per geometry in a form that varies by writer.
Step 2 — Digest the attributes deterministically
Column order and row order must both be normalised, and the dtype must be included so a widened column changes the identity.
import pandas as pd
def attribute_digest(df: pd.DataFrame, key: str) -> bytes:
frame = df.drop(columns=["geometry"], errors="ignore").sort_values(key)
h = hashlib.sha256()
for col in sorted(frame.columns):
h.update(col.encode())
h.update(str(frame[col].dtype).encode())
# A canonical string form: None and NaN both become the same token.
values = frame[col].map(lambda v: "\x00NULL" if pd.isna(v) else repr(v))
h.update("\x1f".join(values).encode())
return h.digest()
Step 3 — Combine into one fixture identity
Order the components and length-prefix them so no combination of contents can produce a collision by concatenation.
def fixture_id(gdf: gpd.GeoDataFrame, *, key: str, grid_size: float) -> str:
epsg = gdf.crs.to_epsg() if gdf.crs else None
if epsg is None:
raise AssertionError("fixture has no resolvable EPSG code — identity would be ambiguous")
parts = [
b"geom:" + geometry_digest(gdf.sort_values(key), grid_size),
b"attr:" + attribute_digest(gdf, key),
b"crs:" + str(epsg).encode(),
b"grid:" + repr(grid_size).encode(),
]
h = hashlib.sha256()
for p in parts:
h.update(len(p).to_bytes(4, "big")) # length-prefix: no concatenation collisions
h.update(p)
return "sha256:" + h.hexdigest()
Step 4 — Prove the properties you claim
The digest makes two claims, and both are testable. Round-tripping through a different format must not change it; changing anything a test observes must.
def test_hash_is_stable_across_formats(tmp_path, parcels):
a = fixture_id(parcels, key="parcel_id", grid_size=0.001)
parcels.to_file(tmp_path / "p.gpkg", driver="GPKG")
parcels.to_file(tmp_path / "p.fgb", driver="FlatGeobuf")
for name in ("p.gpkg", "p.fgb"):
back = gpd.read_file(tmp_path / name)
assert fixture_id(back, key="parcel_id", grid_size=0.001) == a, name
def test_hash_changes_when_the_data_does(parcels):
a = fixture_id(parcels, key="parcel_id", grid_size=0.001)
moved = parcels.copy()
moved.loc[moved.index[0], "geometry"] = moved.geometry.iloc[0].buffer(0.01)
assert fixture_id(moved, key="parcel_id", grid_size=0.001) != a
Verify the fix
Run the two property tests together and confirm they fail for the right reasons when broken:
pytest -q tests/test_fixture_hash.py -v
Remove the normalize call and the stability test should fail while sensitivity still passes. Hash only the geometry and the sensitivity test should fail on the dtype perturbation. Those two failure modes are the ones a hand-written digest most often has.
Using the identity as a storage key
Once the digest is stable, it doubles as a storage key, and that is what makes content-addressed storage work. A fixture is written to a path derived from its own hash, so identical content is stored once regardless of how many suites reference it, and a fetch can verify what it received rather than trusting where it came from.
Two operational properties follow. Deduplication is automatic — two teams generating the same coverage from the same seed store one copy. And verification is local: recompute the digest from the fetched bytes and compare against the key that requested them, so a corrupted transfer or a mismatched cache entry fails immediately instead of producing a test run against unknown data.
The one thing to avoid is deriving the key from a path that happens to include a hash. The reference must be the hash, so that moving, renaming or re-uploading the artefact cannot break the identity — which is exactly the failure a filename-based scheme reintroduces.
Deciding what the digest should be sensitive to
The normalisation choices above are not universal — they encode a judgement about which differences matter, and that judgement belongs to the pipeline rather than to the hashing function. Three decisions come up on every project and each has a defensible answer in both directions.
Should vertex count matter? Two polygons describing the same area with different numbers of collinear vertices are geometrically identical and computationally different: one costs more to process and may simplify differently. If the suite tests simplification or performance, vertex count must be inside the digest and normalize alone is not enough. If it tests only shape, removing collinear vertices before hashing makes the identifier more stable.
Should attribute order within a row matter? Never — a row is a mapping, not a sequence, and sorting column names before digesting is the right normalisation everywhere.
Should the feature count matter when the content is otherwise identical? Always. Two fixtures where one contains a duplicated feature are different inputs to any uniqueness or aggregation test, and a digest that deduplicates before hashing would hide exactly that.
The general rule is to normalise away what the format varies and preserve what the data varies. Format variation — winding, coordinate order, column order, driver quirks — is noise. Data variation — a moved vertex, a widened dtype, an extra row, a different precision — is signal, and any of it that the digest cannot see is a class of change the identity system will silently miss.
Failure modes and edge cases
- Normalising too aggressively. Snapping to a coarser grid inside the digest makes two genuinely different fixtures hash the same. Normalise to the declared precision and include that precision in the digest.
- Row order left unspecified. Hashing features in whatever order they arrive makes the identifier depend on a read order that formats do not guarantee. Sort by a stable key, and assert that key is unique.
- Floating-point repr in the attribute digest.
reprof a float differs between platforms in the last digit for some values. Round to a declared precision, or format with an explicit specification. - Empty and null geometry.
POLYGON EMPTYand a null geometry are different states that some serialisations conflate. Canonicalise deliberately and document which you chose. - Hashing a lazily-read frame. A frame read with a bounding-box filter contains a subset, so its digest identifies the subset rather than the file. Record the read parameters alongside, or read fully before hashing.
- Changing the digest algorithm silently. Every stored fixture’s key becomes unreachable. Version the scheme in the identifier’s prefix so old and new can coexist during a migration.
Conclusion
A fixture hash is a claim that two datasets are interchangeable for testing purposes, and the claim is only as good as the normalisation behind it. Canonicalising geometry, expressing the CRS as a code, digesting attributes with their dtypes in a stable order, length-prefixing the components, and testing both stability and sensitivity produces an identifier that survives formats, drivers and machines — the foundation everything else in fixture versioning and provenance rests on.
Related
- Fixture Versioning and Provenance — the parent layer and the record this identifier anchors.
- Managing Large Spatial Fixtures with Git LFS — the alternative storage arrangement when repository workflow matters more.
- Recording Fixture Provenance Metadata in CI — emitting this identifier so a failing run can be attributed.
- Cross-Format Parity Testing — why the same data serialises differently, which is what this normalisation undoes.