Parsing Nominatim Address Details into Columns Jump to heading
Turn the address object Nominatim returns — whose keys change from country to country — into a fixed set of columns you can assert on, join on, and put in a schema.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
The address object in a Nominatim response is not a fixed schema. It contains whichever administrative levels the geocoder used to build that particular place’s hierarchy, and those differ by country and by how the region is mapped. A German result may carry city; a British one may carry town, village or suburb instead and no city at all; a rural result may carry hamlet and county but nothing in between.
Treating that object as a fixed record — reading address["city"] directly — produces a KeyError in some countries and a silently empty column in others. The correct model is a fallback chain per output column: one ordered list of candidate keys per concept, taking the first that is present.
The second half of the problem is assertion. A geocode that fell back to a settlement centroid still returns a complete-looking address object — it simply has no house_number and no road. Comparing what you queried against what the address object contains is the only reliable way to know whether the match is at the level you needed, and it is far more trustworthy than reading the human-readable display name.
Runnable solution Jump to heading
from __future__ import annotations
import logging
from typing import Any
import pandas as pd
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.nominatim.address")
# One ordered fallback chain per output column. First present key wins.
CHAINS: dict[str, tuple[str, ...]] = {
"house_number": ("house_number",),
"road": ("road", "pedestrian", "footway", "path"),
"district": ("suburb", "city_district", "borough", "neighbourhood",
"quarter", "residential"),
"settlement": ("city", "town", "village", "municipality", "hamlet"),
"county": ("county", "state_district"),
"region": ("state", "province", "region"),
"postcode": ("postcode",),
"country": ("country",),
"country_code": ("country_code",),
}
# place_rank thresholds: the scale runs coarse (continent) to fine (address point).
RANK_HOUSE = 30
RANK_STREET = 26
def flatten_address(address: dict[str, Any]) -> dict[str, str | None]:
"""Collapse the variable address object onto a fixed set of columns."""
row: dict[str, str | None] = {}
for column, keys in CHAINS.items():
row[column] = next((address[k] for k in keys if k in address), None)
# Keep anything the chains did not claim, so nothing is silently discarded.
claimed = {k for keys in CHAINS.values() for k in keys}
leftover = {k: v for k, v in address.items() if k not in claimed}
if leftover:
logger.debug("unmapped address keys: %s", sorted(leftover))
row["extra_keys"] = ";".join(sorted(leftover)) or None
return row
def match_quality(queried_street: str | None, queried_city: str | None,
row: dict[str, str | None], place_rank: int) -> str:
"""Classify the match against what was actually asked for."""
if place_rank >= RANK_HOUSE and row["house_number"]:
level = "house"
elif place_rank >= RANK_STREET and row["road"]:
level = "street"
elif row["settlement"]:
level = "settlement"
else:
level = "coarse"
# A street was asked for but the answer has none: this is a fallback, not a match.
if queried_street and level in {"settlement", "coarse"}:
return f"fallback_{level}"
# The settlement came back different from the one asked for: probably wrong place.
if queried_city and row["settlement"] and \
queried_city.casefold() not in row["settlement"].casefold():
return "settlement_mismatch"
return level
def to_frame(results: list[dict[str, Any]]) -> pd.DataFrame:
"""Build a typed table from stored Nominatim results."""
rows: list[dict[str, Any]] = []
for result in results:
flat = flatten_address(result.get("address", {}))
rank = int(result.get("place_rank", -1))
flat.update({
"lat": float(result["lat"]),
"lon": float(result["lon"]),
"osm_key": f"{result.get('osm_type')}/{result.get('osm_id')}",
"place_rank": rank,
"quality": match_quality(result.get("_queried_street"),
result.get("_queried_city"), flat, rank),
})
rows.append(flat)
frame = pd.DataFrame(rows)
# Every column is a string except the coordinates and the rank; be explicit,
# or pandas will infer object dtype for postcodes with leading zeros.
text_cols = [c for c in frame.columns
if c not in {"lat", "lon", "place_rank"}]
frame[text_cols] = frame[text_cols].astype("string")
counts = frame["quality"].value_counts().to_dict()
logger.info("match quality: %s", counts)
return frame
if __name__ == "__main__":
sample = [{
"lat": "50.0617", "lon": "19.9373", "osm_type": "way", "osm_id": 123,
"place_rank": 26,
"_queried_street": "Rynek Główny", "_queried_city": "Kraków",
"address": {"road": "Rynek Główny", "suburb": "Stare Miasto",
"city": "Kraków", "postcode": "31-042",
"country": "Polska", "country_code": "pl"},
}]
logger.info("\n%s", to_frame(sample)[["road", "settlement", "quality"]])
Step-by-step walkthrough Jump to heading
- One chain per concept. Each output column names an ordered tuple of candidate keys. The first present key wins, so a British
townand a Germancityboth land insettlement. - Order the chains by specificity, not alphabetically.
citybeforetownbeforevillagebeforehamletmeans a result carrying two of them picks the more specific, which is what a human would do. - Never discard silently. Keys no chain claimed are recorded in
extra_keysand logged at debug level. That list is how you discover a country whose hierarchy you had not seen before. - Classify rather than score.
match_qualityreturns a label —house,street,settlement,fallback_settlement,settlement_mismatch— because a label is actionable in a pipeline and a numeric confidence is not. - Compare against what was queried. A street-level query that comes back with only a settlement is a fallback, and the label says so. This is the assertion the whole page exists for.
- Catch the wrong-place case. If the returned settlement does not contain the queried city as a substring, the result is probably in the wrong place even though it looks complete.
- Set dtypes explicitly. Postcodes with leading zeros become integers under type inference and lose the zeros. Declaring string dtype on every non-numeric column prevents a whole family of quiet data loss.
Verification Jump to heading
- No column is entirely empty for a country. Group by
country_codeand count non-null values per column; an all-nullsettlementfor one country means a missing key in the chain. extra_keysis usually empty. A high rate of unmapped keys means the chains need extending for a region in your data.- Fallback labels are a small minority. A large share of
fallback_settlementmeans the queries were more specific than the available data, which is a data question, not a parsing one. - Postcodes retain leading zeros. Check a known postcode that starts with zero; if it lost the zero, the dtype declaration is not being applied.
settlement_mismatchrows are genuinely wrong. Spot-check a handful; if they are actually correct, the substring comparison is too strict for that country’s naming.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
KeyError: 'city' |
Fixed key read against a variable object | Use an ordered fallback chain per column |
| Settlement column empty for one country | That country uses town or village |
Extend the chain and re-run over stored results |
| Postcodes lost leading zeros | Pandas inferred an integer dtype | Declare string dtype on every non-numeric column |
Every row labelled house |
Rank threshold compared against the wrong scale | Read the place rank scale before setting thresholds |
| Fallbacks stored as real matches | No comparison against the queried components | Classify each row against what was actually asked |
settlement_mismatch on correct rows |
Substring test too strict for local naming | Compare on a normalised form, or relax to a token test |
| Unmapped keys never noticed | Leftovers dropped rather than recorded | Record unclaimed keys in a column and review them |
Specification reference Jump to heading
With
addressdetailsenabled, a Nominatim result includes anaddressobject whose members are the elements of the computed address hierarchy for that place. Which members are present depends on the administrative structure of the country and on what is mapped, so consumers must treat the object as a variable set of keys rather than a fixed record. See the Nominatim search API documentation for the response structure and theaddressdetailsparameter.
Frequently Asked Questions Jump to heading
Why does the address object have different keys in different countries?
Because it reflects the administrative hierarchy that actually exists where the place is, and those hierarchies genuinely differ. A settlement that is a city in one country is a town or a village in another, and some countries have an intermediate level between the city and the region that others do not. The variation is correct; what is incorrect is consuming code that assumes one fixed shape. Resolve each concept through an ordered list of candidate keys instead.
How do I know whether a result actually matched the street I asked for?
Compare what you queried against what the address object contains, and read the place rank alongside it. A street-level query that returns an address object with no road member, or a rank coarser than street level, has fallen back to a containing settlement. Both signals are available on every result and neither is visible in the display name, which is why the display name should not be the basis of the decision.
Should I keep the display name at all?
Keep it for human review and never for logic. It is a formatted string assembled for presentation, its composition varies with the result type and the requested language, and parsing it back into components reintroduces exactly the ambiguity the structured address object exists to remove. Store it, show it to reviewers, and branch on the parsed columns.
What should I do with address keys my chains do not cover?
Record them rather than dropping them. A column listing the unclaimed keys per row costs almost nothing and is the only way you will notice that a new country in your data uses a level you have never handled. Review it periodically, extend the chains when a pattern appears, and re-run the flattening over the stored results — which is free, because the geocoding itself was already cached.
Related Jump to heading
- Nominatim Geocoding Pipelines — the parent topic and the ranking model these assertions read.
- Batch Geocoding with Nominatim Without Getting Blocked — where the stored results this page flattens come from.
- Validating OSM Address Tags Against a Reference — the quality check that consumes this table.
- Tag Taxonomy & Key-Value Standards — the address namespace the hierarchy is built from.
- Mapping OSM Tags to a Fixed Schema with YAML — the same fallback-chain idea applied to tags.
Up one level: Nominatim Geocoding Pipelines.