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.
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.
Runnable solution Jump to heading
#!/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:
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:cityandaddr:country. A house number is attached to a street byaddr:street, or, where streets are unnamed, to a settlement byaddr:place; the two are alternatives and at least one is required for the address to resolve.
Related Jump to heading
- Tag & Attribute Consistency Checks — the topic these checks belong to.
- Accelerating Point-in-Polygon Joins on OSM Data — the join that supplies the boundary values.
- Best Practices for OSM Tag Standardization Across Regions — why a street-only rule fails abroad.
- Flagging Deprecated OSM Tags in a Pipeline — a sibling tag check with a clearer auto-fix path.
- OSM Data Quality & Validation — the severity taxonomy these findings map onto.
Up one level: Tag & Attribute Consistency Checks.