Feature Branching for GIS Development Teams

Isolating spatial changes in a dedicated branch protects production data while a team edits geometries, updates attribute schemas, or integrates new basemaps — part of the broader discipline of branching and merge strategies for spatial datasets.


Prerequisites & Environment Setup

Spatial version control fails silently when contributors run different library versions or assume different CRS defaults. Standardise the following before creating any branch.


Core Algorithmic Patterns

1. Copy-on-Write Branch Isolation

Git’s object model stores each commit as a content-addressed snapshot. For binary spatial files tracked with Git LFS, the branch head holds a pointer (a 130-byte text file containing the SHA-256 and size of the binary). Creating a branch is therefore O(1) regardless of dataset size — no data is duplicated. The working tree materialises the full binary only on git lfs pull. This copy-on-write semantic means two branches can reference the same LFS object until one branch writes a new version, at which point LFS allocates a new object and updates the pointer in the next commit.

Spatial complexity note: branch creation is constant time, but git lfs pull scales with the uncompressed dataset size, not the number of branches. A 2 GB GeoPackage costs 2 GB of download per fresh checkout regardless of branch count.

2. Atomic Spatial Commit Pattern

Because standard git diff cannot interpret binary .gpkg content, each commit must be self-describing: the commit message must document which layers changed, which geometry operations were applied, and what the post-commit validation result was. The recommended pattern is to wrap every edit session in a validation script that emits a machine-readable summary, then embed that summary in the commit message body. Tools like spatial diff algorithms for polygon data can generate this summary automatically from the delta between the previous and current layer state.

Spatial complexity note: computing the full geometric delta between two GeoPackage snapshots is O(n log n) for sorted spatial index comparisons; restrict diffs to a bounding-box extent when working with national-scale datasets to keep CI run times under two minutes.

3. Validation-Gated Merge Pattern

The branch is only merge-eligible once a defined validation gate passes. The gate evaluates four orthogonal properties: geometry validity (ST_IsValid / shapely.is_valid()), CRS consistency (all layers share the target EPSG), attribute schema alignment (no new nullable columns without defaults, no column removals without migration scripts), and topology rules (no unintended overlaps or gaps above the precision tolerance). This pattern is the foundation for automated conflict detection in merge requests, which extends it with geometry intersection checks across branches.


Production Workflow Implementation

The diagram below shows the full lifecycle of a spatial feature branch from repository initialisation to post-merge verification.

Feature Branch Lifecycle — Spatial Datasets A flowchart showing five stages: Initialize & Tag Baseline, Create Feature Branch, Iterative Edits with Validation, Pre-Merge Spatial Diff, and Merge & Post-Merge Verification. Arrows connect each stage in sequence, with a feedback loop from the diff stage back to iterative edits when conflicts are found. Initialize & Tag Baseline v1.0.0-baseline Create Feature Branch feature/update-* Iterative Edits & Validation per-commit gate Pre-Merge Spatial Diff overlay + delta Merge & Verify full-baseline conflicts found → revise gate passes

Step 1 — Repository Initialisation & Baseline Anchoring

Configure LFS, verify the CRS, and tag the validated state before any branch work begins. Establish release tagging strategies for spatial basemaps at this point so every future branch has a rollback anchor.

git lfs track "*.gpkg" "*.tif" "*.geotiff"
git add .gitattributes
git commit -m "chore: configure LFS tracking for spatial assets"

# Record CRS and schema metadata
python - <<'EOF'
import geopandas as gpd, json, pathlib
gdf = gpd.read_file("data/boundaries.gpkg")
meta = {
    "crs": gdf.crs.to_epsg(),
    "columns": list(gdf.columns),
    "feature_count": len(gdf),
    "bounds": list(gdf.total_bounds),
}
pathlib.Path("SPATIAL_METADATA.json").write_text(json.dumps(meta, indent=2))
EOF

git add SPATIAL_METADATA.json
git commit -m "chore: record validated spatial baseline metadata"
git tag -a v1.0.0-baseline -m "Validated baseline — EPSG:4326, 14,322 features, topology clean"
git push origin main --tags

Step 2 — Isolated Branch Creation & Naming Conventions

Branch from main with a name that encodes intent, dataset, and scope. Good examples: feature/update-watershed-boundaries, fix/crs-alignment-parcel-data, refactor/simplify-landcover-polygons. Avoid opaque names like dev or spatial-update. After checkout, pull LFS objects and confirm the working tree matches the baseline.

git checkout -b feature/update-watershed-boundaries main
git lfs pull
# Verify the dataset integrity immediately after checkout
python - <<'EOF'
import geopandas as gpd
gdf = gpd.read_file("data/boundaries.gpkg", layer="watersheds")
invalid = gdf[~gdf.geometry.is_valid]
if not invalid.empty:
    raise SystemExit(f"Checkout produced {len(invalid)} invalid geometries — abort")
print(f"Branch baseline OK: {len(gdf)} features, CRS={gdf.crs.to_epsg()}")
EOF

Step 3 — Iterative Development with Spatial Validation

Commit frequently with atomic, descriptive messages. Each commit should leave the dataset in a valid state. A minimal per-commit validation script prevents topology regressions from accumulating silently across many commits.

# validate_branch.py — run before every commit
import sys
import geopandas as gpd
import json

LAYER = "watersheds"
TARGET_EPSG = 4326
SNAP_TOLERANCE = 1e-8

def validate(path: str) -> dict:
    gdf = gpd.read_file(path, layer=LAYER)

    # CRS check
    assert gdf.crs.to_epsg() == TARGET_EPSG, (
        f"CRS mismatch: expected EPSG:{TARGET_EPSG}, got {gdf.crs.to_epsg()}"
    )

    # Geometry validity
    invalid = gdf[~gdf.geometry.is_valid]
    if not invalid.empty:
        gdf.loc[~gdf.geometry.is_valid, "geometry"] = (
            gdf.loc[~gdf.geometry.is_valid, "geometry"]
            .buffer(0)  # standard repair for ring self-intersections
        )
        still_invalid = gdf[~gdf.geometry.is_valid]
        if not still_invalid.empty:
            raise ValueError(
                f"{len(still_invalid)} geometries could not be auto-repaired; "
                "review with QGIS geometry checker before committing"
            )

    # Attribute schema alignment
    required_cols = {"watershed_id", "area_km2", "basin_name", "source_date"}
    missing = required_cols - set(gdf.columns)
    if missing:
        raise ValueError(f"Required columns missing: {missing}")

    return {
        "feature_count": len(gdf),
        "invalid_repaired": len(invalid),
        "crs_epsg": gdf.crs.to_epsg(),
        "bounds": list(gdf.total_bounds),
    }

if __name__ == "__main__":
    result = validate(sys.argv[1] if len(sys.argv) > 1 else "data/boundaries.gpkg")
    print(json.dumps(result, indent=2))
    print("Validation PASSED — safe to commit")

Run it as a pre-commit hook:

# .git/hooks/pre-commit (make executable: chmod +x)
#!/bin/bash
python validate_branch.py data/boundaries.gpkg || exit 1

Step 4 — Pre-Merge Spatial Diff & Conflict Resolution

Before opening a merge request, compute the geometric delta between the feature branch and main. Standard git diff only shows that a binary pointer changed; it cannot reveal which polygons moved, which attributes changed, or whether new overlaps were introduced. Integrate the spatial diff algorithms for polygon data approach to produce a human-readable delta report.

# spatial_diff.py — compare branch layer against main baseline
import geopandas as gpd
from shapely.ops import unary_union

def spatial_diff_report(main_path: str, branch_path: str, layer: str) -> dict:
    main_gdf = gpd.read_file(main_path, layer=layer)
    branch_gdf = gpd.read_file(branch_path, layer=layer)

    # Identify added, removed, and modified features by ID
    main_ids = set(main_gdf["watershed_id"])
    branch_ids = set(branch_gdf["watershed_id"])

    added = branch_ids - main_ids
    removed = main_ids - branch_ids
    common = main_ids & branch_ids

    # Check geometry changes for common features
    modified_geom = []
    for fid in common:
        main_geom = main_gdf.loc[main_gdf["watershed_id"] == fid, "geometry"].iloc[0]
        branch_geom = branch_gdf.loc[branch_gdf["watershed_id"] == fid, "geometry"].iloc[0]
        if not main_geom.equals_exact(branch_geom, tolerance=1e-8):
            sym_diff_area = main_geom.symmetric_difference(branch_geom).area
            modified_geom.append({"id": fid, "sym_diff_area_deg2": sym_diff_area})

    return {
        "added_features": sorted(added),
        "removed_features": sorted(removed),
        "geometry_modified": modified_geom,
        "overlap_check": _overlap_check(branch_gdf),
    }

def _overlap_check(gdf: gpd.GeoDataFrame) -> list:
    """Return pairs of feature IDs whose geometries overlap (excluding shared boundaries)."""
    tree = gdf.sindex
    overlaps = []
    for idx, row in gdf.iterrows():
        candidates = list(tree.intersection(row.geometry.bounds))
        for cidx in candidates:
            if cidx <= idx:
                continue
            other = gdf.iloc[cidx]
            if row.geometry.overlaps(other.geometry):
                overlaps.append((row["watershed_id"], other["watershed_id"]))
    return overlaps

if __name__ == "__main__":
    import json, sys
    report = spatial_diff_report(sys.argv[1], sys.argv[2], "watersheds")
    print(json.dumps(report, indent=2))

When conflicts arise — typically when two branches edited the same polygon boundary — resolve at the dataset level: export both versions, run geopandas.overlay() with how='union', and reconcile attribute discrepancies in the resulting split polygons. Never force-merge binary spatial files without explicit validation of the resulting geometry. This process is examined in depth in resolving topology errors during branch merges.

Step 5 — Integration & Post-Merge Verification

After the merge request is approved and merged into main, run the full validation suite against the merged state, not just the changed layers.

# post_merge_verify.sh
#!/bin/bash
set -e

echo "Running post-merge spatial verification..."

python validate_branch.py data/boundaries.gpkg

# Confirm feature count did not drop unexpectedly
python - <<'EOF'
import geopandas as gpd, json, pathlib

gdf = gpd.read_file("data/boundaries.gpkg", layer="watersheds")
baseline = json.loads(pathlib.Path("SPATIAL_METADATA.json").read_text())

delta = len(gdf) - baseline["feature_count"]
if delta < -10:   # tolerate minor removals, flag large drops
    raise SystemExit(
        f"Feature count dropped by {abs(delta)} — "
        "possible data loss during merge; review before deploying"
    )
print(f"Post-merge feature count: {len(gdf)} (delta from baseline: {delta:+d})")
EOF

echo "Post-merge verification PASSED"

Code Reliability Patterns

Tolerance Snapping

Small coordinate rounding errors accumulate across successive spatial edits, eventually causing topology failures that are invisible to human reviewers but break ST_IsValid checks. Always snap coordinates to the agreed precision tolerance before committing:

from shapely import set_precision

# Snap to 1e-8 degrees (roughly 1 mm at equator for EPSG:4326)
gdf["geometry"] = gdf["geometry"].apply(
    lambda g: set_precision(g, grid_size=1e-8)
)

Rollback Logic

If a post-merge validation fails, revert to the baseline tag rather than attempting surgical fixes on the merged branch. Fast rollback prevents downstream consumers from ingesting corrupted data:

git revert HEAD --no-edit          # prefer revert over reset to preserve history
# or, if the merge commit can be safely abandoned:
git reset --hard v1.0.0-baseline   # only when no downstream tags reference the failed state
git lfs pull                        # re-materialise the binary from the baseline pointer

Error Handling for LFS Failures

LFS pull failures leave pointer files in place of binaries. Detect this before running any spatial analysis:

import pathlib

def assert_lfs_resolved(path: str) -> None:
    """Raise if the file is still an LFS pointer (not the real binary)."""
    with open(path, "rb") as f:
        header = f.read(14)
    if header == b"version https:":
        raise RuntimeError(
            f"{path} is an unresolved LFS pointer. "
            "Run `git lfs pull` and retry."
        )

Performance & Scale Considerations

Large spatial repositories introduce performance bottlenecks that do not appear in code-only repositories. The table below lists the most common hotspots and the corresponding mitigation.

Bottleneck Symptom Mitigation
Full LFS pull on every CI run Pipelines take 8–15 min even for small branches Cache the LFS objects directory between runs; only re-pull if the pointer hash changed
Spatial index missing after GeoPackage rebuild Queries and overlay operations 10–100× slower Always call gdf.to_file() then rebuild index: conn.execute("SELECT CreateSpatialIndex('layer', 'geom')")
Memory exhaustion when loading national-scale layers MemoryError during diff or validation Use bbox parameter in gpd.read_file() to load only the changed bounding box
Slow geopandas.overlay() on dense polygons Merge conflict resolution takes > 30 min Partition by administrative region, resolve per-partition, then concatenate
SQLite lock contention on GeoPackage OperationalError: database is locked in parallel CI jobs Use WAL journal mode: PRAGMA journal_mode=WAL; — see best practices for branching GeoPackage projects

For raster-heavy workflows, see large file handling in DVC for GIS for chunk-level caching strategies that avoid re-uploading unchanged tiles on every branch push.


Troubleshooting & Failure Modes

Symptom Root Cause Fix
git lfs pull downloads nothing; .gpkg is 130 bytes LFS remote not configured or authentication expired Run git lfs env to check the remote URL; re-authenticate with git lfs auth
shapely.is_valid() returns False after git merge Git merged the LFS pointer files rather than the binaries; resulting .gpkg is a concatenated pointer, not a valid SQLite file Never merge .gpkg with git merge directly; always use the spatial merge workflow (export → overlay → re-import)
CRS mismatch error on the feature branch but not on main A dependency library update silently changed the default pyproj authority Pin pyproj version in requirements.txt; add a CRS assertion to the pre-commit hook
Post-merge feature count dropped by hundreds A bounding box clip was applied during the overlay step using the wrong extent Revert the merge, re-run spatial_diff_report() to identify the extent mismatch, then redo the overlay with the correct bounding box
Pre-commit hook takes > 2 minutes on large GeoPackage Validation script loads the entire file into memory Switch to layer-level streaming with fiona.open() and validate only the features whose bounding boxes intersect the edit area
OperationalError: attempt to write a readonly database inside CI CI runner checks out the .gpkg without write permission Add chmod 644 data/*.gpkg to the CI setup step before running validation scripts

FAQ

Can Git LFS track GeoPackage files without corrupting the SQLite layer?

Yes. Git LFS stores a pointer in the repository and the full binary in remote LFS storage. Git never attempts to diff or merge the SQLite bytes directly, so the file is always either fully present or a pointer — no partial-write corruption can occur during checkout. The risk only arises if .gitattributes is misconfigured and Git tries to perform a text-based merge on the binary.

How many simultaneous feature branches are safe for a spatial repository?

There is no hard limit, but each active branch that modifies the same GeoPackage layer increases merge complexity non-linearly. Keep fewer than four concurrent branches touching the same dataset layer; use short-lived branches (days, not weeks) to reduce divergence. For larger teams, consider partitioning the dataset by geographic region so each branch owns a non-overlapping subset.

Should CRS transformation happen inside the feature branch or before branching?

Always resolve CRS to the target projection before creating the branch. A branch that changes both geometry and CRS is nearly impossible to diff meaningfully, and topology checks run against the wrong coordinate space until the reprojection commits land. Run pyproj.CRS.from_epsg(TARGET).equals(gdf.crs) as the first step of every pre-commit hook.

What is the right granularity for a feature branch commit?

Each commit should pass the validation baseline independently — geometry validity, CRS consistency, and attribute schema alignment. A commit that leaves the dataset in an invalid state blocks automated CI gates and obscures which edit introduced the error. Use git commit --no-verify only as a last resort, and always tag such commits explicitly so reviewers can investigate.

How do I handle a raster layer that is too large even for Git LFS?

Store the raster externally using DVC with a cloud remote (S3, GCS) and commit only the .dvc pointer file to the branch. The pointer file is plain text, diffs cleanly, and DVC’s cache layer means only changed chunks need to be re-uploaded on push. This approach also enables pointer synchronization for raster datasets across distributed team members.


Back to Branching & Merge Strategies for Spatial Datasets