Designing JSON Schemas for Building Permits
A building-permit JSON schema is the validation contract that sits at the front door of the core architecture and code taxonomy for municipal permits: every inbound application is measured against it before the workflow engine is allowed to touch the record. This guide builds that contract end to end — from modeling the permit code taxonomy as data, through conditional and spatial constraints, to compiling the schema and emitting structured errors a clerk can act on.
Permalink to this section What Breaks Without a Strict Schema, and Who It Affects
When permit intake has no enforced contract at the boundary, malformed submissions do not fail loudly — they fail later and quietly. A missing valuation slips through and a fee is calculated as $0. A free-text permit_type of “elec.” never matches the inspection-routing table, so the application stalls in an unassigned queue. A parcel ID with a transposed digit passes intake and is only caught weeks later by a clerk reconciling against the county roll. Each of these is a compliance defect that surfaces downstream as an exception, a backlog, or — worst case — a permit issued under the wrong ruleset.
The people who feel this are specific. Municipal clerks inherit the ambiguous records that automation could not route and must triage them by hand. Python automation builders spend their time writing defensive if-checks scattered across the codebase instead of trusting a single boundary. Compliance officers cannot prove, during an audit, that every application was evaluated against the correct fields and enumerations. A rigorously designed schema collapses all of that into one artifact: the input is an arbitrary JSON payload from a portal, an API integration, or a parsed PDF application; the output is either an accepted, fully-typed record or a structured rejection that names exactly which constraint failed and why.
Permalink to this section Prerequisites Checklist
This component targets Python 3.10+ (the union X | None syntax and StrEnum patterns below assume it). Install the two validation engines used throughout — one for declarative JSON Schema documents, one for typed Python models:
pip install "jsonschema>=4.21" # Draft 2020-12 validation engine
pip install "pydantic>=2.6" # typed models + edge validation at the API boundary
Environment and data assumptions before you start:
- A canonical permit code taxonomy — the authoritative list of permit types, zoning districts, and construction typologies for your jurisdiction, ideally exported from the same source used for versioning permit code taxonomies for annual updates so the enums in your schema and your fee tables never drift apart.
- Read access to the parcel roll — the county GIS or assessor dataset that lets you confirm a submitted
parcel_idactually exists. - A pinned JSON Schema dialect. Every schema document below declares
"$schema": "https://json-schema.org/draft/2020-12/schema"so thatif/then/elseandunevaluatedPropertiesbehave consistently across machines.
Permalink to this section Stage 1 — Model the Taxonomy as Enumerated Constraints
The schema’s job begins with eliminating ambiguity, and free-text fields are the primary source of it. Anything that has a finite, governed set of valid values — permit type, zoning district, review status — must be expressed as an enum (or const for a single fixed value) rather than an open string. Source these enums from the taxonomy export, never hand-type them, so a new permit class added to the code book propagates into validation automatically.
# taxonomy.py — load the governed vocabularies once at import time.
import json
from pathlib import Path
_TAXONOMY: dict[str, list[str]] = json.loads(
Path("taxonomy/active.json").read_text(encoding="utf-8")
)
PERMIT_TYPES: list[str] = _TAXONOMY["permit_types"] # e.g. ["building", "electrical", ...]
ZONING_DISTRICTS: list[str] = _TAXONOMY["zoning_codes"] # e.g. ["R-1", "R-2", "C-1", ...]
These lists feed directly into the schema document so the controlled vocabulary lives in exactly one place.
Permalink to this section Stage 2 — Define the Base Contract
The base schema captures the invariant core of every application regardless of permit class: who is applying, which parcel, what the project is worth. Strict typing, an explicit required array, and pattern constraints on identifiers turn the most common data-entry defects into boundary rejections.
# schema.py — the invariant base contract every payload must satisfy.
from taxonomy import PERMIT_TYPES, ZONING_DISTRICTS
BASE_SCHEMA: dict = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://municipalpermit.org/schema/building-permit/1.0",
"type": "object",
"required": ["parcel_id", "permit_type", "valuation", "applicant"],
"additionalProperties": False, # reject unknown keys instead of silently ignoring them
"properties": {
# County APN format — a transposed digit fails here, not at a clerk's desk.
"parcel_id": {"type": "string", "pattern": r"^\d{2}-\d{4}-\d{3}$"},
"permit_type": {"type": "string", "enum": PERMIT_TYPES},
"zoning_district": {"type": "string", "enum": ZONING_DISTRICTS},
"valuation": {"type": "number", "exclusiveMinimum": 0},
"applicant": {
"type": "object",
"required": ["name", "email"],
"properties": {
"name": {"type": "string", "minLength": 1},
"email": {"type": "string", "format": "email"},
"contractor_license": {"type": "string", "pattern": r"^[A-Z]{1,3}\d{4,8}$"},
},
},
},
}
Setting additionalProperties to False is deliberate: in a municipal context an unexpected field usually signals a mismatched portal version or an injection attempt, and silently dropping it hides the problem.
Permalink to this section Stage 3 — Encode Conditional and Jurisdictional Rules
Real ordinances are conditional: a demolition permit requires an asbestos-survey reference; any project over a valuation threshold triggers a structural plan review; a parcel in a historic overlay needs a preservation-board field. JSON Schema expresses these as if/then blocks and oneOf/anyOf branches, which keeps the rules declarative and auditable rather than buried in application code.
# Conditional requirements layered onto the base contract.
CONDITIONAL_RULES: dict = {
"allOf": [
{
# Demolition work must reference an asbestos survey.
"if": {"properties": {"permit_type": {"const": "demolition"}}},
"then": {"required": ["asbestos_survey_id"]},
},
{
# High-value projects require a structural review flag.
"if": {"properties": {"valuation": {"minimum": 500_000}}},
"then": {"required": ["structural_review"]},
},
]
}
Because jurisdictions diverge — setbacks, fire-suppression mandates, and preservation overlays vary by locality — keep these conditional blocks as composable sub-schemas rather than hard-coding one municipality’s rules into the base. That separation of invariant core from jurisdictional overrides is the full subject of how to structure permit application JSON for multi-jurisdiction use, and it is what lets a single validation pipeline serve many counties. When a conditional rule encodes a statutory requirement, anchor it to a version-controlled reference so local ordinances and state mandates stay synchronized — the mechanics of that reconciliation are covered in cross-referencing state and local building codes.
Permalink to this section Stage 4 — Add Spatial Validation
Building permits are inherently spatial, so the contract should carry the geometry needed to validate a parcel against zoning overlays, floodplains, and historic districts. Embed a GeoJSON-shaped geometry object in the schema; the actual cross-reference against municipal layers happens against the patterns described in mapping municipal zoning overlays to GIS data, but the schema guarantees the geometry is well-formed before that check runs.
# A minimal GeoJSON Point/Polygon constraint embedded in the contract.
GEOMETRY_SCHEMA: dict = {
"type": "object",
"required": ["type", "coordinates"],
"properties": {
"type": {"enum": ["Point", "Polygon"]},
# WGS84 longitude/latitude ranges — out-of-range coordinates fail here.
"coordinates": {
"type": "array",
"items": {"type": "number"},
"minItems": 2,
},
},
}
Validating geometry shape at the schema layer means the GIS service never receives a payload it cannot interpret, and an out-of-range coordinate is rejected as a structured error instead of throwing deep inside a spatial query.
Permalink to this section Stage 5 — Compile, Validate, and Route
With the layers defined, compile them once at startup into a single validator and reuse it per request. Recompiling on every call is a common and expensive mistake on constrained municipal servers; a pre-built validator turns validation into a hot-path operation.
# validate.py — compile once, validate many, return a routing decision.
from typing import Any
from jsonschema import Draft202012Validator
from schema import BASE_SCHEMA, CONDITIONAL_RULES, GEOMETRY_SCHEMA
# Merge the layers into one document and compile a reusable validator.
FULL_SCHEMA: dict = {
**BASE_SCHEMA,
**CONDITIONAL_RULES,
"properties": {**BASE_SCHEMA["properties"], "geometry": GEOMETRY_SCHEMA},
}
_VALIDATOR = Draft202012Validator(FULL_SCHEMA)
def validate_application(payload: dict[str, Any]) -> tuple[bool, list[dict[str, str]]]:
"""Return (accepted, errors). On failure, errors carry field + reason."""
errors = sorted(_VALIDATOR.iter_errors(payload), key=lambda e: e.path)
if not errors:
return True, []
structured = [
{"field": "/".join(str(p) for p in e.absolute_path) or "<root>",
"rule": e.validator,
"message": e.message}
for e in errors
]
return False, structured
The boolean return is the routing signal: an accepted payload is handed to the workflow state machine; a rejected one returns its structured error list to the caller. That hard boundary — nothing enters the queue until it conforms — is the whole point of the contract.
Permalink to this section Configuration Reference
| Parameter | Type | Default | Municipal-context notes |
|---|---|---|---|
$schema dialect |
string | draft/2020-12 |
Pin it. if/then/unevaluatedProperties semantics differ across drafts; a drifting dialect silently changes validation behavior. |
additionalProperties |
bool | False |
Reject unknown keys. An unexpected field usually means a stale portal version or tampering, not benign extra data. |
parcel_id pattern |
string (regex) | ^\d{2}-\d{4}-\d{3}$ |
Match your county’s APN format exactly; this is the cheapest place to catch transposition errors. |
valuation bound |
number | exclusiveMinimum: 0 |
Blocks $0 filings that would compute a zero fee; high thresholds also drive plan-review triggers. |
format: email |
string | off by default | The jsonschema engine does not enforce format unless a format checker is registered — wire one in or it is documentation only. |
| Validator lifetime | object | compile-once | Build Draft202012Validator at startup, never per request, on memory-limited servers. |
Schema $id version |
string | …/1.0 |
Bump on breaking field changes so historical records pin to the contract that evaluated them. |
Permalink to this section Error Handling and Edge Cases
Several failure modes are specific to municipal intake and worth handling explicitly rather than letting them surface as opaque 500s.
formatis advisory by default. Injsonschema, keywords likeformat: "email"do not fail validation unless you attach a format checker. Passformat_checker=Draft202012Validator.FORMAT_CHECKERwhen constructing the validator if email/date formats must be enforced.- Numeric strings from legacy portals. Older intake forms submit
"valuation": "350000"as a string. JSON Schema will reject it againsttype: number— correct, but unhelpful to the applicant. Coerce known-numeric fields in a pre-validation pass, or accept["number", "string"]with apatternand normalize after. - Encoding and Unicode in names. Applicant names arrive in mixed encodings; normalize to NFC before validation so a
minLengthcheck and later database lookups see the same bytes. - Conditional-rule gaps. An
if/thenwith no matchingelsesilently passes payloads the rule never anticipated. Pair high-stakes conditionals with an explicitelseor a catch-allrequiredso a new permit type cannot route around a mandate.
from jsonschema import Draft202012Validator
# Enforce formats and surface a clean message instead of a raw stack trace.
_VALIDATOR = Draft202012Validator(
FULL_SCHEMA, format_checker=Draft202012Validator.FORMAT_CHECKER
)
For a permit denied by validation, attach a human-readable correction hint rather than exposing internal validator keywords — applicants should see “Parcel ID must look like 12-3456-789”, not pattern mismatch at /parcel_id.
Permalink to this section Testing and Verification
Treat the schema as code: it has regressions, and they are silent. Pin a set of fixture payloads — at least one valid, and one per constraint you care about — and assert both acceptance and the exact rejection reason. Testing only the happy path lets a loosened constraint pass unnoticed.
# test_schema.py — verify both acceptance and precise rejection.
from validate import validate_application
VALID = {
"parcel_id": "12-3456-789", "permit_type": "building",
"valuation": 350_000, "applicant": {"name": "Ada Lovelace", "email": "[email protected]"},
}
def test_valid_payload_accepted() -> None:
accepted, errors = validate_application(VALID)
assert accepted and errors == []
def test_demolition_requires_asbestos_survey() -> None:
bad = {**VALID, "permit_type": "demolition"} # missing asbestos_survey_id
accepted, errors = validate_application(bad)
assert not accepted
assert any(e["rule"] == "required" for e in errors)
def test_zero_valuation_rejected() -> None:
accepted, _ = validate_application({**VALID, "valuation": 0})
assert not accepted
Run these in CI on every taxonomy export and every schema change. A green suite is your evidence that a 1.0 → 1.1 schema bump did not quietly start accepting payloads the previous contract rejected — exactly the guarantee an audit asks for.
Permalink to this section Integration Notes
This schema is the seam between ingestion and the rest of the platform. Upstream, the payloads it validates are produced by the automated permit ingestion and parsing workflows track — portal posts, API integrations, and OCR-extracted PDF fields all converge on this single contract, which is why the schema must tolerate the imperfect output those sources produce.
Two adjacent components consume the contract directly. When an upstream source floods the pipeline with failures, the cached copy of this schema is what lets degraded mode keep validating locally — the failover path that relies on it is described in building fallback routing for legacy system downtime, and the same caching discipline appears in the retry logic of error handling and retry logic for ingestion pipelines. Downstream, an accepted record carries the validated fields that drive who may act on it under implementing role-based access for clerk portals — the schema decides what a record is, and access control decides who may touch it.
Permalink to this section Frequently Asked Questions
Permalink to this section Should I use JSON Schema documents or Pydantic models?
Use both, at different layers. JSON Schema is the portable, language-agnostic contract you can publish, version, and hand to a third-party integrator. Pydantic is the ergonomic enforcement layer at your Python API boundary, where you also want coercion and rich error objects. Generate one from the other where you can, but treat the JSON Schema document as the source of truth that auditors and external systems consume.
Permalink to this section How do I keep the schema’s enums in sync with the permit code book?
Never hand-edit enums. Source them from the same taxonomy export that feeds your fee tables and routing logic, and load them at import time as shown in Stage 1. When the code book changes, the export changes, and your schema picks up the new values on the next deploy — the discipline for those releases is covered in versioning permit code taxonomies for annual updates.
Permalink to this section Why reject unknown fields with additionalProperties: false?
Because in a permitting context an unexpected key almost never means harmless extra data. It usually signals a portal sending a stale payload shape or a malformed integration, and occasionally tampering. Rejecting it turns a silent mismatch into a clear, actionable error at the boundary instead of a corrupted record downstream.
Permalink to this section Does validating geometry in the schema replace a GIS check?
No. The schema only guarantees the geometry is well-formed — correct type, in-range coordinates. Confirming the parcel actually falls within a given zoning overlay or floodplain is a spatial query against live GIS layers, handled in the zoning-overlay mapping workflow. The schema’s job is to ensure the GIS service never receives a payload it cannot parse.
Permalink to this section Related
- Core Architecture & Code Taxonomy for Municipal Permits — the parent architecture this validation contract anchors.
- How to structure permit application JSON for multi-jurisdiction use — separating the invariant base schema from per-jurisdiction overrides.
- Cross-referencing state and local building codes — keeping conditional rules synchronized with statutory references.
- Mapping municipal zoning overlays to GIS data — the spatial cross-reference that consumes validated geometry.
- Versioning permit code taxonomies for annual updates — the release process that keeps the schema’s enums current.