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.

Address classes in an urban and a rural permit file Stacked bar chart comparing address classes in two permit files of 5 000 records each. The urban file is 4 810 site addresses with small numbers of postal-only, positional and absent values. The rural file has only 3 640 site addresses, with 620 postal-only, 410 positional and 330 absent. Nearly 27 per cent of the rural file cannot be geocoded meaningfully, which is the population this page is about. Address classes in an urban and a rural permit file 0 2000 4000 6000 4810 urban file 410 620 3640 rural file Permit records (5 000 each) absent positional (milepost, corner) postal-only (PO box, HC/RR) site address Only the bottom band is safe to geocode.
Twenty-seven per cent of the rural file does not describe a location at all. Geocoding it anyway returns points for post offices and town centroids, each of which falls inside some parcel — so the pipeline would report a higher match rate on the file with worse data.
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.

Alternate keys, ranked by what they can establish A four-row by four-column matrix of alternate identifiers on a permit record. Rows are the assessor parcel number, subdivision with lot and block, the public land survey section township and range, and owner name alone. Columns are what the key identifies, the tier it can support, how often it is present on rural permits, and its failure mode. A parcel number identifies the land definitively and supports a confirmed match. A PLSS section identifies a square mile and can only narrow, never confirm. Owner name alone identifies a person and cannot locate land at all. Alternate keys, ranked by what they can establish identifies best tier it supports present on rural permits failure mode Assessor parcel number the parcel itself confirmed often format drift Subdivision + lot + block one platted lot confirmed sometimes plat name variants PLSS section / township / range a square mile probable at best usually many parcels Owner name alone a person none always owns several parcels strong narrows only cannot locate land
The parcel number is worth checking before any address handling: it names the land directly, it is in the registry's own vocabulary, and on rural permits it is present more often than a usable street address. The bottom row is included as a warning — an owner name locates a person, not a parcel.
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.

What happens to the 1 360 non-site rural records Lollipop chart of outcomes for the 1 360 rural permit records whose address is not a site address. The assessor parcel number resolves 780 of them, subdivision with lot and block resolves a further 190, PLSS narrowed to a single parcel resolves 95, milepost corridors produce candidate sets for 140, and 155 remain unmatched. A dashed rule marks zero as the target for the unmatched row, which is not achievable and is worth stating rather than hiding. What happens to the 1 360 non-site rural records 0 200 400 600 800 1000 limit parcel number on the record 780 subdivision + lot + block 190 PLSS narrowed to one parcel 95 milepost corridor (candidates) 140 still unmatched 155 Records of the 1 360 non-site addresses "Still unmatched" is recorded with its class, never dropped.
Just over half the unmatchable residue is rescued by the parcel number already on the permit — evidence stronger than any address could provide. The 155 that remain are recorded as unmatched with their class, which is a known gap somebody can work on rather than an invisible one.

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.