Mapping Attribute Constraints to GeoJSON Schemas

You are working on a GeoJSON pipeline — perhaps a parcel registry, a utility network export, or a municipal boundary dataset — and the specification requires that every feature carry validated, typed, business-rule-compliant attributes before it reaches any spatial operation. The GeoJSON specification (RFC 7946) defines geometry types and coordinate arrays precisely, but leaves properties as an arbitrary object or even null. Without an explicit schema overlay, validators silently accept empty property bags, misspelled keys, or out-of-range numeric values. This page shows you how to close that gap using JSON Schema (draft-2020-12) and the Python jsonschema library — part of the broader discipline of attribute schema mapping for spatial datasets.

Prerequisites

  • Python 3.10+ with jsonschema 4.18+ installed (pip install jsonschema). Earlier versions lack full draft-2020-12 support and have incomplete if/then handling.
  • GeoJSON inputs conforming to RFC 7946 (FeatureCollection at the top level). If your source uses the older draft-GeoJSON-05 format, normalize the crs member out of the file first.
  • A documented attribute specification — at minimum a table of field names, expected types, allowed values, and which fields are mandatory. Without this, schema authoring becomes guesswork.
  • No unresolved coordinate reference system (CRS) ambiguity. GeoJSON mandates WGS 84 (EPSG:4326) geographic coordinates. If your source data uses a projected CRS, reproject before validation so coordinate values fall in the expected numeric range. See CRS precision standards for decimal-place guidance that flows through to attribute numeric fields.

Gotcha upfront: pattern validation applies to strings only. If you mistakenly attach a pattern keyword to a numeric field, most validators silently ignore it rather than raising an error.

The RFC 7946 Gap and Schema Overlay

The RFC intentionally permits maximum flexibility in properties. This is correct for a general interchange format — it means GIS tools from QGIS to Leaflet can consume the format without a mandatory schema. But for regulated datasets (cadastral parcels, utility asset registers, transport networks submitted to a government authority), that flexibility is a liability.

The fix is a JSON Schema overlay: a separate document that describes the shape your properties must take, validated at ingestion time by a library that understands the schema language. The diagram below shows where the constraint gate sits in a typical ingestion pipeline.

GeoJSON Attribute Constraint Validation Pipeline A linear pipeline with five stages: GeoJSON Input, Attribute Schema Gate, Geometry Validity Check, CRS Normalisation, and Publish. A Dead-letter Queue branches off the Attribute Schema Gate stage for failed features. GeoJSON Input Attribute Schema Gate (JSON Schema) Geometry Validity Check CRS Normalise & Publish Dead-letter Queue / QA Triage fail pass

Running the attribute gate first means geometry validity and CRS normalisation never process a feature that will ultimately be rejected for a missing permit number or an invalid zoning code. In high-volume pipelines this saves 40–70 % of compute on the downstream stages.

Step-by-Step Procedure

Step 1 — Author the constraint schema

Start with a JSON Schema document that wraps the FeatureCollection structure and targets the properties object inside each Feature. Keep the schema in a version-controlled file (e.g., schemas/parcels_v2.json) rather than inlining it in application code.

PARCEL_CONSTRAINT_SCHEMA = {
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "$id": "https://your-org.example/schemas/parcels/v2",
    "type": "object",
    "required": ["type", "features"],
    "properties": {
        "type": {"const": "FeatureCollection"},
        "features": {
            "type": "array",
            "items": {
                "type": "object",
                "required": ["type", "geometry", "properties"],
                "properties": {
                    "type": {"const": "Feature"},
                    "geometry": {
                        "type": "object",
                        "required": ["type", "coordinates"]
                    },
                    "properties": {
                        "type": "object",
                        "required": ["parcel_id", "zoning_class", "lot_area_sqft"],
                        "additionalProperties": False,
                        "properties": {
                            "parcel_id": {
                                "type": "string",
                                "pattern": "^[A-Z]{2}-\\d{6}$"
                            },
                            "zoning_class": {
                                "enum": ["R-1", "R-2", "C-1", "I-1"]
                            },
                            "lot_area_sqft": {
                                "type": "number",
                                "exclusiveMinimum": 0
                            },
                            "permit_status": {
                                "type": "string",
                                "enum": ["pending", "approved", "denied"]
                            },
                            "permit_number": {
                                "type": "string",
                                "pattern": "^PMT-\\d{8}$"
                            }
                        },
                        "if": {
                            "properties": {
                                "zoning_class": {"const": "C-1"}
                            },
                            "required": ["zoning_class"]
                        },
                        "then": {
                            "required": ["permit_status"]
                        }
                    }
                }
            }
        }
    }
}

Verification: load this schema into jsonschema.Draft202012Validator(schema) without a data argument — if it raises SchemaError, fix the schema before proceeding.

Step 2 — Build the validation gate

Instantiate the validator once per schema version and reuse it across batches. Draft202012Validator compiles regex patterns and if/then resolvers at construction time; rebuilding it per-feature is a measurable performance regression on large files.

import json
from jsonschema import Draft202012Validator, ValidationError
from typing import Any

# Compile once at module load time
_VALIDATOR = Draft202012Validator(PARCEL_CONSTRAINT_SCHEMA)

def validate_geojson_batch(data: dict[str, Any]) -> list[str]:
    """
    Returns a list of human-readable, index-aware error paths.
    Uses iter_errors() so every violation is collected in one pass.
    """
    errors: list[str] = []
    for error in _VALIDATOR.iter_errors(data):
        path_parts = [str(p) for p in error.absolute_path]
        path = ".".join(path_parts) if path_parts else "root"
        errors.append(f"[{path}] {error.message}")
    return errors

Verification: call validate_geojson_batch({"type": "FeatureCollection", "features": []}) — it should return an empty list (an empty FeatureCollection is valid).

Step 3 — Integrate into the ingestion pipeline

Place the gate at the ingestion boundary, before any call to geopandas.read_file() or shapely geometry operations.

import json

def ingest_geojson(path: str) -> None:
    with open(path, encoding="utf-8") as fh:
        data = json.load(fh)

    errors = validate_geojson_batch(data)
    if errors:
        # Route to dead-letter queue; do not proceed to spatial operations
        for msg in errors:
            print(f"ATTRIBUTE ERROR: {msg}")
        raise ValueError(
            f"{len(errors)} attribute constraint(s) failed in {path}. "
            "Fix or quarantine before continuing."
        )

    # Only reached when attributes are valid
    import geopandas as gpd
    gdf = gpd.read_file(path)
    run_geometry_checks(gdf)   # geometry validity next

Verification: pass a file with a deliberately malformed parcel_id (e.g., "123" instead of "CA-123456"). The function should raise ValueError and print the path [features.0.properties.parcel_id].

Step 4 — Emit structured QA reports

For CI/CD contexts, write errors to a machine-readable JSON artifact rather than printing to stdout. This enables automated ticketing systems to route each violation to the correct feature index and attribute key.

import json
from pathlib import Path

def write_qa_report(errors: list[str], output_path: str) -> None:
    report = {
        "total_violations": len(errors),
        "violations": [
            {"path": e.split("] ")[0].lstrip("["), "message": e.split("] ", 1)[1]}
            for e in errors
        ]
    }
    Path(output_path).write_text(json.dumps(report, indent=2), encoding="utf-8")

Verification: inspect the output JSON — each entry should have a path key in the form features.N.properties.field_name that a downstream script can parse without regex.

Interpreting Results

error.absolute_path is a collections.deque that mirrors the JSON Pointer to the failing value. The table below maps common error patterns to root causes and fixes.

Error path pattern error.message fragment Root cause Fix
features.N.properties.parcel_id does not match "^[A-Z]{2}-\\d{6}$" Legacy ID without state prefix Backfill prefix from county FIPS lookup
features.N.properties.zoning_class 'MX-1' is not one of ['R-1', 'R-2', 'C-1', 'I-1'] New mixed-use code not added to enum Extend enum in schema and re-run
features.N.properties.lot_area_sqft 0 is not valid under exclusiveMinimum Zero-area sliver polygon passed geometry check Flag for topology repair — see geometry validity checks
features.N.properties 'permit_status' is a required property C-1 zoning without permit status Attribute is missing upstream; quarantine and request resubmission
features.N.properties Additional properties are not allowed ('legacy_tag' was unexpected) ETL script appended ad-hoc column Remove or map field in transformation layer

When exclusiveMinimum: 0 fires on lot_area_sqft, the geometry underlying that feature is almost certainly a sliver or degenerate polygon. Passing it to a topology checker without fixing the attribute is misleading — the attribute error is the earlier, cheaper signal.

Gotchas & Edge Cases

1. additionalProperties: false rejects valid RFC 7946 extension members at the wrong level. If you mistakenly apply additionalProperties: false to the Feature object rather than its nested properties object, you will reject features that include a top-level id or bbox — both explicitly permitted by RFC 7946. Lock down only properties.

2. Draft-04 vs draft-2020-12 exclusiveMinimum semantics differ. In draft-04, exclusiveMinimum is a boolean (true) paired with a sibling minimum. In draft-07 and draft-2020-12 it is a numeric threshold. Using "exclusiveMinimum": 0 with a draft-04 validator silently does nothing — the rule is ignored. Always match your $schema URI to the validator class you instantiate.

3. if/then conditions scope only to the containing schema object. The conditional if/then block in the example above applies to the entire properties object. If you nest if/then inside a properties.sub_object, it does not propagate up. Write one if/then per logical business rule and document each with a comment.

4. pattern validation is case-sensitive by default. JSON Schema regex is anchored to the ECMAScript regex dialect (case-sensitive, no POSIX character classes). A pattern of ^[A-Z]{2}-\\d{6}$ will reject ca-123456 even if your source data uses lowercase state codes. Normalise case during transformation before running pattern validation, or use "pattern": "^(?i:[a-z]{2})-\\d{6}$" only with validators that support the inline flag.

5. Large FeatureCollections load entirely into memory. json.load() on a 2 GB GeoJSON file will exhaust RAM on modest machines. For files above ~200 MB, stream features using ijson and validate each feature’s properties object independently rather than wrapping the whole FeatureCollection in a single schema call.

When to Escalate

This approach — a single JSON Schema overlay validated with jsonschema — handles the vast majority of attribute constraint scenarios for GeoJSON datasets up to roughly 500 k features on a single machine with adequate RAM.

Move to a more capable pattern when:

  • Feature count exceeds 500 k or file size exceeds 500 MB. Switch to streaming with ijson, or convert the dataset to GeoPackage and validate attribute constraints using SQLite CHECK constraints inside the container — faster for large datasets and avoids full-file memory load.
  • Cross-feature rules are required (e.g., “no two features may share the same parcel_id”). JSON Schema cannot express cross-instance constraints. Use the rule engine patterns in GeoPandas or PostGIS UNIQUE constraints for this.
  • Attribute rules must run as part of an asynchronous ingestion workflow. A synchronous Python gate blocks the thread. Refactor to a task queue — see designing async validation queues with Celery for a worked example.
  • Schema versions must be managed centrally across teams. A single schemas/ directory in a Git repository does not scale to multiple publishing teams. Consider a schema registry (Confluent Schema Registry or AWS Glue Schema Registry) with a REST API that the validation gate calls at startup.

Related:

Back to Attribute Schema Mapping for Spatial Datasets.