Best Practices for GeoJSON Validation
Invalid GeoJSON slips through most parsers and only fails later — a blank map tile, a wrong-hemisphere point, a spatial join that returns nothing. This guide builds a three-tier validator that catches structure, topology, and coordinate-system defects before they reach downstream code, so a bad file is rejected at ingestion rather than debugged in production. It is for anyone loading third-party or user-supplied .geojson into a Python pipeline. It sits under Shapefile & GeoJSON Parsing in Geospatial Data Ingestion & Processing Workflows, and pairs naturally with Automating Shapefile Cleanup with Python for the Shapefile side of the same problem.
Why This Approach / What Goes Wrong
The GeoJSON specification, RFC 7946, is stricter than the older 2008 draft most files were written against. It mandates WGS84 (EPSG:4326) coordinates in longitude-then-latitude order, requires exterior rings to wind counter-clockwise, and — critically — removed the crs member entirely. A file that parses cleanly as JSON can still violate every one of these rules, and the libraries that read it will usually let it through.
Three distinct failure classes hide behind a single "it loaded fine":
- Structural violations pass as JSON. A
FeatureCollectionmissing itstypekey, a geometry with the wrong coordinate array depth, or anullwhere a coordinate pair belongs is valid JSON but invalid GeoJSON.json.load()accepts all of it; the error only surfaces when something tries to build geometry from it. - Topological defects survive parsing. Self-intersecting polygons, unclosed rings, and zero-area slivers deserialize into geometry objects without complaint. They then crash tile renderers or silently corrupt area and intersection results — the same class of defect handled in Fixing Self-Intersecting Polygons Programmatically.
- CRS is assumed, not checked. A pre-RFC-7946 file may carry a legacy
crsmember declaring a projected system while the coordinates are labelled as lon/lat. Strip the member blindly and you inherit coordinates that are metres pretending to be degrees — the axis-order and CRS trap that dominates practitioner errors across Coordinate Reference System Transformations.
The fix is to validate in three tiers — structure, then topology, then CRS — and fail loudly at the first tier that breaks, rather than letting a tolerant parser paper over the problem.
Prerequisites
geojson>=3.0.0— RFC 7946 structural validation viais_valid/errors()shapely>=2.0.0— GEOS-backed geometry validity andexplain_validityijson>=3.2.0— streaming parse for files too large to load whole
python -m pip install "geojson>=3.0.0" "shapely>=2.0.0" "ijson>=3.2.0"
Install Shapely 2.0 or later — the vectorized is_valid and stable explain_validity signatures used below differ from Shapely 1.x, a gap detailed in Shapely 1.x vs Shapely 2.0 Vectorization.
Step-by-Step Implementation
1. Validate structure against RFC 7946 first. Before touching geometry, confirm the document is a well-formed GeoJSON object. The geojson library walks the schema — type keys, FeatureCollection shape, coordinate array depth — and reports precise errors, so a malformed file is rejected before any expensive Shapely parsing begins.
import json
import geojson
def check_structure(raw_document: dict) -> None:
"""Raise ValueError if the document violates RFC 7946 structure."""
parcels_fc = geojson.loads(json.dumps(raw_document))
if not parcels_fc.is_valid:
raise ValueError(f"RFC 7946 structure violation: {parcels_fc.errors()}")
with open("parcels.geojson", "r", encoding="utf-8") as fh:
parcels_raw = json.load(fh)
check_structure(parcels_raw) # raises on missing type keys, bad nesting, wrong depth
2. Validate topology per feature with GEOS. Convert each geometry dict into a native GEOS object via shape(), then let is_valid and explain_validity catch self-intersections, unclosed rings, and degenerate points that the structural pass cannot see. Log the feature index so a failure traces straight back to the source record.
from shapely.geometry import shape
from shapely.validation import explain_validity
def check_topology(features: list[dict]) -> list[dict]:
"""Return a list of {index, reason} for every invalid geometry."""
problems = []
for index, feature in enumerate(features):
geom_dict = feature.get("geometry")
if geom_dict is None: # a null-geometry feature is legal in RFC 7946
continue
parcel_geom = shape(geom_dict)
if not parcel_geom.is_valid:
problems.append({"index": index, "reason": explain_validity(parcel_geom)})
return problems
topology_problems = check_topology(parcels_raw.get("features", []))
# e.g. [{"index": 42, "reason": "Self-intersection[13.402 52.518]"}]
3. Audit the CRS and coordinate bounds. RFC 7946 pins coordinates to WGS84 lon/lat, so any surviving crs member signals a pre-standard file, and any coordinate outside [-180, 180] longitude or [-90, 90] latitude usually means projected metres were serialized without reprojection. Flag both rather than silently repairing them.
def check_crs_and_bounds(document: dict, features: list[dict]) -> list[str]:
"""Return human-readable warnings about CRS and out-of-range coordinates."""
warnings = []
if "crs" in document:
warnings.append(
"Legacy 'crs' member present. RFC 7946 assumes EPSG:4326 (WGS84). "
"Verify coordinates are lon/lat degrees before removing it."
)
for index, feature in enumerate(features):
geom = feature.get("geometry")
if not geom:
continue
# shape().bounds gives (minx, miny, maxx, maxy) in coordinate order (lon, lat)
minx, miny, maxx, maxy = shape(geom).bounds
if not (-180 <= minx <= maxx <= 180 and -90 <= miny <= maxy <= 90):
warnings.append(
f"Feature {index} coordinates fall outside WGS84 bounds "
f"({minx:.1f},{miny:.1f})-({maxx:.1f},{maxy:.1f}) — likely a projected CRS."
)
return warnings
4. Compose the tiers into one gate. Wire the three checks into a single function that stops at the first hard failure (structure) and collects soft warnings (topology, CRS) so a caller can decide whether to reject or repair. Realistic pipelines run this at ingestion and refuse to advance a file that does not pass tier one.
import logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
def validate_geojson(filepath: str) -> dict:
"""Three-tier validator: structure (hard) → topology + CRS (soft warnings)."""
with open(filepath, "r", encoding="utf-8") as fh:
document = json.load(fh)
check_structure(document) # hard fail on RFC 7946 structure
features = document.get("features", [])
topology_problems = check_topology(features)
crs_warnings = check_crs_and_bounds(document, features)
for problem in topology_problems:
logging.warning("Feature %(index)s invalid: %(reason)s", problem)
for warning in crs_warnings:
logging.warning(warning)
return {
"status": "pass" if not topology_problems and not crs_warnings else "warn",
"features_checked": len(features),
"invalid_geometries": len(topology_problems),
}
5. Catch the spec rules no library enforces. The three tiers above cover what geojson and GEOS know how to check, which is not the whole of RFC 7946. Four constraints slip past every validator in the stack and each has a distinctive downstream symptom.
The first is the JSON layer itself. Python's json module accepts NaN, Infinity and -Infinity as bare literals even though no JSON specification permits them — so a file that loads perfectly in Python is rejected outright by a browser's JSON.parse, by PostgreSQL's jsonb type, and by most JVM parsers. The same module silently keeps the last value when an object repeats a key, so a feature carrying two properties blocks loses one without a word. Both are one-argument fixes at load time.
import json
def strict_load(filepath: str) -> dict:
"""Load GeoJSON while rejecting the two things json.load tolerates but the spec does not."""
def reject_constant(literal: str):
raise ValueError(f"Non-JSON literal {literal!r} in document — NaN/Infinity are not valid JSON")
def reject_duplicates(pairs):
seen = {}
for key, value in pairs:
if key in seen:
raise ValueError(f"Duplicate key {key!r} — the earlier value would be discarded silently")
seen[key] = value
return seen
# utf-8-sig strips a byte-order mark, which RFC 7946 forbids but exporters still emit
with open(filepath, "r", encoding="utf-8-sig") as fh:
return json.load(fh, parse_constant=reject_constant, object_pairs_hook=reject_duplicates)
The remaining three are geometric. A position may hold two or three numbers — longitude, latitude, and optional elevation — and nothing more; a fourth element, usually a measure value carried over from a Shapefile with M values, is prohibited and makes GDAL and most JavaScript renderers read the elevation slot as garbage. A bbox member is allowed at any level but is never recomputed by a parser, so an edited file routinely ships a stale bounding box that a tile server trusts and clips against, blanking features that are genuinely present. And a geometry that crosses the antimeridian should be cut into two parts at ±180°; leave it whole and every renderer draws a band right across the globe, because the shortest path between longitude 179 and −179 is ambiguous in a flat coordinate list.
from shapely.geometry import shape
def check_spec_details(document: dict, features: list[dict]) -> list[str]:
"""Constraints RFC 7946 imposes that neither the geojson library nor GEOS tests."""
findings = []
def positions(coords):
"""Yield every position in an arbitrarily nested coordinate array."""
if coords and isinstance(coords[0], (int, float)):
yield coords
else:
for part in coords or []:
yield from positions(part)
for index, feature in enumerate(features):
geom = feature.get("geometry")
if not geom or geom.get("type") == "GeometryCollection":
continue
for position in positions(geom.get("coordinates")):
if len(position) > 3:
findings.append(f"Feature {index}: position of length {len(position)} — max is 3 (lon, lat, elevation)")
break
minx, _, maxx, _ = shape(geom).bounds
if maxx - minx > 180:
findings.append(f"Feature {index}: x-extent of {maxx - minx:.1f}° — probably an unsplit antimeridian crossing")
declared = document.get("bbox")
if declared and features:
union_bounds = shape({"type": "GeometryCollection", "geometries":
[f["geometry"] for f in features if f.get("geometry")]}).bounds
if not all(abs(a - b) < 1e-9 for a, b in zip(declared, union_bounds)):
findings.append(f"Declared bbox {declared} does not match computed {union_bounds} — stale after an edit")
return findings
The antimeridian test uses an x-extent heuristic rather than a hard longitude comparison, because a legitimately wide feature — a country-scale polygon — still stays under 180° of span, while a two-degree parcel that wraps the seam reports an extent of roughly 358°. That asymmetry makes the check reliable without a special case for large geometries.
6. Keep the validator cheap enough to run on every ingest. The cost of this pipeline is not the schema walk; it is building one GEOS object per feature. shape() constructs the geometry through Python — a dict traversal, a coordinate tuple per vertex, then a GEOS allocation — and on a file of a few hundred thousand parcels that dominates the wall clock by an order of magnitude over json.load. Two changes remove most of it. Shapely 2.0 exposes from_geojson, which hands the raw geometry text to GEOS and skips the Python object graph entirely; it needs GEOS 3.10.1 or newer, so guard on shapely.geos_version if the code has to run on older builds. And validity is a vectorized array operation in Shapely 2.0 — shapely.is_valid and shapely.is_valid_reason accept a whole NumPy array of geometries, so the per-feature Python loop from step 2 collapses into two calls.
import numpy as np
import shapely
from shapely import from_geojson
assert shapely.geos_version >= (3, 10, 1), "from_geojson requires GEOS 3.10.1+"
# Parse geometry text straight into GEOS — no intermediate Python dicts
geoms = np.array([
from_geojson(json.dumps(f["geometry"])) if f.get("geometry") else None
for f in features
], dtype=object)
valid = shapely.is_valid(geoms) # vectorized, one call
reasons = shapely.is_valid_reason(geoms[~valid]) # only for the failures
for position, reason in zip(np.flatnonzero(~valid), reasons):
logging.warning("Feature %s invalid: %s", position, reason)
Memory, not speed, is what actually caps the whole-document approach. A parsed GeoJSON document costs roughly eight to ten times its on-disk size once every coordinate is a Python float object inside a list, so a 400 MB file needs several gigabytes of headroom before a single geometry is built. That ratio, not any file-size rule of thumb, is what decides when to switch to the streaming pattern shown at the end of this guide.
Verification
Run the validator against a known-good file and a deliberately broken one, and assert on the returned summary. A clean GeoDataFrame export should report pass with zero invalid geometries.
# A minimal valid FeatureCollection (WGS84 lon/lat, closed CCW ring)
valid_fc = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {"parcel_id": "A-100"},
"geometry": {
"type": "Polygon",
"coordinates": [[[13.40, 52.51], [13.41, 52.51],
[13.41, 52.52], [13.40, 52.52], [13.40, 52.51]]],
},
}
],
}
with open("parcels_ok.geojson", "w", encoding="utf-8") as fh:
json.dump(valid_fc, fh)
report = validate_geojson("parcels_ok.geojson")
print(report) # {'status': 'pass', 'features_checked': 1, 'invalid_geometries': 0}
assert report["status"] == "pass"
assert report["invalid_geometries"] == 0
A passing test on a clean file proves very little — the tier that matters is the one that fires. Pair the happy path with a deliberately broken fixture per tier, because a validator that never rejects anything is indistinguishable from a validator that is not running. The bowtie polygon below is the canonical self-intersection: its ring crosses itself between the second and fourth vertex, which json.load and the structural tier both accept without comment.
bowtie_fc = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {"parcel_id": "B-200"},
"geometry": {
"type": "Polygon",
# vertices ordered so the ring crosses itself — a classic bowtie
"coordinates": [[[13.40, 52.51], [13.41, 52.52],
[13.41, 52.51], [13.40, 52.52], [13.40, 52.51]]],
},
}
],
}
with open("parcels_bowtie.geojson", "w", encoding="utf-8") as fh:
json.dump(bowtie_fc, fh)
broken = validate_geojson("parcels_bowtie.geojson")
print(broken)
# {'status': 'warn', 'features_checked': 1, 'invalid_geometries': 1}
assert broken["status"] == "warn", "tier 2 did not fire — is Shapely linked against GEOS?"
assert broken["invalid_geometries"] == 1
The structural tier deserves the same treatment: delete the "type" key from the feature and assert that validate_geojson raises ValueError. Both fixtures belong in the test suite, not in a scratch file, because the failure mode they guard against is a dependency change — an upgrade that quietly swaps explain_validity for a different signature turns the whole gate into a pass-through, and only a fixture that is supposed to fail will notice.
Edge Cases & Debugging
- Ring winding order rejected by the renderer. RFC 7946 wants exterior rings counter-clockwise and holes clockwise — the reverse of the 2008 convention. If a valid polygon renders as a hole in Mapbox or deck.gl, rewind it with
shapely.geometry.polygon.orient(parcel_geom, sign=1.0)before export. buffer(0)changes area or splits a feature. A zero-width buffer repairs many self-intersections but can silently drop slivers or fracture one polygon into several; log geometry type and area before and after, and prefermake_valid()for repairs you need to audit.- Coordinates outside
[-180, 180]/[-90, 90]. The data is almost certainly in a projected CRS mislabelled as lon/lat; reproject with Coordinate Systems with PyProj usingalways_xy=Truerather than clamping the values. - Mixed geometry types in one collection. A
FeatureCollectionholding bothPointandPolygonfeatures is legal but rejected by some tile builders and PostGIS typed columns; split bygeometry["type"]before loading. - File too large for
json.load(). Beyond a few hundred megabytes, loading the whole document exhausts memory; stream features one at a time withijson(below) orfiona.open(), the same discipline used across Cloud-Native Geospatial Formats.
import ijson
from shapely.geometry import shape
# Stream-validate topology without loading the whole file into memory
with open("nationwide_parcels.geojson", "rb") as fh:
for index, feature in enumerate(ijson.items(fh, "features.item")):
parcel_geom = shape(feature["geometry"])
if not parcel_geom.is_valid:
logging.warning("Feature %s invalid: %s", index, explain_validity(parcel_geom))
- A feature has no
propertiesat all, orproperties: null. Both are legal, and both break the very commonfeature["properties"]["name"]access pattern with aTypeErrorrather than a validation error. Normalise to an empty object during ingestion instead of guarding at every call site. GeometryCollectionnested inside aGeometryCollection. RFC 7946 discourages collections and forbids nesting them, but files exported from older desktop tools still contain them. Flatten to individual features before validation, or the coordinate-depth walk in step 5 recurses into a shape it cannot interpret.- The file is a bare geometry or a single
Feature, not aFeatureCollection. All three are valid GeoJSON documents at the top level. Branch ondocument["type"]and wrap the singular cases into a one-element collection so the rest of the pipeline sees one shape. - Validation passes but the map is still empty. Check the property values, not the geometry: a renderer styling on a field that is
nullfor every feature draws nothing. Comparelen(features)against what the source claims and confirm at least one feature has the styling attribute populated.
Frequently Asked Questions
Should invalid geometry reject the file or be repaired automatically?
Reject at ingestion, repair only in an explicit, logged step. Automatic repair inside a validator is how a bad extract becomes permanent: make_valid will turn a self-intersecting parcel into a multipolygon that no longer matches the legal description, and nobody downstream ever learns the shape changed. The split used here — structure hard-fails, topology and CRS warn — exists so a human decides. When repair is the right call, run it as a named stage with before-and-after area logging, using the techniques in Fixing Self-Intersecting Polygons Programmatically.
Is a JSON Schema validator enough on its own?
No, and it is slower than the structural tier it would replace. A schema can express the type enumerations and the nesting depth of a coordinate array, which is exactly what tier one already does; it cannot express that a ring must close, that a polygon must not cross itself, or that longitude precedes latitude, because those are geometric facts rather than shape constraints. Schema validation on a large FeatureCollection also walks every coordinate in Python, so it costs more than the GEOS pass that finds the real defects.
Why does a file that GDAL reads without complaint fail this validator?
Because GDAL's GeoJSON driver is deliberately permissive: it repairs ring closure on read, tolerates the legacy crs member, accepts NaN, and reports geometry it cannot interpret as null rather than raising. That tolerance is a feature when you are consuming data and a liability when you are certifying it, and it is why a file that round-trips through GeoPandas can still be rejected by a browser or a strict tile builder. Validate the bytes you received, not the geometry your reader reconstructed from them.
How do I validate a GeoJSON file that is larger than memory?
Stream it. ijson yields one feature at a time from the features.item prefix, so peak memory tracks the largest single feature rather than the document, at the cost of losing the document-level checks — a top-level crs member or bbox appears before or after the array depending on the writer, so read those with a separate pass over the first few kilobytes. The alternative is to stop validating GeoJSON altogether and convert once, on arrival, to a format designed for partial reads; the trade-offs are laid out in Cloud-Native Geospatial Formats.
Do I need this if the data is going straight into PostGIS?
Yes, and arguably more so. PostGIS accepts invalid geometry into a geometry column without complaint unless a CHECK (ST_IsValid(geom)) constraint is present, so the defect survives the load and surfaces later inside an ST_Intersection that errors mid-transaction. Validating at ingestion means the failure is attributable to a file and a feature index; validating implicitly at query time means it is attributable to nothing. Loading and constraint patterns are covered under PostGIS Integration with Python.
Which tier catches a file where longitude and latitude are swapped? Usually tier three, but only by luck of geography. Swapping the pair puts a Berlin point at 52.5° east, 13.4° north — inside the WGS84 envelope, so the bounds check passes and the point lands in Iraq instead of Germany. The reliable test is a domain one: assert the collection's bounds fall inside the extent your data is supposed to cover, and fail if they do not. Transposition only trips the numeric check when latitude exceeds 90, which is to say for roughly half the planet.