Delta Tracking Algorithms for Vector Data
Effective version control for geospatial vector datasets requires moving beyond full-file snapshots. Part of the broader Geospatial Data Versioning Fundamentals & Architecture, this topic covers how to compute, store, and apply only the geometric and attribute differences between two states of a shapefile, GeoJSON file, GeoPackage layer, or PostGIS table.
Prerequisites & Environment Setup
Before implementing vector delta tracking, confirm each of the following:
Core Algorithmic Patterns
Vector deltas differ fundamentally from raster or point-cloud approaches. Raster deltas rely on tile-level binary diffs or run-length encoding; vector deltas must preserve topology, attribute schemas, and spatial relationships. Three patterns dominate production deployments.
Spatial Hashing and Bounding-Box Filtering
Before comparing any geometry, the algorithm partitions both baseline and target datasets using an R-tree index. Only features whose bounding boxes intersect undergo expensive geometric operations. This bounds the comparison cost from O(n²) to approximately O(n log n) for typical GIS workloads.
The critical tuning lever is grid-cell granularity. Cells that are too small increase index build time and produce many false-negative misses for adjacent features. Cells that are too large degrade back toward a full-table scan. A practical starting point is to set cell size to five times the median feature diameter in the dataset.
An important complement to bounding-box pre-filtering is geometry hashing: computing a deterministic hash (SHA-256) of each feature’s WKB representation lets the comparison step skip the expensive equals_exact call for geometrically identical features, reducing the modified-candidate set before any floating-point comparison occurs.
Topology-Aware Geometry Differencing
Using symmetric difference (A ⊕ B) and intersection operations, the algorithm classifies changes into four discrete categories:
- Added: features present in the target but absent in the baseline
- Removed: features present in the baseline but absent in the target
- Modified: features sharing a primary key but differing in geometry or attributes
- Unchanged: features passing exact equality checks across geometry and all attribute columns
For complex linework or polygon boundaries, topology validation is non-negotiable. Snapping vertices to a shared tolerance grid prevents phantom deltas caused by floating-point drift across coordinate transformations. Before running automated conflict detection in merge requests, every feature should pass this snapping step; otherwise the merge detector will flag non-changes as conflicts.
Attribute and Schema Diffing
Geometry changes rarely occur in isolation. Attribute differencing must handle type casting, null propagation, and schema evolution. A robust implementation tracks column additions, deletions, and renames alongside row-level updates. When schema drift occurs between versions, the delta payload must include a schema_migration block with explicit column operations rather than failing silently or corrupting downstream readers.
This is particularly important when coordinating with attribute reconciliation for tabular spatial data, where column-level merge conflicts require knowing exactly which version introduced a given schema change.
Production Workflow Implementation
A reliable delta pipeline follows a strict five-step sequence: ingest, index, compute, serialise, and validate. Skipping validation introduces silent corruption that compounds across subsequent commits.
Step 1 — Baseline Ingestion and Indexing
Load both the baseline and target datasets. Normalise both to the same projected CRS, repair invalid geometries, and verify that the primary key column is present and unique. Then build an R-tree spatial index on the baseline to accelerate lookup during the diff phase.
import geopandas as gpd
from shapely.validation import make_valid
PROJECTED_CRS = "EPSG:3857"
PRIMARY_KEY = "feature_id"
def load_and_normalise(path: str) -> gpd.GeoDataFrame:
gdf = gpd.read_file(path).to_crs(PROJECTED_CRS)
gdf.geometry = gdf.geometry.apply(make_valid)
assert gdf[PRIMARY_KEY].is_unique, f"Duplicate {PRIMARY_KEY} values in {path}"
return gdf.reset_index(drop=True)
For datasets that exceed available RAM, load them in spatial tiles using geopandas.read_file with the bbox parameter and process each tile independently.
Step 2 — Delta Computation with Spatial Indexing
Iterate the target dataset, querying the baseline R-tree for bounding-box candidates, then apply tolerance-based geometry comparison and attribute hashing. This is the core logic where all three algorithmic patterns converge.
import hashlib
import rtree
from shapely import snap
TOLERANCE = 1e-6
def _attr_hash(row: "pd.Series") -> str:
"""Deterministic hash of all non-geometry attribute values."""
payload = "|".join(str(v) for v in row.drop("geometry"))
return hashlib.sha256(payload.encode()).hexdigest()
def compute_vector_delta(
baseline: gpd.GeoDataFrame,
target: gpd.GeoDataFrame,
) -> dict:
baseline = baseline.copy()
target = target.copy()
# Snap to tolerance grid to eliminate floating-point phantom diffs
baseline.geometry = baseline.geometry.apply(lambda g: snap(g, g, TOLERANCE))
target.geometry = target.geometry.apply(lambda g: snap(g, g, TOLERANCE))
# Precompute attribute hashes for the baseline
base_attr = {
row[PRIMARY_KEY]: _attr_hash(row)
for _, row in baseline.iterrows()
}
# Build R-tree on baseline
idx = rtree.index.Index()
for i, geom in enumerate(baseline.geometry):
if geom and not geom.is_empty:
idx.insert(i, geom.bounds)
added, removed, modified = [], [], []
seen_ids: set = set()
for _, tgt_row in target.iterrows():
fid = tgt_row[PRIMARY_KEY]
geom = tgt_row.geometry
if not geom or geom.is_empty:
continue
candidates = list(idx.intersection(geom.bounds))
matched = False
for c_idx in candidates:
base_row = baseline.iloc[c_idx]
if base_row[PRIMARY_KEY] != fid:
continue
# Fast attribute check via hash; geometry check only when hash differs
attr_changed = (_attr_hash(tgt_row) != base_attr.get(fid))
geom_changed = not geom.equals_exact(base_row.geometry, tolerance=TOLERANCE)
if attr_changed or geom_changed:
modified.append({
"id": fid,
"type": "modified",
"attr_changed": attr_changed,
"geom_changed": geom_changed,
})
matched = True
seen_ids.add(fid)
break
if not matched:
added.append({"id": fid, "type": "added"})
baseline_ids = set(baseline[PRIMARY_KEY])
for fid in baseline_ids - seen_ids:
removed.append({"id": fid, "type": "removed"})
return {"added": added, "removed": removed, "modified": modified}
Step 3 — Serialisation
Write the delta payload to a compact format. JSON works well for human-readable audit trails; FlatBuffers or Protocol Buffers reduce payload size by 60–80% for large datasets. Include a schema version and the source commit hash in the payload header so downstream apply steps can verify compatibility before writing.
import json, datetime
def serialise_delta(delta: dict, baseline_hash: str) -> str:
payload = {
"schema_version": 1,
"baseline_commit": baseline_hash,
"generated_at": datetime.datetime.utcnow().isoformat() + "Z",
"tolerance": TOLERANCE,
"counts": {k: len(v) for k, v in delta.items()},
**delta,
}
return json.dumps(payload, indent=2)
Step 4 — Application and Validation
Apply the delta payload to a pristine copy of the baseline, then run topology and count checks before writing to the versioned store.
def apply_and_validate(
baseline: gpd.GeoDataFrame,
delta: dict,
target_count: int,
) -> gpd.GeoDataFrame:
result = baseline.copy()
base_ids = set(result[PRIMARY_KEY])
# Remove deleted features
removed_ids = {r["id"] for r in delta.get("removed", [])}
result = result[~result[PRIMARY_KEY].isin(removed_ids)]
# Guard: added IDs must not exist in baseline
added_ids = {a["id"] for a in delta.get("added", [])}
collision = added_ids & base_ids
if collision:
raise ValueError(f"Added IDs already exist in baseline: {collision}")
# Row-count assertion
expected = len(baseline) - len(removed_ids) + len(added_ids)
if expected != target_count:
raise AssertionError(
f"Row count mismatch: expected {target_count}, got {expected}"
)
return result
Always wrap the full apply step in a transaction so a validation failure rolls back without leaving a partially updated layer in the versioned store.
Code Reliability Patterns
Defensive programming is not optional in production delta pipelines. The following patterns address the most common failure modes.
Tolerance snapping before any comparison. Apply shapely.snap(geom, geom, TOLERANCE) to every geometry before hashing or calling equals_exact. This makes the tolerance grid explicit and deterministic rather than implicit in each comparison call.
Geometry validity guards. Call shapely.validation.make_valid immediately after loading. Invalid geometries (self-intersections, rings with fewer than four points) produce undefined results in difference operations and will silently corrupt delta payloads.
Primary key assertions. Assert uniqueness of the primary key column in both datasets at load time. A duplicated key causes the matching loop to record only the first hit, silently dropping all subsequent changes to that feature.
Rollback on validation failure. Never write the result of apply_and_validate to persistent storage before all assertions pass. Keep the candidate output in a temporary layer and promote it atomically after validation succeeds.
Exception isolation per feature. Wrap per-feature geometry operations in try/except blocks that log the offending feature_id and skip the feature rather than aborting the entire diff run. Accumulate errors into a warnings list in the delta payload for post-run review.
Performance and Scale Considerations
Vector delta computation scales non-linearly with feature count and vertex density. The following techniques keep pipeline latency acceptable at regional and national scales.
Extent pre-filtering. If only part of the dataset changed (e.g., a single municipality), restrict both the baseline and target reads to the bounding box of the change region using gpd.read_file(path, bbox=change_bbox). This is the single highest-impact optimisation for incremental workflows.
Incremental attribute hashing. Precomputing SHA-256 hashes for all baseline attribute rows at ingest time means the inner comparison loop only calls equals_exact when a hash differs, avoiding geometry parsing for unchanged features.
Parallel tile processing. Partition the dataset into spatial tiles and process each tile in a separate process using concurrent.futures.ProcessPoolExecutor. Tile boundaries must be padded by the feature diameter to avoid edge effects on features straddling tile edges.
Memory-mapped I/O for large layers. For GeoPackage or FlatGeobuf files exceeding available RAM, use fiona.open with chunked reading and stream results into the R-tree one tile at a time.
Benchmarking reference points. On a modern laptop (16 GB RAM, SSD) a Python implementation of the above pattern handles roughly 200,000 simple polygon features per minute for the compute step, and roughly 500,000 features per minute for attribute-only diffing. Vertex-dense layers (e.g., cadastral parcels at 1:500 survey grade) run 3–5× slower per feature; geometry simplification before differencing is worth considering for non-survey workflows.
Troubleshooting and Failure Modes
| Symptom | Root Cause | Fix |
|---|---|---|
Thousands of unexpected modified records between identical datasets |
Floating-point drift from a CRS reprojection | Snap both datasets to the same tolerance grid before comparison; ensure both inputs are projected to the same CRS before any operation |
Delta apply raises AssertionError: Row count mismatch |
Schema migration added rows or a primary key column contains nulls | Audit the primary key column for nulls; separate schema migrations from row-level delta payloads |
rtree.index.Index raises RTreeError: Illegal nesting of RTree operations |
R-tree accessed from multiple threads without a lock | Use multiprocessing instead of threading; each worker builds its own index from a serialised chunk |
make_valid returns a GeometryCollection instead of a Polygon |
Source geometry is severely self-intersecting (e.g., figure-eight ring) | Run ST_CollectionExtract(ST_MakeValid(...), 3) in PostGIS, or manually select only polygon-type sub-geometries from the collection |
| Delta payload grows larger than the full snapshot | Nearly every feature is flagged as modified | Check tolerance: if TOLERANCE is too strict relative to the data’s actual precision, a previous reprojection step introduced sub-tolerance coordinate noise — raise TOLERANCE one order of magnitude and re-run |
| Primary key collision error on delta apply | Added feature IDs already exist in the baseline due to a key-reuse bug in the upstream editor | Deduplicate IDs in the editor workflow; add a pre-merge check in the automated conflict detection gate |
FAQ
What is the difference between a full snapshot and a vector delta?
A full snapshot stores the complete geometry and attribute state at every version commit. A delta stores only the changed features — added, removed, or modified — relative to the previous state. For a dataset that changes 2% of its features per commit, a delta payload is 50× smaller than a full snapshot. Storage savings compound when large file handling in DVC for GIS tracks binary assets alongside the delta metadata.
How do I choose the right tolerance value for geometry comparison?
Use 1e-8 m for survey-grade data in a metric projected CRS, 1e-6 m for regional municipal mapping, and 1e-4 m for display-only or web-tile layers. The tolerance must be consistent across all commits in the repository. If you change the tolerance mid-lifecycle, the first delta run after the change will produce a large number of phantom modifications from the tolerance boundary shift. When in doubt, start conservative (1e-8) and relax if phantom diffs appear in unchanged areas.
Can this pipeline work with PostGIS instead of flat files?
Yes. Replace the geopandas R-tree pass with ST_Difference and ST_Equals calls executed server-side. PostGIS parallelises geometry operations natively and keeps the computation close to the data, avoiding large in-memory transfers. The attribute hashing step maps directly to md5(row_to_json(t)::text) over a CTE. The output can be written to a delta_log table with the same structure as the JSON payload described here.
How should schema changes between versions be handled?
Detect column additions, deletions, and renames before computing any row-level delta. Include a schema_migration array in the delta payload header, listing each operation in apply order (e.g., {"op": "add_column", "name": "source_date", "type": "date"}). Downstream apply steps should process schema migrations first, then row-level changes, in a single transaction. This pattern mirrors how spatial diff algorithms for polygon data handle structural changes before geometric ones.
What causes phantom diffs and how do I reliably prevent them?
Phantom diffs arise when floating-point rounding during CRS reprojection or geometry simplification produces coordinates that differ at sub-tolerance precision from the stored baseline, but that difference is larger than the tolerance threshold. The reliable prevention strategy has three parts: always reproject to a projected CRS before any comparison; snap all coordinates to a tolerance grid immediately after reprojection; and never compare features loaded from different projections within the same diff run. Using a consistent snapping grid also ensures that automated patching for minor geometry shifts does not re-apply patches that were themselves within tolerance.
Related
- Delta Compression Techniques for LiDAR Point Clouds — cross-domain delta strategies for high-density point data
- Spatial Diff Algorithms for Polygon Data — branch-level polygon differencing and merge preparation
- Automated Conflict Detection in Merge Requests — CI gate that consumes delta payloads to surface spatial conflicts
- Attribute Reconciliation for Tabular Spatial Data — resolving attribute-level conflicts after a delta-detected change
- Pointer Synchronization for Raster Datasets — complementary delta strategy for tile-based raster assets
Back to Geospatial Data Versioning Fundamentals & Architecture