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
jsonschema4.18+ installed (pip install jsonschema). Earlier versions lack full draft-2020-12 support and have incompleteif/thenhandling. - GeoJSON inputs conforming to RFC 7946 (FeatureCollection at the top level). If your source uses the older draft-GeoJSON-05 format, normalize the
crsmember 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.
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:
- Attribute Schema Mapping for Spatial Datasets — parent cluster covering the full schema mapping lifecycle
- Geometry Validity Checks for Vector Data — the next gate after attribute constraints pass
- Building Rule Engines with GeoPandas — cross-feature and cross-layer constraint patterns
- Designing Async Validation Queues with Celery — non-blocking ingestion for high-volume GeoJSON feeds