Handling PO boxes and unnumbered addresses in parcel matching
A rural county’s permit file arrives and 18% of the addresses are PO BOX 447, HC 62 BOX 19, MP 14.5 STATE HWY 14, or blank with a note in the description field. Feed those to a geocoder and it will return points — a post office, a highway centreline interpolation, a town centroid — and every one of them will land inside some parcel. The pipeline records 18% more matches and a corresponding number of permits attached to land the applicant does not own. The correct handling is to recognise these before geocoding, because the failure is not that the geocoder is bad; it is that the input does not describe a location at all. This is the rural-data companion to address geocoding & parcel matching.
Diagnosis: classifying an address before you geocode it jump to heading
Sort the input into four classes on text alone. Only the first is a site address; the other three need different handling, and one of them must never be geocoded.
| Class | Examples | Describes | Correct action |
|---|---|---|---|
| Site address | 1240 County Road 27 |
a location on the ground | geocode and match |
| Postal-only | PO BOX 447, HC 62 BOX 19, RR 3 BOX 12 |
a mail receptacle | never geocode; use an alternate key |
| Positional-but-not-addressed | MP 14.5 SH 14, NE corner of Elm & 3rd |
a location, imprecisely | geocode with a wide tolerance, never confirm |
| Absent | blank, UNKNOWN, SAME, N/A |
nothing | route to the alternate key or to review |
import re
POSTAL_PATTERNS = [
re.compile(r"\bP\.?\s?O\.?\s*BOX\b", re.I),
re.compile(r"\bPOST\s+OFFICE\s+BOX\b", re.I),
re.compile(r"\bH\.?C\.?\s*\d+\s+BOX\b", re.I), # highway contract route
re.compile(r"\bR\.?R\.?\s*\d+\s+BOX\b", re.I), # rural route
re.compile(r"\bGENERAL\s+DELIVERY\b", re.I),
re.compile(r"\bPMB\s*\d+", re.I), # private mailbox
]
POSITIONAL_PATTERNS = [
re.compile(r"\bM\.?P\.?\s*\d+(\.\d+)?\b", re.I), # milepost
re.compile(r"\b(NE|NW|SE|SW|NORTH|SOUTH|EAST|WEST)\s+(CORNER|SIDE)\b", re.I),
re.compile(r"\b\d+\s*(FT|FEET|MI|MILES)\s+(N|S|E|W)\b", re.I),
]
ABSENT_VALUES = {"", "UNKNOWN", "UNKNOWN ADDRESS", "N/A", "NA", "NONE", "SAME",
"SEE NOTES", "TBD", "NO ADDRESS", "."}
def classify_address(raw: str | None) -> str:
s = " ".join((raw or "").upper().split())
if s in ABSENT_VALUES:
return "absent"
if any(p.search(s) for p in POSTAL_PATTERNS):
return "postal_only"
if any(p.search(s) for p in POSITIONAL_PATTERNS):
return "positional"
if not re.search(r"\d", s):
return "positional" # a street name with no number is not a site address
return "site"
The classifier runs before any network call, which is the point: a postal-only address consumes a geocoder request, returns a confident point, and produces a wrong match. Not asking is cheaper and more correct.
Step-by-step implementation jump to heading
1. Refuse to geocode postal-only addresses jump to heading
class NotALocation(Exception):
"""The input does not describe a place. Geocoding it produces a plausible
wrong answer rather than an error, which is why this is a hard refusal."""
def geocode_if_locatable(raw, geocoder):
cls = classify_address(raw)
if cls in ("postal_only", "absent"):
raise NotALocation(f"{cls}: {raw!r}")
geo = geocoder.geocode(raw)
if cls == "positional" and geo is not None:
geo = geo.with_kind("positional") # never eligible to confirm a match
return geo
2. Reach for the alternate keys, in order of authority jump to heading
A record with no usable address is not a lost cause — municipal permit data usually carries something else that identifies the land, and those keys are better than an address because they are already parcel-scoped.
def alternate_key_match(record, registry):
"""Try the identifiers that name the land directly. Ordered by authority: a
parcel number is definitive, a subdivision lot is near-definitive, an owner
name plus section is a hint."""
if record.parcel_number:
pid = registry.by_parcel_number(record.parcel_number)
if pid:
return Match(pid, "confirmed", evidence="parcel_number_on_record")
if record.subdivision and record.lot:
pid = registry.by_plat(record.subdivision, record.lot, record.block)
if pid:
return Match(pid, "confirmed", evidence="plat_lot_block")
if record.section and record.township and record.range_:
candidates = registry.by_plss(record.section, record.township, record.range_)
if len(candidates) == 1:
return Match(candidates[0], "probable", evidence="plss_single_parcel")
if candidates and record.owner_name:
narrowed = registry.narrow_by_owner(candidates, record.owner_name)
if len(narrowed) == 1:
return Match(narrowed[0], "probable", evidence="plss_plus_owner")
return Match(None, "ambiguous", evidence=f"plss_{len(candidates)}_parcels",
candidates=candidates)
return Match(None, "unmatched", evidence="no usable identifier")
In rural counties, parcel_number is present on a surprising share of permits precisely because the assessor’s number is how everyone locally refers to land. Checking it first — before any address handling at all — often resolves most of the 18%.
3. Handle mileposts as a corridor, not a point jump to heading
A milepost does locate something, just not precisely. Treat it as a segment of the route and return the parcels adjacent to that segment, as candidates rather than a match.
def milepost_candidates(raw, routes, parcels_gdf, tolerance_m=250.0):
m = re.search(r"\bM\.?P\.?\s*(\d+(?:\.\d+)?)", raw, re.I)
route = extract_route_name(raw)
if not m or route is None:
return []
point = routes.point_at_milepost(route, float(m.group(1)))
if point is None:
return []
near = parcels_gdf[parcels_gdf.geometry.distance(point) <= tolerance_m]
return list(near.index) # candidates, never a single confirmed match
4. Report the classes separately jump to heading
def batch_summary(records, matches):
classes = Counter(classify_address(r.address) for r in records)
return {
"site_addresses": classes["site"],
"postal_only": classes["postal_only"],
"positional": classes["positional"],
"absent": classes["absent"],
# The number that matters: how many of the NON-site records were still
# matched, via an alternate key rather than via geocoding.
"rescued_by_alternate_key": sum(
1 for r, m in zip(records, matches)
if classify_address(r.address) != "site" and m.parcel_id is not None),
}
A rural batch reported as “82% matched” hides the interesting fact. “82% matched from site addresses, plus 11% rescued by parcel number, 7% unmatched” tells you where to spend effort — and it tells a reviewer that the 11% were resolved by something stronger than an address, not weaker.
Verification & testing jump to heading
@pytest.mark.parametrize("raw", [
"PO BOX 447", "P.O. Box 12", "HC 62 BOX 19", "RR 3 BOX 12",
"General Delivery", "PMB 1180",
])
def test_postal_only_is_never_geocoded(raw, geocoder):
with pytest.raises(NotALocation):
geocode_if_locatable(raw, geocoder)
assert geocoder.call_count == 0 # the important assertion
@pytest.mark.parametrize("raw,expected", [
("1240 County Road 27", "site"),
("MP 14.5 State Hwy 14", "positional"),
("NE corner of Elm & 3rd", "positional"),
("County Road 27", "positional"), # street with no number
("", "absent"),
("SAME", "absent"),
])
def test_classification(raw, expected):
assert classify_address(raw) == expected
def test_parcel_number_beats_a_bad_address(registry):
rec = record(address="PO BOX 447", parcel_number="0714-22-1")
m = alternate_key_match(rec, registry)
assert m.tier == "confirmed" and m.evidence == "parcel_number_on_record"
The geocoder.call_count == 0 assertion is the one that keeps the behaviour honest over time. Somebody will eventually “improve” the pipeline by geocoding everything and filtering afterwards, and this test explains why that is not an improvement.
Failure recovery jump to heading
Permits already matched from PO boxes. Identify them by evidence string — anything whose evidence names a geocoder while the raw address matches a postal pattern — and retract those matches. Do not simply re-run: the wrong parcel assignments have propagated into whatever consumed them.
A whole rural county matched suspiciously well. Check the share of matches whose geocode kind is place or interpolated. A rural batch matching at 97% is almost always geocoding mailboxes and town centroids; the honest figure is lower and more useful.
Alternate keys that do not resolve. A parcel number on a permit that finds nothing in the registry usually means a format difference — stripped leading zeros, a check digit, or a hyphenation change. Normalise the parcel number the same way the registry does before concluding it is absent, which is the same class of problem described in validating zoning schema consistency across city portals.
Frequently asked questions jump to heading
Why not geocode the PO box and use the result as a weak signal?
Because the result is not weak, it is wrong. A geocoded post office is a precise point at a real building that has nothing to do with the applicant’s land, and it will fall inside a parcel with high apparent confidence. A weak signal is one that is imprecise about the right place; this is a precise answer about the wrong place, which no downstream tolerance can rescue.
Should an unmatched record be dropped?
No — record it with its class and reason. An unmatched permit is a known gap that somebody can resolve, while a dropped one is invisible and will be discovered later as a discrepancy in a count. The class matters too: 7% unmatched because the addresses are postal-only is a data-sourcing problem, and 7% unmatched because the parcel layer is stale is a different one.
Is a milepost worth using at all?
As a candidate generator, yes. A milepost plus a route locates a corridor segment, and the parcels adjacent to that segment are a genuinely useful shortlist for a human — often two or three lots. As a match, no: mileposts are recorded to the nearest tenth of a mile at best, which is 160 metres of ambiguity, and rural frontages are narrower than that.
Which alternate key should be tried first?
The assessor’s parcel number, before any address handling at all. It names the land directly, it is already in the registry’s own vocabulary, and in rural counties it appears on permits more often than a usable street address does. Trying it first turns a large part of the “unmatchable” residue into confirmed matches with better evidence than an address could ever provide.
Related jump to heading
- Parent topic: Address Geocoding & Parcel Matching
- Section overview: Automated Feed Ingestion & GIS Data Parsing
- Matching permit addresses to parcels with fuzzy and spatial joins — the join these records never reach
- Validating geocode results against parcel centroids — the distance test that catches a mailing address that slipped through