geofix
Fast, dependency-light GeoJSON validator and repairer for the command line, pre-commit hooks and CI. It checks files against RFC 7946 (structure, coordinates, winding, bounding boxes) and polygon topology (self-intersections, holes), tells you where each problem is, and can repair most of them, reporting every change it makes.
- Pure Python, no required dependencies (only
tomlion Python < 3.11 for readingpyproject.toml). Topology checks run without shapely or GEOS. - Streams large files: a
FeatureCollectionis read one feature at a time, and newline-delimited GeoJSON (GeoJSONSeq / RFC 8142,.geojsonl,.ndgeojson) one record at a time. - Made for CI: exit codes,
--format githubannotations,--format json,--select/--ignorerule codes,[tool.geofix]configuration, a pre-commit hook. - Explains CRS mistakes: "this looks like EPSG:3857 Web Mercator (the centre would be
lon 28.99762, lat 40.97734)", UTM/TM eastings, swapped
[lat, lon]axes. - Repairs safely:
geofix fixnever changes data without a recorded change, leaves valid files byte-for-byte untouched, and is idempotent.
pip install geofix # pure Python
pip install "geofix[shapely]" # + shapely.make_valid for complex topology repairs
Command line
geofix check
$ geofix check examples/bad.geojson
examples/bad.geojson:2:1: GF212 warning: exterior ring is clockwise [$.features[0].geometry.coordinates[0]]
hint: Reverse the ring (ogr2ogr -lco RFC7946=YES writes the right order).
examples/bad.geojson:3:1: GF220 error: ring crosses itself at (29.025, 41.005) [$.features[1].geometry.coordinates[0]]
hint: Split bow-ties into separate polygons or rebuild with shapely.make_valid.
examples/bad.geojson:4:1: GF109 warning: 1 consecutive duplicate position(s), first [29.05, 41] [$.features[2].geometry.coordinates[0][2]]
hint: Remove the repeated positions.
examples/bad.geojson:4:1: GF210 error: ring is not closed: first position [29.04, 41], last [29.04, 41.01] [$.features[2].geometry.coordinates[0]]
hint: Repeat the first position at the end of the ring.
examples/bad.geojson:4:1: GF108 info: 1 of 5 position(s) have more than 7 decimals [$.features[2].geometry.coordinates[0][3]]
hint: Round coordinates; 6 decimals is ~10 cm, 7 is ~1 cm (RFC 7946 section 11.2).
examples/bad.geojson:5:1: GF006 warning: id 'parcel-3' is also used by $.features[2] [$.features[3].id]
hint: Give every feature its own id, or drop ids that are not identifiers.
examples/bad.geojson:5:1: GF107 error: coordinates (x 3.228e+06..3.228e+06, y 5.009e+06..5.009e+06) look projected, not WGS 84 longitude/latitude: this looks like EPSG:3857 Web Mercator (the centre would be lon 28.99762, lat 40.97734) or a Gauss-Kruger/TM grid with zone-prefixed eastings [$.features[3].geometry.coordinates]
hint: Reproject to EPSG:4326, e.g. ogr2ogr -t_srs EPSG:4326 -lco RFC7946=YES out.geojson in.geojson.
examples/bad.geojson:6:1: GF106 warning: latitude 129.0 is out of range while every longitude is within +/-90: positions look like [lat, lon] [$.features[4].geometry.coordinates[0]]
hint: GeoJSON order is longitude first; swap x and y (EPSG:4326 axis order is a common cause).
examples/bad.geojson:7:1: GF200 warning: MultiPolygon has empty coordinates [$.features[5].geometry]
hint: Remove the feature, or use "geometry": null for features without a location.
examples/bad.geojson:1:1: GF301 error: "bbox" [29, 41, 29.03, 41.01] does not contain the coordinates (extent [29, 41, 3228000.5, 5009000.25]) [$.bbox]
hint: Recompute the bbox from the coordinates.
Found 10 issues (4 errors, 5 warnings, 1 info) in 1 of 1 file.
$ echo $?
1
Each line is file:line:column: CODE severity: message [JSON path]. The line is where the
feature (or record) starts; the JSON path points inside it. Directories are searched
recursively for .geojson, .geojsonl, .geojsons, .geojsonseq, .ndgeojson and
.geo.json files; - reads standard input.
Useful options: --select GF2,GF3, --ignore GF007,GF108 (codes or prefixes, ALL),
--fail-on error|warning|info, --precision N, --statistics, --no-hints,
--format text|json|github.
geofix fix
$ geofix fix examples/bad.geojson -o fixed.geojson --no-hints
examples/bad.geojson: fixed 9 problems (GF108 x1, GF109 x1, GF200 x1, GF210 x1, GF212 x2, GF220 x2, GF301 x1)
examples/bad.geojson:5:1: GF006 warning: id 'parcel-3' is also used by $.features[2] [$.features[3].id]
examples/bad.geojson:5:1: GF107 error: coordinates (x 3.228e+06..3.228e+06, y 5.009e+06..5.009e+06) look projected, not WGS 84 longitude/latitude: this looks like EPSG:3857 Web Mercator (the centre would be lon 28.99762, lat 40.97734) or a Gauss-Kruger/TM grid with zone-prefixed eastings [$.features[3].geometry.coordinates]
examples/bad.geojson:6:1: GF106 warning: latitude 129.0 is out of range while every longitude is within +/-90: positions look like [lat, lon] [$.features[4].geometry.coordinates[0]]
Applied 9 fixes to 1 file. Found 3 remaining issues (1 error, 2 warnings) in 1 of 1 file.
What it did: rewound the clockwise shell, split the bow-tie into a two-part MultiPolygon,
removed the duplicate vertex, closed the open ring, rounded the over-precise coordinate,
removed the feature with an empty geometry and recomputed the collection bbox. It did
not guess a CRS or swap axes: those are reported for a human to decide.
geofix fix FILEwrites the result to standard output (the report goes to stderr);-o OUTwrites a file;-i/--in-placerewrites inputs only when something changed;--dry-runreports what would change and exits 1 if anything would.--show-changeslists every change instead of a per-file summary;--exit-nonzero-on-fixexits 1 when a file was modified;--no-shapelykeeps repairs pure Python.- Collections are written one feature per line (readable diffs);
--indent Npretty-prints single objects. Collection members keep their order, exceptbbox, which is written afterfeaturesbecause it can only be computed once every feature has been repaired. - Repairs run in this order: round (GF108), drop duplicates (GF109), close rings (GF210),
drop degenerate holes/parts (GF211/GF214), topology (GF220-GF222), rewind (GF212/GF213),
recompute bboxes (GF300-GF302). A second
geofix fixon the output changes nothing. - Rounding to the configured precision (7 decimals by default, ~1 cm) is a repair like any
other: disable it with
--ignore GF108or change it with--precision 9.
geofix stats and geofix rules
$ geofix stats examples/bad.geojson
examples/bad.geojson
kind: FeatureCollection
size: 1.2 kB
features: 6
geometry types: Polygon 3, Point 1, LineString 1, MultiPolygon 1
null / empty: 0 / 1
positions: 18 (0 with z)
max decimals: 10
bbox: [29, 41, 3.228e+06, 5.009e+06]
ids: 6 (5 unique)
properties: name (5)
read in: 0.00 s
geofix rules lists every rule (--format text|json|markdown).
Exit codes
| Status | Meaning |
|---|---|
| 0 | No issue at or above --fail-on (default error) |
| 1 | Issues at or above --fail-on; also fix --dry-run / --exit-nonzero-on-fix when a fix is (or was) needed |
| 2 | Usage or configuration error, or an input that cannot be read |
Warnings do not fail a run by default; use --fail-on warning (or fail-on = "warning" in
the configuration) for strict RFC 7946 conformance, e.g. ring winding.
Rules
| Code | Name | Severity | Checks | geofix fix |
|---|---|---|---|---|
| GF000 | invalid-json | error | The input is not valid UTF-8 JSON. | report only |
| GF001 | invalid-type | error | Missing or unknown "type" member. | report only |
| GF002 | missing-member | error | A required member is missing (Feature "geometry"/"properties", FeatureCollection "features", geometry "coordinates", GeometryCollection "geometries"). | Adds "properties": {} or "geometry": null to features that lack them. |
| GF003 | invalid-member | error | A member has the wrong JSON type (e.g. "features" is not an array, a feature is not a Feature object, "geometry" is not an object or null). | report only |
| GF004 | invalid-properties | error | "properties" must be a JSON object or null. | report only |
| GF005 | invalid-id | error | Feature "id" must be a string or a number. | report only |
| GF006 | duplicate-id | warning | Feature "id" is not unique within the collection. | report only |
| GF007 | foreign-member | info | Member that RFC 7946 does not define for this object (a foreign member). | report only |
| GF008 | reserved-member | error | Member that changes GeoJSON semantics on this object type (RFC 7946 section 7.1), e.g. "coordinates" on a Feature or "properties" on a geometry. | report only |
| GF009 | crs-member | warning | "crs" member present; RFC 7946 removed it and requires WGS 84 longitude/latitude. | Removes "crs" when it already names WGS 84 (CRS84 / EPSG:4326). |
| GF010 | nested-geometry-collection | warning | GeometryCollection nested inside another GeometryCollection (RFC 7946 3.1.8 says SHOULD avoid). | report only |
| GF100 | invalid-coordinates | error | "coordinates" has the wrong nesting depth or holds non-numeric values. | report only |
| GF101 | position-too-short | error | Position has fewer than two numbers. | report only |
| GF102 | position-too-long | warning | Position has more than three numbers (RFC 7946 3.1.1: SHOULD NOT). | report only |
| GF103 | non-finite-coordinate | error | Coordinate is NaN or Infinity (non-standard JSON literals, or overflowing numbers). | report only |
| GF104 | longitude-out-of-range | error | Longitude outside [-180, 180]. | report only |
| GF105 | latitude-out-of-range | error | Latitude outside [-90, 90]. | report only |
| GF106 | swapped-axes | warning | Coordinates look like [lat, lon] instead of [lon, lat]. | report only |
| GF107 | projected-coordinates | error | Coordinates look projected (metres: EPSG:3857, UTM, national TM grids), not WGS 84. | report only |
| GF108 | excessive-precision | info | Coordinates carry more decimals than the configured precision (default 7, ~1 cm). | Rounds longitude/latitude to the configured precision. |
| GF109 | duplicate-vertex | warning | Consecutive duplicate positions in a line or ring. | Drops consecutive duplicate positions. |
| GF110 | mixed-dimensions | info | Geometry mixes 2D and 3D positions. | report only |
| GF200 | empty-geometry | warning | Geometry has no coordinates (or a GeometryCollection has no geometries). | Removes features whose geometry is empty from collections and sequences. |
| GF201 | null-geometry | info | Feature has "geometry": null (allowed, but many tools skip such features). | report only |
| GF202 | line-too-short | error | LineString has fewer than two positions. | report only |
| GF203 | zero-length-line | warning | LineString whose positions are all identical. | report only |
| GF210 | unclosed-ring | error | Linear ring's first and last positions differ. | Appends the first position to close the ring. |
| GF211 | ring-too-short | error | Linear ring has fewer than four positions. | Drops degenerate holes and polygon parts (always reported). |
| GF212 | exterior-clockwise | warning | Exterior ring is clockwise; RFC 7946 3.1.6 requires counter-clockwise. | Reverses the ring. |
| GF213 | hole-counterclockwise | warning | Interior ring (hole) is counter-clockwise; RFC 7946 3.1.6 requires clockwise. | Reverses the ring. |
| GF214 | zero-area-ring | error | Linear ring encloses no area (all positions collinear or collapsed). | Drops degenerate holes and polygon parts (always reported). |
| GF220 | self-intersection | error | Ring crosses or touches itself. | Splits single-crossing (bow-tie) rings in pure Python; other cases use shapely.make_valid when shapely is installed. |
| GF221 | hole-outside-shell | error | Hole is not inside its exterior ring (lies outside it or crosses it). | Rebuilt with shapely.make_valid when shapely is installed. |
| GF222 | holes-overlap | error | Two holes of the same polygon cross, overlap or nest. | Rebuilt with shapely.make_valid when shapely is installed. |
| GF230 | antimeridian-crossing | warning | Consecutive positions jump more than 180 degrees of longitude; the segment probably crosses the antimeridian. | report only |
| GF300 | invalid-bbox | error | "bbox" is not an array of 2*n numbers with min <= max (west > east is allowed across the antimeridian). | Recomputes the bbox from the coordinates. |
| GF301 | bbox-mismatch | error | "bbox" does not contain all coordinates of the object. | Recomputes the bbox from the coordinates. |
| GF302 | bbox-not-tight | info | "bbox" is larger than the extent of the coordinates. | Recomputes the bbox from the coordinates. |
Notes on the checks:
- Topology is planar, in the coordinate space of the file, like the OGC simple feature rules: within a ring any contact between non-adjacent edges is an error (crossing, touching, or a collinear spike); between rings, crossings and shared edges are errors, while a single touching point is allowed. Overlap between the parts of a MultiPolygon is not checked.
- Antimeridian: RFC 7946 3.1.9 asks for geometries crossing 180 degrees to be cut into
multi-part geometries. GF230 flags segments that jump more than 180 degrees of longitude;
geofix does not cut them (use
ogr2ogr -wrapdatelineor theantimeridianpackage). Abboxwith west > east is accepted as an antimeridian-crossing box (RFC 7946 5.2). - CRS heuristics (GF106/GF107) look at each geometry's extent. Values beyond +/-1000 are
treated as metres; eastings of 100 000-900 000 m suggest UTM or a national TM grid (for
example TUREF/TM), values within the Web Mercator square are back-projected so you can see
where the data would land if it were EPSG:3857.
[lat, lon]is only detectable when a latitude falls outside +/-90 while every longitude is within +/-90. - Precision (GF108) counts decimals of the shortest representation of each longitude and latitude (elevations are not checked).
Configuration
geofix reads [tool.geofix] from the nearest pyproject.toml (or --config FILE;
--isolated ignores it). Command-line options override it.
[tool.geofix]
select = ["ALL"] # codes or prefixes, e.g. ["GF0", "GF2"]
ignore = ["GF007"] # foreign members are fine for us
fail-on = "warning" # error | warning | info
precision = 6 # GF108 threshold and fix rounding
shapely = true # let `geofix fix` use shapely.make_valid when installed
format = "text" # text | json | github
pre-commit
# .pre-commit-config.yaml
repos:
- repo: https://github.com/YusufEminoglu/geofix_sdk
rev: v0.1.0
hooks:
- id: geofix # validate
# - id: geofix-fix # or repair in place (the commit stops if a file changed)
Both hooks run on .geojson, .geojsonl, .geojsons, .geojsonseq and .ndgeojson files
and honour [tool.geofix]. Pass options with args, e.g. args: [--fail-on, warning].
GitHub Actions
# .github/workflows/geojson.yml
name: GeoJSON
on: [push, pull_request]
jobs:
geofix:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.13"
- run: pip install geofix
- run: geofix check --format github data/
--format github prints workflow commands
so issues appear as annotations on the pull request diff, at the line where each feature
starts:
::warning file=examples/bad.geojson,line=2,col=1,title=geofix GF212 (exterior-clockwise)::exterior ring is clockwise at $.features[0].geometry.coordinates[0]%0Ahint: Reverse the ring (ogr2ogr -lco RFC7946=YES writes the right order).
::error file=examples/bad.geojson,line=3,col=1,title=geofix GF220 (self-intersection)::ring crosses itself at (29.025, 41.005) at $.features[1].geometry.coordinates[0]%0Ahint: Split bow-ties into separate polygons or rebuild with shapely.make_valid.
Python API
import geofix
# Validate a parsed object, a JSON string/bytes, or a file (streamed).
issues = geofix.check({"type": "Point", "coordinates": [3228000.0, 5009000.0]})
issues = geofix.check_file("parcels.geojson", ignore=["GF007"], precision=6)
for issue in issues:
print(issue.code, issue.severity, issue.line, issue.path, issue.message, issue.hint)
# Repair. The input is never modified; every modification is a Change.
result = geofix.fix(my_feature_collection)
result.data # repaired object
result.changes # [Change(code="GF212", path="$.features[0]...", message="rewound ...")]
result.issues # what is still wrong after the repair
# Repair a file feature by feature (dst may equal src; None is a dry run; "-" is stdout).
result = geofix.fix_file("in.geojson", "out.geojson", use_shapely=False)
# Options can be shared.
opts = geofix.Options(select=["GF2", "GF3"], fail_on="warning", precision=7)
geofix.check_file("in.geojson", opts)
# Streaming building blocks.
with open("big.geojsonl", encoding="utf-8") as fh:
for path, feature in geofix.iter_features(fh):
...
stats = geofix.stats_file("in.geojson") # counts, bbox, property keys, max decimals
geofix.RULES["GF220"].summary # rule registry
geofix.Validator and geofix.Repairer are the incremental engines behind these functions
(feed them features one at a time); geofix.Reader produces the streaming events.
Performance
Pure Python 3.12 on an AMD Ryzen 4000-series laptop CPU (single thread, Windows), synthetic files (numbers vary with hardware; the test-suite only asserts generous limits):
| File | Size | Positions | check |
fix --dry-run |
|---|---|---|---|---|
| 100 000 convex polygons (10 vertices) | 37.6 MB | 1.10 M | 5.1 s | 5.5 s |
| 100 000 non-convex polygons, 10% with holes | 44.1 MB | 1.37 M | 7.9 s | 8.2 s |
| one ring, 1 000 000 vertices (ellipse) | 25.8 MB | 1.00 M | 4.6 s | 4.6 s |
| one ring, 1 000 000 vertices (wavy, 1 000 lobes) | 25.8 MB | 1.00 M | 8.1 s | 7.9 s |
Repairing every feature of the 100 000-polygon file (all clockwise and over-precise: 200 000 changes) and writing the result takes about 21 s; a second run finds nothing to do in 5.4 s.
How: positions are validated with C-level map/itemgetter passes; the precision test is a
regex over the repr of a whole coordinate list (exact round() only runs when it matches);
convex rings skip the segment search; larger rings are split into x/y-monotone chains (whose
own segments cannot meet) and only chains with overlapping boxes are bisected against each
other, as in GEOS, with a uniform grid for zig-zag data. Memory is bounded by the largest
single feature, not the file.
How geofix compares
| geofix | @mapbox/geojsonhint | geojson (Python) | shapely is_valid / make_valid |
ogr2ogr -lco RFC7946=YES |
|
|---|---|---|---|---|---|
| Runtime | Python, no required deps | Node.js | Python | Python + GEOS (bundled in wheels) | GDAL |
| RFC 7946 structure checks | yes | yes | partial (is_valid / errors()) |
no (geometry only) | no (it converts) |
| Winding order (right-hand rule) | check + fix | check | no | no (orient can fix) |
writes CCW/CW |
| Self-intersections, holes | check; fix bow-ties, rest via shapely | no | no | yes (GEOS) | no |
bbox, ids, foreign members, crs |
yes | partly | no | no | writes a new file |
| Projected-CRS / swapped-axes hints | yes | no | no | no | n/a (reprojects when told the CRS) |
| Reports what was changed | yes (per change) | n/a | n/a | no | no |
| Streams large files / GeoJSONSeq | yes | no | no | n/a | yes |
| CI output (exit codes, GitHub annotations) | yes | CLI only | no | no | no |
Use shapely/GEOS when you need full OGC validity semantics on arbitrary geometries (geofix
calls shapely.make_valid for cases it cannot repair itself), and GDAL when you need to
reproject or convert formats; geofix is the lightweight gate that tells you which of those
you need, in a form CI understands.
Development
python -m pip install -e ".[dev,shapely]"
python -m pytest
python -m ruff check . && python -m ruff format --check src tests
python -m mypy src
License
MIT, see LICENSE. Citation metadata: CITATION.cff.
Metadata
Release files for geofix 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| geofix-0.1.0.tar.gz | 79.0 kB | With provenance | Details for geofix-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| geofix-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for geofix-0.1.0-py3-none-any.whl |
Total release size: 137.1 kB
Release files / geofix-0.1.0.tar.gz
| Download URL | geofix-0.1.0.tar.gz |
|---|---|
| Size | 79.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
edd131f284d82ab8ba13a6d6586a2076cddc3f2fc3346b4a1601c0150d001dd4
|
|
BLAKE2b-256 checksum How to use checksums |
b20655fabf8ce7d9c35d0d08398185c563f2973bc66623d9c6661ae40cfe32c7
|
| 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 logRelease files / geofix-0.1.0-py3-none-any.whl
| Download URL | geofix-0.1.0-py3-none-any.whl |
|---|---|
| Size | 58.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
06e61871db35eaba152b0e34ca45f8cd77fb42cf73714cd5bd5adc41068941a4
|
|
BLAKE2b-256 checksum How to use checksums |
def45872fac3341cfef8f4cfdf5309ca9c9d75e5e32c64aa9eae6e4bfee5042f
|
| 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 logSecurity for geofix 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.
Attestation for geofix-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 logA 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.