Deduplicating Addresses Before an OSM Import Jump to heading
Before uploading an address dataset, find out what OpenStreetMap already has — which is harder than it sounds, because the same address can be mapped in three quite different ways and a naive check finds only one of them.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
OpenStreetMap represents an address in at least three ways, and a deduplication check that looks for only one will report almost everything as new.
An address node is a point carrying addr:housenumber and addr:street. It is the simplest form and the one everybody checks.
An addressed building is a way or relation carrying the same tags on the building itself. There is no separate point, so a check that only examines nodes finds nothing — and then the import adds an address node inside a building that already has that address.
An interpolation way is a line between two address nodes carrying addr:interpolation, asserting that the numbers between them exist along it. The individual addresses are not mapped as objects at all; they are implied. A check that ignores interpolation will duplicate whole streets.
The second complication is that housenumbers are messy. “12”, “12A”, “12-14”, “12/3” and “12 a” may all denote the same or different things depending on the country, and a string comparison over raw values is unreliable in both directions.
Runnable solution Jump to heading
from __future__ import annotations
import logging
import re
import unicodedata
from dataclasses import dataclass
import geopandas as gpd
import pandas as pd
from shapely.geometry import Point
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.import.dedupe")
MATCH_RADIUS_M = 40.0
_WS = re.compile(r"\s+")
_NUM = re.compile(r"^(\d+)\s*([A-Za-z]?)$")
def norm_street(name: str) -> str:
text = unicodedata.normalize("NFKC", name or "").casefold()
return _WS.sub(" ", re.sub(r"[^\w\s]", " ", text)).strip()
def norm_number(value: str) -> str:
"""Canonical housenumber: digits plus an optional single letter suffix."""
text = unicodedata.normalize("NFKC", value or "").casefold()
text = _WS.sub("", text.replace("–", "-"))
match = _NUM.match(text)
return f"{int(match.group(1))}{match.group(2)}" if match else text
def expand_interpolation(row) -> list[str]:
"""Housenumbers a single interpolation way asserts, as canonical strings."""
kind = (row.get("addr:interpolation") or "").lower()
try:
start, end = int(row["_from"]), int(row["_to"])
except (TypeError, ValueError, KeyError):
return []
if start > end:
start, end = end, start
step = {"odd": 2, "even": 2, "all": 1}.get(kind)
if step is None:
# A numeric interpolation value is a literal step, e.g. "4".
step = int(kind) if kind.isdigit() else 1
if kind == "odd" and start % 2 == 0:
start += 1
if kind == "even" and start % 2 == 1:
start += 1
return [str(n) for n in range(start, end + 1, step)]
def existing_addresses(osm: gpd.GeoDataFrame,
interpolations: gpd.GeoDataFrame | None = None
) -> gpd.GeoDataFrame:
"""Every address OSM already holds, in all three representations."""
frames: list[gpd.GeoDataFrame] = []
# 1 + 2: nodes AND ways/relations carrying address tags. Using the
# representative point of a building is what makes the two comparable.
addressed = osm[osm["addr:housenumber"].notna()
& osm["addr:street"].notna()].copy()
addressed["geometry"] = addressed.geometry.representative_point()
addressed["_form"] = addressed.geometry.geom_type.where(
addressed["_osm_type"].eq("node"), "building")
frames.append(addressed)
# 3: interpolation ways, expanded into the numbers they assert.
if interpolations is not None and len(interpolations):
rows = []
for _, way in interpolations.iterrows():
for number in expand_interpolation(way):
rows.append({
"addr:housenumber": number,
"addr:street": way.get("addr:street"),
"_form": "interpolation",
"geometry": way.geometry.interpolate(0.5, normalized=True),
})
if rows:
frames.append(gpd.GeoDataFrame(rows, geometry="geometry",
crs=interpolations.crs))
out = pd.concat(frames, ignore_index=True)
out["_key"] = (out["addr:street"].map(norm_street) + "|"
+ out["addr:housenumber"].map(norm_number))
logger.info("OSM already holds %d address(es): %s",
len(out), out["_form"].value_counts().to_dict())
return gpd.GeoDataFrame(out, geometry="geometry", crs=osm.crs)
def classify(candidates: gpd.GeoDataFrame, existing: gpd.GeoDataFrame,
epsg: int) -> pd.DataFrame:
"""Split import candidates into new, duplicate and review."""
left = candidates.to_crs(epsg=epsg).copy()
right = existing.to_crs(epsg=epsg).copy()
left["_key"] = (left["addr:street"].map(norm_street) + "|"
+ left["addr:housenumber"].map(norm_number))
probe = left.copy()
probe["geometry"] = probe.geometry.buffer(MATCH_RADIUS_M)
near = gpd.sjoin(probe[["_key", "geometry"]], right[["_key", "_form",
"geometry"]],
how="left", predicate="intersects",
lsuffix="new", rsuffix="osm")
same = near["_key_new"].eq(near["_key_osm"])
duplicate_keys = set(near.loc[same, "_key_new"])
# A nearby address on the SAME street with a DIFFERENT number is not a
# duplicate, but a nearby identical key beyond the radius might be.
left["_status"] = [
"duplicate" if k in duplicate_keys else "new" for k in left["_key"]
]
far_dupes = set(left.loc[left["_status"] == "new", "_key"]) & set(right["_key"])
left.loc[left["_key"].isin(far_dupes), "_status"] = "review"
counts = left["_status"].value_counts().to_dict()
logger.info("import candidates: %s", counts)
return left[["_key", "_status", "addr:housenumber", "addr:street"]]
if __name__ == "__main__":
logger.info("upload only rows whose status is 'new'")
Step-by-step walkthrough Jump to heading
- Collect all three representations. Address nodes, addressed buildings and expanded interpolation ways go into one frame. Omitting any of them makes the check report duplicates as new.
- Use a representative point for areas. A building’s representative point is guaranteed to lie inside it, unlike a centroid, which makes an addressed building directly comparable with an address node.
- Expand interpolation ranges honestly. Odd, even, all and numeric steps each behave differently, and the start value has to be adjusted to the right parity. A range that fails to parse contributes nothing rather than guessing.
- Canonicalise the key. Street name normalised, housenumber reduced to digits plus an optional letter, joined into one comparable key.
"12 A"and"12a"must produce the same key. - Join spatially, then compare keys. Proximity alone is not a duplicate — a different number on the same street is usually the house next door — so the spatial join finds candidates and the key comparison decides.
- Treat a distant identical key as review, not duplicate. The same street and number appearing far away may be a long street, a repeated name, or a positional error, and a human should look.
- Report the three counts. New, duplicate and review are the numbers the import plan promised, and they are the first thing a reviewer will ask for.
Verification Jump to heading
- All three forms are represented. The counts by form should be non-zero wherever the area uses them; an absent form usually means the filter missed it.
- Interpolation expansion is plausible. A way from 2 to 20 marked even should yield ten numbers, not nineteen.
- Key canonicalisation collapses variants. Confirm that differently formatted versions of one housenumber produce one key.
- Proximity alone does not duplicate. A record with a different number on the same street, metres away, must be classified as new.
- The review group is small and interesting. Sample it; the entries should be genuinely ambiguous rather than routine.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Nearly everything reported as new | Only address nodes checked | Include addressed buildings and interpolation ways |
| Whole streets duplicated | Interpolation ways ignored | Expand interpolation ranges into implied numbers |
| Variants of one number treated separately | Raw housenumber strings compared | Canonicalise to digits plus an optional letter |
| Neighbouring houses marked duplicate | Proximity used as the decision | Compare canonical keys after the spatial join |
| Duplicates missed on long streets | Radius smaller than the positional offset | Route distant identical keys to review |
| Areas and points incomparable | Centroid used for buildings | Use a representative point, which is inside the shape |
| Counts do not match the plan | Statuses computed but not reported | Report new, duplicate and review counts every run |
Specification reference Jump to heading
OpenStreetMap address data may be attached to a node, to a way or relation representing a building or site, or asserted over a range by an
addr:interpolationway whose endpoints carryaddr:housenumbervalues. The interpolation value may beodd,even,all, or a numeric step. See the addresses documentation on the OSM Wiki for the tagging of each form and the semantics of interpolation.
Frequently Asked Questions Jump to heading
Why is checking address nodes not enough?
Because in many regions most addresses are tagged on the building rather than on a separate point, and in others whole residential streets are represented by interpolation ways with no individual address objects at all. A node-only check finds none of those, reports them as new, and the import adds a duplicate address inside every already-addressed building and along every interpolated street. Which form dominates varies by region, so all three have to be checked.
How should housenumbers be compared?
On a canonical form rather than as raw strings. Reduce to the numeric part plus an optional single-letter suffix, folding case and removing whitespace, so that differently formatted versions of one number compare equal. Ranges and compound numbers need a local decision, because whether “12-14” is one address or three depends on the country’s conventions — and that decision belongs in the published import plan.
What if the same address exists far from where my record places it?
Route it to review rather than deciding automatically. A matching street and number beyond the search radius can mean a long street where both are genuine, a repeated street name in the same settlement, or a positional error on one side. All three are real, they need different responses, and none of them is safe to guess at. It is also a small enough group that human review is affordable.
Does a duplicate mean the OSM data is better?
Not necessarily, and this is exactly why what happens to a duplicate belongs in the plan rather than in the code. The existing feature may be a surveyed address a mapper walked past, or it may be a rough placement from an older import. Adding attributes it lacks is usually uncontroversial; overwriting what it has is not. Decide the policy in advance, publish it, and let the code implement one rule rather than improvise.
Related Jump to heading
- Preparing an OSM Import — the parent topic and the policy this check implements.
- Converting a Shapefile to OSM XML with ogr2osm — producing the candidates this filters.
- Validating OSM Address Tags Against a Reference — the same comparison used as a quality check.
- Nearest-Neighbour Matching with GeoPandas sjoin_nearest — the spatial join this builds on.
- Parsing Nominatim Address Details into Columns — normalising address components from a geocoder.
Up one level: Preparing an OSM Import.