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":

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.

The three-tier GeoJSON validation gate A raw parcels.geojson file flows left to right through three checks. Tier 1 validates structure with the geojson library against RFC 7946 (is_valid / errors); Tier 2 validates topology with Shapely and GEOS (is_valid / explain_validity); Tier 3 audits the CRS by flagging any legacy crs member and coordinates outside the WGS84 bounds of longitude minus 180 to 180 and latitude minus 90 to 90. A file that passes all three reaches a clean, downstream-ready outlet. Each tier has a red side branch to a shared reject-and-log box, labelled with the defect it catches: a missing type key, a self-intersection, and projected metres mislabelled as degrees. Three-tier validation gate: fail at the first broken tier raw parcels .geojson TIER 1 Structure geojson · RFC 7946 is_valid / errors() TIER 2 Topology Shapely · GEOS explain_validity TIER 3 CRS audit legacy crs + bounds ±180 lon · ±90 lat ✓ clean downstream-ready missing type key self-intersection metres-as-degrees ✗ reject + log structure hard-fails · topology & CRS warn
The three-tier GeoJSON validation gate

Prerequisites

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.

Which validation tier catches which GeoJSON defect A matrix with six defects as rows and five columns: json.load, tier 1 structure, tier 2 topology, tier 3 CRS audit, and the downstream symptom. A missing type key, a wrong coordinate array depth and a null geometry are structural concerns; a self-intersecting ring is caught only by the topology tier; a legacy crs member and coordinates in projected metres are caught only by the CRS audit. A null geometry is legal under RFC 7946 and is caught by no tier, so the loop must guard for None. The json.load column is empty for every row: parsing success proves nothing. Which tier catches which defect — and what it costs if none do defect in the file PARSE json.load TIER 1 structure TIER 2 topology TIER 3 CRS audit what breaks downstream missing "type" key wrong coordinate array depth null geometry on a Feature self-intersecting ring legacy "crs" member coordinates in projected metres KeyError when the geometry is built shape() raises mid-batch legal in RFC 7946 · guard for None tiler crashes · wrong overlay area datum assumed, never verified points land off the map · empty join json.load() accepts every row above — a document that parses has proved nothing about its geometry.
Each tier has a blind spot: only the pairing of structural, topological and CRS checks accounts for every defect class in the table.
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.

The WGS84 coordinate envelope and an out-of-range feature A plotted longitude-latitude frame spans minus 180 to 180 degrees horizontally and minus 90 to 90 degrees vertically. A Berlin point at 13.40, 52.51 sits comfortably inside the frame. A red arrow leaves the right edge toward a panel showing the bounds of a second feature, roughly 392000 by 5820000, whose longitude exceeds 180 and whose latitude exceeds 90 by four orders of magnitude. Those numbers are UTM metres serialized as though they were degrees, which the CRS audit flags rather than clamps. Any coordinate outside the WGS84 envelope is not degrees latitude (deg) (13.40, 52.51) · inside the envelope valid range: lon ±180, lat ±90 -180-90090180 900-90 longitude (deg) off the envelope entirely shape(geom).bounds (392000.0, 5820000.0, 392410.0, 5820380.0) lon 392000 > 180 lat 5820000 > 90 ⇒ UTM metres serialized as though they were degrees flag it — never clamp the values A legacy crs member plus out-of-range bounds is the metres-as-degrees signature.
The bounds check is a cheap CRS smoke test: degrees have a fixed envelope, so any value outside it identifies projected coordinates on sight.
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

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))

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.