Validating Parcel IDs Against County GIS APIs
This guide sits under Mapping Municipal Zoning Overlays to GIS Data, part of the Core Architecture & Code Taxonomy for Municipal Permits reference, and narrows to one operational task: confirming that an Assessor Parcel Number on an incoming permit actually resolves to a real parcel in the county’s authoritative GIS before any overlay join runs against it.
The stakes are concrete. An unvalidated parcel identifier — a transposed digit, a stale book-page-parcel string, a formatting variant a clerk typed by hand — silently poisons every spatial join downstream, routing an application to the wrong review board or dropping it out of the overlay pipeline entirely. County parcel data lives behind ArcGIS REST FeatureServer endpoints that rate-limit aggressively, go down during business hours, and republish their authoritative fabric on a cadence the county never announces. So the task is not a single lookup: it is normalizing a messy APN into the county’s canonical format, querying a flaky remote service with disciplined retries, confirming the match with a checksum and a bounded fuzzy fallback, and caching the answer without ever serving a stale parcel as if it were current. The input is one untrusted string; the required output is a verified, versioned parcel record with a provenance stamp a compliance officer can defend months later.
Permalink to this section Precise Problem Framing
A parcel identifier is the join key that binds a permit application to its spatial and regulatory context, so an invalid one is not a cosmetic defect — it is a routing failure with compliance consequences. The county assessor’s FeatureServer is the authoritative source of truth, but it is a remote dependency you do not control: it enforces per-IP rate limits, returns partial pages, and occasionally serves an older cached fabric behind a load balancer. Your validation layer must treat every response as suspect and every non-answer as a decision point.
Three properties define a correct validator. It is deterministic: the same APN normalizes to the same canonical key on every run, so cache entries and audit records line up. It is resilient: a transient 429 or 503 triggers a bounded backoff rather than a hard failure, because a county service hiccup should never reject a legitimate permit. And it is honest about staleness: when the authoritative layer was last edited before the application’s filing window, the record is flagged, not silently trusted. The remainder of this guide implements those three properties in four steps.
Permalink to this section Step 1 — Normalize and Checksum the APN
County APNs arrive in incompatible surface forms — 123-456-789, 123 456 789, 123456789, sometimes with a trailing check digit or a sub-parcel suffix. Before any network call, collapse the string to the county’s canonical format and reject anything structurally impossible. Validating structure locally is free and eliminates the majority of bad lookups without touching the rate-limited remote service.
from __future__ import annotations
import re
# Each county gets an explicit format contract: a compiled pattern plus the
# canonical rendering. Never share one regex across jurisdictions.
APN_PATTERNS: dict[str, re.Pattern[str]] = {
"example_county": re.compile(r"^(?P<book>\d{3})(?P<page>\d{3})(?P<parcel>\d{3})$"),
}
def normalize_apn(raw: str, county: str) -> str:
"""Strip separators, validate structure, and render the canonical APN.
Raises ValueError on a structurally impossible identifier so it never
reaches the county API or the cache.
"""
digits = re.sub(r"[\s\-.]", "", raw.strip().upper())
pattern = APN_PATTERNS[county]
m = pattern.fullmatch(digits)
if m is None:
raise ValueError(f"APN {raw!r} does not match {county} format")
# Canonical book-page-parcel with hyphen separators for stable cache keys.
return f"{m['book']}-{m['page']}-{m['parcel']}"
def verify_check_digit(apn_digits: str) -> bool:
"""Validate a trailing mod-10 check digit where the county publishes one.
Not every county issues one; gate this on a per-county config flag rather
than assuming its presence.
"""
body, check = apn_digits[:-1], int(apn_digits[-1])
total = sum(int(d) * (i % 2 + 1) for i, d in enumerate(body))
return (10 - total % 10) % 10 == check
Structural rejection here is the cheapest guard in the whole pipeline: it turns a class of clerk typos into an immediate, explainable error instead of an empty remote query that looks like a missing parcel.
Permalink to this section Step 2 — Query the FeatureServer with Retry and Backoff
The ArcGIS REST FeatureServer query interface takes a where clause, an outFields list, and a format flag, and returns a features array of {attributes, geometry} objects. Build the request explicitly and drive it through a requests.Session wired with a retry policy so transient rate limits and server errors are absorbed rather than surfaced. A shared session also reuses the TLS connection, which matters when validating a batch of parcels against one county host.
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# backoff_factor=0.8 → sleeps of ~0.8s, 1.6s, 3.2s between attempts, with the
# status_forcelist covering rate limiting and the common gateway errors.
_retry = Retry(
total=4,
backoff_factor=0.8,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=frozenset({"GET", "POST"}),
respect_retry_after_header=True, # honor the county's Retry-After when sent
)
def make_session() -> requests.Session:
session = requests.Session()
session.mount("https://", HTTPAdapter(max_retries=_retry, pool_connections=4))
return session
def query_parcel(session: requests.Session, service_url: str, apn: str,
out_sr: int = 2229, timeout: float = 8.0) -> list[dict]:
"""Query a county FeatureServer layer for one canonical APN.
Returns the raw features list (possibly empty). Network/HTTP faults after
the retry budget is exhausted propagate as requests exceptions.
"""
params = {
"where": f"APN = '{apn}'", # parameterized by the caller, never raw user text
"outFields": "APN,SITUS_ADDR,LAST_EDITED_DATE",
"returnGeometry": "true",
"outSR": out_sr, # match the jurisdiction's target CRS
"resultRecordCount": 5, # cap the page; a valid APN returns 1
"f": "json",
}
resp = session.get(f"{service_url}/query", params=params, timeout=timeout)
resp.raise_for_status()
return resp.json().get("features", [])
Because the where clause is assembled from a value that already passed normalize_apn, it is constrained to digits and hyphens — but keep the clause built from the canonical string, never from raw clerk input, so a crafted APN can never smuggle SQL-like syntax into the county query.
Permalink to this section Step 3 — Resolve the Match: Exact, Checksum, then Bounded Fuzzy
An exact single-feature result is the happy path. Zero features is the interesting case: it may mean the parcel genuinely does not exist, or that a transposed digit landed one character off a real APN. Rather than fail outright, run a bounded fuzzy match against nearby candidates and require a human-review flag before accepting a near-miss — an automated fuzzy correction that silently rewrites a parcel ID is itself a compliance risk.
from difflib import SequenceMatcher
FUZZY_THRESHOLD = 0.92 # ratio below this is treated as "no confident match"
def resolve_match(apn: str, features: list[dict],
candidates: list[str]) -> dict:
"""Classify the query outcome into a decision the router can act on."""
if len(features) == 1:
return {"apn": apn, "status": "exact", "feature": features[0]}
if len(features) > 1:
# Duplicate APNs signal a county data defect — never auto-pick one.
return {"apn": apn, "status": "ambiguous", "count": len(features)}
# Zero features: look for a single high-confidence near neighbour.
scored = sorted(
((SequenceMatcher(None, apn, c).ratio(), c) for c in candidates),
reverse=True,
)
if scored and scored[0][0] >= FUZZY_THRESHOLD:
ratio, best = scored[0]
# Flagged, not accepted: a clerk confirms before the pipeline proceeds.
return {"apn": apn, "status": "fuzzy_review", "suggested": best,
"confidence": round(ratio, 3)}
return {"apn": apn, "status": "not_found"}
The distinction between not_found, ambiguous, and fuzzy_review is what lets the intake pipeline route each outcome differently — a genuine miss to rejection, a duplicate to a data-quality queue, and a near-miss to clerk confirmation — instead of collapsing all three into a useless “no match.”
Permalink to this section Step 4 — Cache Results and Detect Stale County Data
County FeatureServers are slow and rate-limited, so caching validated parcels is mandatory for any batch workload; the Redis cache-warming approach for parcel lookup endpoints covers pre-populating that layer ahead of a submission window. The subtlety unique to parcel data is staleness: a cached record is only valid while the underlying county fabric has not been republished. Compare the layer’s LAST_EDITED_DATE against your cache entry and flag — do not silently serve — anything older than the application’s filing context demands.
import time
from dataclasses import dataclass, field
@dataclass
class CachedParcel:
apn: str
feature: dict
fetched_at: float
layer_edited_at: float # epoch of the county layer's last edit
ttl_seconds: int = 6 * 3600
def is_fresh(entry: CachedParcel, layer_edited_now: float) -> bool:
"""Fresh only if the TTL holds AND the county layer was not re-edited."""
within_ttl = (time.time() - entry.fetched_at) < entry.ttl_seconds
layer_unchanged = layer_edited_now <= entry.layer_edited_at
return within_ttl and layer_unchanged
@dataclass
class ParcelCache:
_store: dict[str, CachedParcel] = field(default_factory=dict)
def get(self, apn: str, layer_edited_now: float) -> CachedParcel | None:
entry = self._store.get(apn)
if entry and is_fresh(entry, layer_edited_now):
return entry
self._store.pop(apn, None) # evict stale/expired so the next call refetches
return None
Tying freshness to the layer edit timestamp — not just a wall-clock TTL — is what prevents the classic municipal failure of validating a parcel against last fiscal year’s fabric after the county quietly re-cut boundaries. Once a parcel is validated, hand it to the best practices for linking zoning codes to parcel IDs contract so the confirmed identifier becomes a stable versioned join key.
Permalink to this section Parameter and Flag Reference
| Parameter | Type | Default | Municipal-context notes |
|---|---|---|---|
Retry.total |
int |
4 |
Total attempts across all methods; enough to ride out a brief county rate-limit burst without hanging intake. |
backoff_factor |
float |
0.8 |
Grows delay geometrically (~0.8/1.6/3.2 s); raise for counties with strict per-minute quotas. |
status_forcelist |
tuple |
429,5xx |
Retries transient faults only; a 400/404 is a definitive answer and must not retry. |
respect_retry_after_header |
bool |
True |
Honors the county’s own throttling signal instead of guessing. |
timeout |
float |
8.0 |
Per-request ceiling; a hung socket must not stall a batch of thousands. |
out_sr |
int |
2229 |
Request geometry in the jurisdiction’s State Plane CRS to avoid a client-side reprojection. |
FUZZY_THRESHOLD |
float |
0.92 |
Below this, no auto-suggestion; tune conservatively — a false correction is worse than a miss. |
ttl_seconds |
int |
21600 |
Cache lifetime; always paired with a layer-edit check, never used alone. |
Permalink to this section Common Failure Patterns and Fixes
Permalink to this section Retrying a definitive 404 as if it were transient
Adding 404 to status_forcelist turns a “parcel does not exist” answer into four slow retries that still fail. Keep the force-list to 429 and 5xx only; a 400/404 is authoritative and should route straight to not_found.
Permalink to this section Silent auto-correction of near-miss APNs
Accepting the top fuzzy candidate without human review rewrites a permit’s parcel binding to a different real property. Always return fuzzy_review with the suggestion and confidence, and require a clerk to confirm before the corrected APN is persisted.
Permalink to this section Cache serving a parcel against a republished fabric
A wall-clock-only TTL happily returns a parcel the county re-cut this morning. Gate freshness on layer_edited_at as in Step 4, and refresh the layer’s last-edit timestamp once per batch rather than per parcel to stay inside rate limits.
Permalink to this section Ambiguous multi-feature results auto-resolved
A duplicated APN in the county fabric returns two features; picking features[0] is non-deterministic across republishes. Classify as ambiguous and route to a data-quality queue so the county defect is fixed at the source.
Permalink to this section Unbounded batch hammering the county quota
Validating ten thousand parcels with no concurrency cap trips the per-IP limit and blocks legitimate intake. Run the workload through the shared ingestion error-handling and retry logic with a bounded worker pool so backpressure is explicit.
Permalink to this section Audit and Logging Guidance
Every validation decision is a compliance event, so log one structured entry per resolved APN: the raw input, the canonical form, the match status, the county layer’s LAST_EDITED_DATE, the number of retry attempts consumed, and the final not_found/exact/fuzzy_review/ambiguous outcome. Record the county service URL and the response’s own timestamp so an auditor can reconstruct exactly which fabric version authorized the match. Never log a fuzzy suggestion as an accepted correction — log the suggestion and the separate clerk-confirmation event so the record shows a human, not the algorithm, rewrote the identifier. Retain these entries for the full state records-retention period, because a parcel-binding dispute can surface long after the permit closes. A spike of 429s from one county host is the earliest signal that a batch is exceeding quota, and a rise in ambiguous results is an early warning of a county data-quality regression worth escalating to the GIS department.
Permalink to this section Frequently Asked Questions
Permalink to this section Should I validate parcel IDs at intake or in an overnight batch?
Both, for different reasons. Validate at intake so a clerk sees a bad APN immediately, using a warm cache to keep the interaction fast. Re-validate in an overnight batch to catch parcels whose county fabric changed after submission — a parcel valid at filing can be re-cut before review, and only a scheduled re-check surfaces that drift.
Permalink to this section What happens when the county FeatureServer is completely down?
Do not emit a not_found, which is wrongly permissive — treat exhausted retries as an explicit service_unavailable outcome and hold the application in a pending state, serving the last known-good cached record with its staleness flag set. This mirrors the fallback-routing posture used elsewhere in the platform so a county outage degrades gracefully rather than dropping permits.
Permalink to this section How do I handle a county that uses a different APN format than its neighbours?
Give each county its own compiled pattern and canonical renderer in APN_PATTERNS, keyed by jurisdiction, and never let one county’s regex validate another’s identifiers. When a permit spans jurisdictions, resolve each parcel against its own county’s contract before merging — the same principle that governs multi-jurisdiction record structure downstream.
Permalink to this section Related
- Best Practices for Linking Zoning Codes to Parcel IDs — the versioned data contract a validated APN feeds into.
- Mapping Municipal Zoning Overlays to GIS Data — the overlay pipeline that consumes confirmed parcel identifiers.
- Warming Redis Caches for Parcel Lookup Endpoints — pre-populating the validation cache before a submission window.
- Error Handling and Retry Logic for Ingestion Pipelines — bounded, backpressured batch validation against rate-limited county services.