Skip to main content

geojson-diff

CI PyPI Python License: MIT

Feature-level diffs of GeoJSON datasets for GIS and data teams that review changes in git, pull requests and data pipelines.

A line diff of a GeoJSON file tells you that line 18 412 changed. geojson-diff tells you that parcel 1042 got a new height, bus stop 77 moved 21 m, three buildings were removed and one was added - and it can say so in your terminal, in a pull-request comment, on a map, or through an exit code that fails a CI job.

  • Diffs FeatureCollections, GeoJSONSeq (RFC 8142) and newline-delimited GeoJSON.
  • Matches features by id, by property key(s), by geometry hash or spatially (proximity + attribute similarity, deterministic assignment), in a chain.
  • Classifies added / removed / modified / moved / unchanged.
  • Typed, nested property diffs with JSON Pointer paths.
  • Tolerance-aware geometry diffs in metres (haversine) or degrees: Hausdorff distance, vertex-level change counts, area and length deltas, bbox of changes.
  • Outputs text (coloured on a TTY), JSON (patch-like), Markdown (PR comments), a GeoJSON diff layer (simplestyle, renders on GitHub) and a self-contained HTML report with a Leaflet map.
  • Git diff driver / textconv, a git-geojson-diff REV1 REV2 PATH helper and CI exit codes with --fail-on thresholds.
  • Pure Python standard library, streaming reader, typed (py.typed). Optional shapely extra for exact equality and symmetric-difference areas.

Installation

pip install geojson-diff
pip install "geojson-diff[shapely]"   # optional: exact equality + symmetric-difference area

Python 3.9 or newer. No required dependencies.

Quick start

geojson-diff old.geojson new.geojson [--key id] [--tolerance 0.5m] \
    [--format text|json|md|geojson|html] [-o out]

Using the two files in examples/:

$ geojson-diff parcels_v1.geojson parcels_v2.geojson --tolerance 0.5m
geojson-diff parcels_v1.geojson -> parcels_v2.geojson
5 -> 5 features: 1 added, 1 removed, 1 modified, 1 moved, 2 unchanged
matched by id, hash; tolerance 0.5 m

+ added     6  Point
      name: "New cafe"
      amenity: "cafe"
- removed   3  Polygon
      name: "Old shed"
      height: 3
~ modified  1  Polygon
      height: 12 -> 14.5 (integer -> number)
      tags.use: "residential" -> "mixed"
      + tags.levels: 4
> moved     4  LineString
      + surface: "asphalt"
      geometry: Hausdorff 21.0 m, centroid shift 5.6 m, vertices 1 moved (4 -> 4), length +3.3 m

bbox of changes: [29.000000, 41.000000, 29.004500, 41.001800]
area delta -93.5 m2, length delta -35.7 m
$ echo $?
1

Bus stop 2 was nudged by about 9 cm, which is inside the 0.5 m tolerance, so it is reported as unchanged. Without --tolerance the comparison is exact and the stop shows up as moved.

How features are classified

Status Meaning
added only in the new file
removed only in the old file
modified matched, properties (or the feature id) changed, geometry within tolerance
moved matched, geometry changed beyond the tolerance (property changes are listed too)
unchanged matched, nothing changed

A geometry is unchanged when both versions have the same layout and every vertex stayed within the tolerance, or - when the vertex layout differs - when the Hausdorff distance between them is within the tolerance. Rotating a ring's start vertex, flipping its orientation or inserting a collinear vertex therefore does not count as a change. Z values are compared against the tolerance value in metres (exactly for degree tolerances).

Tolerances

--tolerance Meaning
0 (default) exact comparison
0.5m, 10cm, 5mm, 1km, 2ft metres; haversine on longitude/latitude data
1e-6deg, 0.00001°, 0.00001 coordinate units (degrees for RFC 7946 data)

Longitude/latitude data is detected automatically; projected data (coordinates outside +-180/+-90, or --crs projected) is measured in CRS units and reported with units instead of metres.

Matching strategies

--match takes an ordered chain; features that one strategy cannot match are offered to the next.

Strategy Matches on Notes
id the feature's top-level id 1 and "1" are the same key
key one or more properties (--key parcel_id, --key block --key lot) dotted paths for nested keys (meta.ref); id falls back to the feature id, @id always means the feature id
hash a normalised geometry hash rounded to --precision decimals (7); insensitive to ring start, orientation and part order
spatial centroid proximity + attribute similarity --max-distance 50m, --attribute-weight 0.5, --min-score 0.5; same geometry family only
auto (default) key if --key is given, else id then hash

Spatial matching scores every candidate pair as (1 - w) * (1 - distance / max_distance) + w * similarity and assigns pairs greedily by score, then distance, then input order, so the result is always the same for the same inputs. Duplicate keys are paired in input order and reported as warnings.

geojson-diff old.geojson new.geojson --key parcel_id
geojson-diff old.geojson new.geojson --key block,lot --ignore-property updated_at
geojson-diff old.geojson new.geojson --match id,hash,spatial --max-distance 25m

Property diffs

Nested objects are compared key by key and equal-length arrays element by element; paths are RFC 6901 JSON Pointers (/properties/tags/0). 1 and 1.0 are equal unless --strict-types is given; true and 1, or "1" and 1, never are, and type changes are reported (integer -> number). --ignore-property (repeatable, dotted for nested keys) skips noisy attributes such as timestamps.

Output formats

--format (or the extension of -o) selects the output:

Format Use
text terminal; coloured when stdout is a TTY (--color, NO_COLOR, FORCE_COLOR)
json machine-readable, JSON-Patch-like
md pull-request comments and CI job summaries
geojson a diff layer for QGIS, geojson.io or GitHub's map viewer
html a single-file report with a Leaflet before/after map

Markdown

$ geojson-diff parcels_v1.geojson parcels_v2.geojson -t 0.5m -f md
### GeoJSON diff: parcels_v1.geojson → parcels_v2.geojson

**4 feature changes** (5 → 5 features; matched by id, hash; tolerance 0.5 m)

| Added | Removed | Modified | Moved | Unchanged |
|---:|---:|---:|---:|---:|
| 1 | 1 | 1 | 1 | 2 |

bbox of changes 29.000000, 41.000000, 29.004500, 41.001800 · area Δ -93.5 m² · length Δ -35.7 m

<details open><summary>Feature changes (4)</summary>

| Change | Feature | Geometry | Details |
|---|---|---|---|
| added | 6 | Point | name: "New cafe"<br>amenity: "cafe" |
| removed | 3 | Polygon | name: "Old shed"<br>height: 3 |
| modified | 1 | Polygon | height: 12 → 14.5 (integer → number)<br>tags.use: "residential" → "mixed"<br>+ tags.levels: 4 |
| moved | 4 | LineString | + surface: "asphalt"<br>geometry: Hausdorff 21.0 m, centroid shift 5.6 m, vertices 1 moved (4 → 4), length +3.3 m |

</details>

<sub>Generated by geojson-diff</sub>

Every data value is escaped (HTML entities for Markdown syntax, @ and #), so a property value cannot inject markup, links, table columns or user mentions. --md-map appends the diff layer as a ```geojson block, which GitHub renders as an interactive map; --max-rows limits the table.

JSON

$ geojson-diff parcels_v1.geojson parcels_v2.geojson -t 0.5m -f json

The document holds options, a summary and one entry per change. Property changes use JSON Patch vocabulary with the old value and JSON types added:

{
  "op": "modify",
  "status": "modified",
  "key": "1",
  "old_index": 0,
  "new_index": 0,
  "matched_by": "id",
  "patch": [
    {"op": "replace", "path": "/properties/height", "old": 12, "old_type": "integer",
     "value": 14.5, "new_type": "number"},
    {"op": "replace", "path": "/properties/tags/use", "old": "residential",
     "old_type": "string", "value": "mixed", "new_type": "string"},
    {"op": "add", "path": "/properties/tags/levels", "value": 4, "new_type": "integer"}
  ],
  "geometry": {"changed": false, "old_type": "Polygon", "new_type": "Polygon",
               "units": "m", "vertices": 4},
  "area_delta": 0.0,
  "length_delta": 0.0,
  "bbox": [29.001, 41.001, 29.0014, 41.0013]
}

moved entries add {"op": "replace", "path": "/geometry", ...} to the patch and a geometry block with hausdorff, max_vertex_shift, centroid_shift, vertices_added/removed/moved, area_old/new/delta, length_old/new/delta, bbox and (with --shapely) symmetric_difference_area. added / removed entries carry the whole feature.

GeoJSON diff layer

-f geojson writes a FeatureCollection in which every changed feature is styled with simplestyle-spec properties (stroke, fill, fill-opacity, marker-color, title, description) - green for added, red for removed, orange for modified, blue for moved, with a grey before outline for moved features. GitHub renders .geojson files with that styling. The change itself is described by diff:status, diff:role (change or before), diff:key, diff:changed and diff:hausdorff.

HTML report

geojson-diff old.geojson new.geojson -t 0.5m -o review.html

A single file with summary cards, a Leaflet map (before and after layers, popups) and a table of changes. Leaflet 1.9.4 is loaded from unpkg with Subresource Integrity and OpenStreetMap tiles are attributed. Data is embedded as escaped JSON and rendered with textContent, and the table is HTML-escaped, so hostile property values cannot run script. The OpenStreetMap tile servers may refuse requests from file:// pages that send no referrer; serve the report over HTTP (for example as a CI artifact or python -m http.server) or pass your own tile server through the Python API (render(result, "html", tiles=..., attribution=...)).

Git integration

Readable git diff with a textconv filter

--textconv prints a stable, line-per-attribute rendering (features sorted by id, properties sorted by key, coordinates rounded), so the ordinary line diff becomes meaningful. Tell git which files to treat this way in .gitattributes:

*.geojson  diff=geojson
*.geojsons diff=geojson

and define the geojson diff driver once in your git configuration (run this yourself; it is per repository unless you add --global):

git config diff.geojson.textconv "geojson-diff --textconv"
git config diff.geojson.cachetextconv true

Feature-level git diff with an external diff driver

Instead of (or as well as) textconv, git can hand both versions to geojson-diff:

git config diff.geojson.command "geojson-diff --git --tolerance 0.5m"
git diff                 # uses the driver for *.geojson
git log -p --ext-diff -- data/parcels.geojson
git diff --no-ext-diff   # plain line diff when you need it

Git calls the command with path old-file old-hex old-mode new-file new-hex new-mode (nine arguments for renames); /dev/null stands for the missing side of an added or deleted file. In this mode geojson-diff always exits 0 unless the input is broken, because git aborts the whole diff when an external driver fails. Any option can be added to the command (--key, --format md, ...).

Diffing revisions: git-geojson-diff

git-geojson-diff HEAD~1 HEAD data/parcels.geojson
git-geojson-diff main data/parcels.geojson             # main vs. working tree
git geojson-diff v1.2 v1.3 data/parcels.geojson --format html -o review.html

Because the script is on your PATH as git-geojson-diff, git also runs it as git geojson-diff. A file missing at one revision counts as empty, so added and deleted files work. It takes every geojson-diff option and uses the same exit codes.

CI usage

Exit code Meaning
0 no changes (or none above the --fail-on thresholds)
1 changes found
2 error (unreadable or invalid input, bad arguments)
geojson-diff old.geojson new.geojson --fail-on removed            # fail only on removals
geojson-diff old.geojson new.geojson --fail-on removed:10,moved:0 # allow up to 10 removals
geojson-diff old.geojson new.geojson --fail-on any:5 -q           # any category above 5
geojson-diff old.geojson new.geojson --exit-zero -f md >> "$GITHUB_STEP_SUMMARY"

--fail-on takes added, removed, modified, moved or any, each with an optional :N allowance (exit 1 when the count exceeds N; default 0).

GitHub Actions: post the summary on pull requests

examples/github-actions/geojson-diff.yml diffs every changed *.geojson file between the pull-request base and head, posts a single Markdown comment (updated on later pushes), copies it to the job summary and fails the check when features were removed. The core of it:

      - name: Diff changed GeoJSON files
        id: diff
        shell: bash
        env:
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
        run: |
          set +e  # exit status 1 means "changes found", not a failure of this step
          status=0
          : > geojson-diff.md
          while IFS= read -r -d '' path; do
            git-geojson-diff "$BASE_SHA" HEAD "$path" \
              --format md --title "GeoJSON diff: $path" \
              --tolerance 0.5m --fail-on removed --max-rows 50 >> geojson-diff.md
            rc=$?
            echo >> geojson-diff.md
            if [ "$rc" -eq 2 ]; then echo "::error file=$path::geojson-diff failed"; status=2; fi
            if [ "$rc" -eq 1 ] && [ "$status" -eq 0 ]; then status=1; fi
          done < <(git diff -z --name-only "$BASE_SHA" HEAD -- '*.geojson' '*.geojsons')
          cat geojson-diff.md >> "$GITHUB_STEP_SUMMARY"
          echo "status=$status" >> "$GITHUB_OUTPUT"

      - name: Post the summary on the pull request
        if: github.event.pull_request.head.repo.full_name == github.repository
        env:
          GH_TOKEN: ${{ github.token }}
          PR: ${{ github.event.pull_request.number }}
        run: gh pr comment "$PR" --body-file geojson-diff.md --edit-last --create-if-none

The checkout needs fetch-depth: 0 and the job pull-requests: write; see the full file for the remaining steps.

Python API

from geojson_diff import DiffOptions, diff, render, diff_layer

result = diff(
    "old.geojson",
    "new.geojson",
    keys=["parcel_id"],              # or match=["id", "hash", "spatial"]
    tolerance="0.5m",
    ignore_properties=["updated_at"],
)

print(result.counts)        # e.g. {'added': 1, 'removed': 1, 'modified': 1, 'moved': 1, 'unchanged': 2}
print(result.has_changes, result.bbox, result.area_delta)

for change in result.changes:            # unchanged features are kept with keep_unchanged=True
    print(change.status, change.key, change.matched_by)
    for prop in change.properties:
        print("  ", prop.op, prop.path, prop.old, "->", prop.new, prop.old_type, prop.new_type)
    if change.geometry and change.geometry.changed:
        print("   hausdorff", change.geometry.hausdorff, "m")

markdown = render(result, "md", max_rows=50)
html = render(result, "html", title="Parcel update 2026-11")
layer = diff_layer(result)                    # dict, ready for json.dump
payload = result.to_dict()                    # the JSON output as a dict

diff() accepts paths, "-" (stdin), open files, parsed GeoJSON objects or plain iterables of features. Lower-level helpers are public as well: compare_features, compare_geometry, diff_properties, hausdorff_distance, haversine, geometry_area, geometry_length, geometry_hash, iter_features, parse_tolerance.

Large files

The reader decodes one feature at a time: the features array of a FeatureCollection is streamed, as are GeoJSONSeq and newline-delimited files. The old file is indexed by key; when the first strategy is id, key or hash, the new file is streamed against that index and unchanged features are dropped as soon as they are compared, so memory grows with the old file plus the changes. Geometry metrics are computed only for geometries that actually changed. On a laptop, two 66 MB FeatureCollections with 100 000 polygons each diff in about 8 seconds, most of it JSON decoding.

Optional shapely extra

With pip install "geojson-diff[shapely]" and --shapely (use_shapely=True):

  • with an exact tolerance (0), geometries are compared for topological equality (shapely.equals), and
  • moved polygons report symmetric_difference_area (square metres via a local projection for longitude/latitude data).

Limitations

  • The Hausdorff distance is measured from every vertex to the other geometry's segments in both directions. It is exact for points; for lines and polygons it can slightly underestimate when the farthest point lies inside an edge rather than at a vertex.
  • Areas use a spherical model (WGS84 semi-major axis), accurate to well under 1 % for the parcel- to city-scale changes reviewed in practice.
  • moved means "geometry changed beyond tolerance"; a reshaped polygon is moved too.

Development

python -m pip install -e ".[dev,shapely]"
python -m pytest --cov=geojson_diff
python -m ruff check . && python -m ruff format --check src tests
python -m mypy src

License and citation

MIT License - see LICENSE. If you use geojson-diff in research, please cite it using CITATION.cff.

Author: Yusuf Eminoğlu

Metadata

Release files for geojson-diff 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for geojson-diff 0.1.0
File Size Uploaded Provenance Actions
geojson_diff-0.1.0.tar.gz 66.9 kB With provenance Details for geojson_diff-0.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for geojson-diff 0.1.0
File Interpreter ABI Platform Actions
geojson_diff-0.1.0-py3-none-any.whl Python 3 none any Details for geojson_diff-0.1.0-py3-none-any.whl

Total release size: 117.7 kB

Release files / geojson_diff-0.1.0.tar.gz

Download URL geojson_diff-0.1.0.tar.gz
Size 66.9 kB
Tags Source
SHA-256 checksum
How to use checksums
7a3f7873f1c91d7928618b50ffa0f23bfb6590a35b48ac2bd601bd738c1286bf
BLAKE2b-256 checksum
How to use checksums
efc184ec3ec0ccd12e669d36feafbc0bf007e0314bac384f86a0b8a3cb9786a7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 8, 2026.

Transparency log

Release files / geojson_diff-0.1.0-py3-none-any.whl

Download URL geojson_diff-0.1.0-py3-none-any.whl
Size 50.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
fdc9874af1d145f0bb2468c8b17f7d6a6038dec317942f1153c5d56b6f3a864a
BLAKE2b-256 checksum
How to use checksums
4f44833323ea09070f745cd1bb1e0809e14d686ac9c6ce466693a84159e645e6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 8, 2026.

Transparency log

Security for geojson-diff 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

Both files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for geojson_diff-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 8, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page