Validating OSM Address Tags Against a Reference Jump to heading

Find the addresses that cannot be geocoded, using checks that need no reference data at all — and know when a reference is genuinely required.

Prerequisites Jump to heading

Conceptual minimum Jump to heading

“Validating against a reference” is where address QA usually starts, and it is the wrong place to start. Most defective OSM addresses are internally inconsistent, and internal inconsistency needs nothing external to detect.

Which address checks need an external reference A grid of five checks. Housenumber present with street is checkable alone as internal consistency. Postcode matching the country format is checkable with a per-country regular expression. Street name spelling can be partly checked by fuzzy matching against nearby ways and is better with a reference. Whether the address exists at all cannot be checked without an authoritative address file. City matching the containing boundary is checkable with a spatial join. What can be checked without a reference, and what cannot checkable alone? needs a reference housenumber present with street yes — internal consistency no postcode matches the country format yes — a regex per country no street name spelled as the nearby street partly — fuzzy match to nearby ways better with one the address exists no an authoritative address file city matches the containing boundary yes — a spatial join no Three of these five need no external data at all, and they catch most of what is actually wrong.
Start with the three that need nothing. They are cheap, they never go stale, and they find the majority of real defects.
Defect distribution across 2.86 million OSM addresses A bar chart of a European extract. 91.3 percent of addresses are complete and consistent. 5.2 percent carry a housenumber with no street or place and are unusable for geocoding. 2.1 percent have a postcode failing the country format from a typo or the wrong country. 1.0 percent have a city that disagrees with the containing boundary, often at a boundary edge. 0.4 percent have a housenumber that is a range or a list. What a real address layer looks like 2.86 M addr:housenumber nodes and ways, European extract complete and consistent 91.3% — nothing to report housenumber, no street or place 5.2% — unusable for geocoding postcode fails the country format 2.1% — typo or wrong country city disagrees with the boundary 1.0% — often a boundary edge case housenumber is a range or list 0.4% — "12-14", "3;5;7" The largest defect class needs no reference data to find: an address with a number and nothing to attach it to.
Five percent of addresses cannot be geocoded because nothing says which street they are on — and that is found with a dictionary lookup.

The largest single defect class — a addr:housenumber with no addr:street and no addr:place — is a dictionary lookup away. It also matters more than it looks: such an address is not merely incomplete, it is unusable, because a house number without a street cannot be resolved to anything.

The four address checks in increasing order of cost A four-stage chain. Completeness asks whether a housenumber is accompanied by a street or place, costing a dictionary lookup. Format checks a postcode against a country regular expression. Containment compares the city or postcode against the boundary the address falls inside, needing a spatial join. Conflation matches against an authoritative address file, which is fuzzy, expensive and optional. Four checks in increasing cost order completeness housenumber ⟹ street or place a dict lookup format postcode against a country regex one regex containment city / postcode vs the boundary a spatial join conflation match an authoritative file fuzzy, expensive, optional Run them in this order and the expensive one only ever sees the addresses the cheap ones could not clear.
Cheapest first is not just an optimisation here — the cheap checks also produce the least ambiguous findings.

Runnable solution Jump to heading

python
#!/usr/bin/env python3
"""Validate OSM address tags: completeness, format, containment, then conflation."""
from __future__ import annotations

import logging
import re
from dataclasses import dataclass
from enum import Enum

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger(__name__)


class AddressDefect(str, Enum):
    NO_STREET_OR_PLACE = "no_street_or_place"
    POSTCODE_FORMAT = "postcode_format"
    HOUSENUMBER_SHAPE = "housenumber_shape"
    CITY_MISMATCH = "city_mismatch"
    POSTCODE_MISMATCH = "postcode_mismatch"


@dataclass(frozen=True)
class Finding:
    osm_id: int
    defect: AddressDefect
    detail: str


#: Postcode formats, by ISO country code. Deliberately permissive: the goal is to
#: catch a transposed digit or a postcode from the wrong country, not to be a
#: definitive validator of every national scheme.
POSTCODE_PATTERNS: dict[str, re.Pattern[str]] = {
    "DE": re.compile(r"^\d{5}$"),
    "FR": re.compile(r"^\d{5}$"),
    "GB": re.compile(r"^[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}$", re.I),
    "IE": re.compile(r"^[A-Z]\d{2}\s*[A-Z\d]{4}$", re.I),
    "NL": re.compile(r"^\d{4}\s*[A-Z]{2}$", re.I),
    "PL": re.compile(r"^\d{2}-\d{3}$"),
    "US": re.compile(r"^\d{5}(-\d{4})?$"),
}

#: A single number, optionally with a letter suffix: 12, 12a, 12 A.
_SIMPLE_NUMBER = re.compile(r"^\d+\s*[A-Za-z]?$")
#: Ranges and lists are legitimate but need different handling downstream.
_RANGE_OR_LIST = re.compile(r"^\d+\s*[-–/;,]\s*\d+.*$")


def check_completeness(osm_id: int, tags: dict[str, str]) -> list[Finding]:
    """A housenumber needs something to attach itself to."""
    if "addr:housenumber" not in tags:
        return []
    if tags.get("addr:street") or tags.get("addr:place"):
        return []
    return [Finding(osm_id, AddressDefect.NO_STREET_OR_PLACE,
                    f"housenumber {tags['addr:housenumber']!r} with no street or place")]


def check_postcode(osm_id: int, tags: dict[str, str], country: str | None) -> list[Finding]:
    postcode = tags.get("addr:postcode")
    if not postcode:
        return []
    code = (tags.get("addr:country") or country or "").upper()
    pattern = POSTCODE_PATTERNS.get(code)
    if pattern is None:
        return []                       # no pattern for this country: not a defect
    if pattern.match(postcode.strip()):
        return []
    return [Finding(osm_id, AddressDefect.POSTCODE_FORMAT,
                    f"{postcode!r} does not match the {code} format")]


def check_housenumber(osm_id: int, tags: dict[str, str]) -> list[Finding]:
    """Ranges and lists are valid tagging; flag them so consumers can expand them."""
    number = tags.get("addr:housenumber")
    if not number:
        return []
    if _SIMPLE_NUMBER.match(number.strip()):
        return []
    if _RANGE_OR_LIST.match(number.strip()):
        return [Finding(osm_id, AddressDefect.HOUSENUMBER_SHAPE,
                        f"{number!r} is a range or list — expand before geocoding")]
    return [Finding(osm_id, AddressDefect.HOUSENUMBER_SHAPE,
                    f"{number!r} is not a recognised housenumber form")]


def check_containment(osm_id: int, tags: dict[str, str],
                      boundary_city: str | None,
                      boundary_postcode: str | None) -> list[Finding]:
    """Compare the tagged city and postcode against the boundary the point falls in.

    Case- and whitespace-insensitive, because a difference in capitalisation is
    not a defect and reporting it drowns the real mismatches.
    """
    findings: list[Finding] = []
    tagged_city = (tags.get("addr:city") or "").strip().casefold()
    if tagged_city and boundary_city and tagged_city != boundary_city.strip().casefold():
        findings.append(Finding(osm_id, AddressDefect.CITY_MISMATCH,
                                f"tagged {tags['addr:city']!r}, inside {boundary_city!r}"))
    tagged_pc = (tags.get("addr:postcode") or "").replace(" ", "").casefold()
    if tagged_pc and boundary_postcode:
        if tagged_pc != boundary_postcode.replace(" ", "").casefold():
            findings.append(Finding(osm_id, AddressDefect.POSTCODE_MISMATCH,
                                    f"tagged {tags['addr:postcode']!r}, "
                                    f"inside {boundary_postcode!r}"))
    return findings


def validate(rows: list[tuple[int, dict[str, str], str | None, str | None, str | None]]
             ) -> list[Finding]:
    """rows: (osm_id, tags, country, boundary_city, boundary_postcode)."""
    findings: list[Finding] = []
    for osm_id, tags, country, city, postcode in rows:
        findings += check_completeness(osm_id, tags)
        findings += check_postcode(osm_id, tags, country)
        findings += check_housenumber(osm_id, tags)
        findings += check_containment(osm_id, tags, city, postcode)

    counts: dict[AddressDefect, int] = {d: 0 for d in AddressDefect}
    for finding in findings:
        counts[finding.defect] += 1
    total = len(rows) or 1
    for defect, n in counts.items():
        logger.info("%-22s %7d  %5.2f%%", defect.value, n, 100 * n / total)
    return findings

Step-by-step walkthrough Jump to heading

check_completeness accepts addr:place as well as addr:street, because in places without street names — parts of Japan, rural Ireland before Eircode, many informal settlements — addr:place is the correct tagging. A validator that demands addr:street reports an entire country’s correct addresses as broken, which is the regional-convention trap described in Best Practices for OSM Tag Standardization Across Regions.

POSTCODE_PATTERNS returns no finding for countries it has no pattern for. The alternative — treating an unknown country as a failure — produces a validator whose defect rate is a function of your table’s coverage rather than of the data.

check_housenumber distinguishes ranges from nonsense. 12-14 and 3;5;7 are legitimate tagging for a building spanning several numbers, and reporting them as errors is wrong; reporting them as “expand before geocoding” is useful, because a geocoder that treats 12-14 as a literal string will never match a search for 13.

check_containment casefolds and strips before comparing. Without that, München versus MÜNCHEN becomes a finding, and the real mismatches disappear into thousands of capitalisation differences.

The boundary city and postcode come from a spatial join done upstream, using the pattern in Accelerating Point-in-Polygon Joins on OSM Data — this function only compares.

Verification Jump to heading

Test the regional cases explicitly, since they are where validators overreach:

python
def test_addr_place_is_acceptable():
    assert not check_completeness(1, {"addr:housenumber": "3", "addr:place": "Kilcurry"})

def test_missing_street_and_place_is_a_defect():
    findings = check_completeness(2, {"addr:housenumber": "3"})
    assert findings[0].defect is AddressDefect.NO_STREET_OR_PLACE

def test_unknown_country_is_not_a_postcode_failure():
    assert not check_postcode(3, {"addr:postcode": "ABC-123"}, country="ZZ")

def test_case_difference_is_not_a_mismatch():
    assert not check_containment(4, {"addr:city": "MÜNCHEN"}, "München", None)

def test_range_is_flagged_not_rejected():
    findings = check_housenumber(5, {"addr:housenumber": "12-14"})
    assert "expand" in findings[0].detail

Then run over a real extract and compare the distribution against the chart. A postcode_format rate far above two percent usually means the country is being resolved incorrectly — check the spatial join before suspecting the data.

For the city-mismatch findings, sample twenty by hand before acting on any of them. A large share are addresses genuinely near a boundary where the tagged city is the postal city and the boundary is the administrative one, and those are not defects in either dataset.

Common errors and fixes Jump to heading

Symptom Root cause Fix
Whole regions reported as incomplete addr:place not accepted Accept street or place
Postcode failures spike for one country No pattern, or the wrong country resolved Return no finding for unknown countries
Thousands of city mismatches Case or whitespace differences Casefold and strip before comparing
12-14 reported as invalid Ranges treated as malformed Flag for expansion, do not reject
Findings dominated by boundary-edge cases Postal city compared with administrative Compare against the postal boundary, or downgrade the severity
Conflation finds nothing Street names normalised differently on each side Normalise both sides identically first

Frequently Asked Questions Jump to heading

Do I need an authoritative address file?

Not to start. The three reference-free checks find over eight percent of addresses with real problems, and they never go stale. A reference file answers a different question — does this address exist — which is valuable for completeness reporting and much harder to act on, because a missing match is as likely to mean the reference is out of date as that the OSM address is wrong.

Should a city mismatch be auto-corrected from the boundary?

No. The tagged addr:city is frequently the postal city, which legitimately differs from the administrative boundary — postal geography and administrative geography are different things and neither is wrong. Overwriting the tagged value with the boundary’s name destroys the postal information and makes the address worse for its main use.

How should housenumber ranges be handled downstream?

Expand them into individual addresses for geocoding, keeping the original string. 12-14 becomes 12, 13 and 14 as searchable entries pointing at one feature, so a search for 13 succeeds while the map still shows one building. Interpolating even and odd sides correctly needs local knowledge, which is why addr:interpolation ways exist and are worth reading rather than guessing.

What severity do these deserve?

no_street_or_place is an error — the address cannot be used. Format and shape findings are warnings, since the address is usable and merely irregular. Containment mismatches are informational until you have sampled enough to know how many are real in your area. This maps onto the severity taxonomy in OSM Data Quality & Validation, where the point of a severity is the action it triggers.

Specification reference Jump to heading

The Karlsruhe schema places address components on addr:* keys: addr:housenumber, addr:street, addr:place, addr:postcode, addr:city and addr:country. A house number is attached to a street by addr:street, or, where streets are unnamed, to a settlement by addr:place; the two are alternatives and at least one is required for the address to resolve.

Up one level: Tag & Attribute Consistency Checks.