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.

Which address keys carry the same concept in different places A grid mapping four output columns to the response keys that may carry them. The settlement column may arrive as city, town, village or hamlet depending on size and country. The district column may arrive as suburb, city district, borough or neighbourhood. The region column may arrive as state, province, region or county. The road column is comparatively stable but may arrive as road, pedestrian or footway for addresses on a path. A note warns that a fixed key read produces a silently empty column rather than an error. One concept, several possible keys Primary key Common alternates When it varies Settlement city town, village, hamlet by size and country District suburb city_district, borough by mapping style Region state province, region, county by country Road road pedestrian, footway by way type Reading one fixed key per concept gives an empty column in every country that names it differently, and no error anywhere.
The variation is not noise: each alternate key is the correct term for that kind of place in that country.

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

python
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

  1. One chain per concept. Each output column names an ordered tuple of candidate keys. The first present key wins, so a British town and a German city both land in settlement.
  2. Order the chains by specificity, not alphabetically. city before town before village before hamlet means a result carrying two of them picks the more specific, which is what a human would do.
  3. Never discard silently. Keys no chain claimed are recorded in extra_keys and logged at debug level. That list is how you discover a country whose hierarchy you had not seen before.
  4. Classify rather than score. match_quality returns a label — house, street, settlement, fallback_settlement, settlement_mismatch — because a label is actionable in a pipeline and a numeric confidence is not.
  5. 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.
  6. 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.
  7. 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.
How a raw address object becomes an assertable row Four steps. The chain step resolves each output column by taking the first present key from an ordered candidate list. The capture step records any keys no chain claimed, so an unfamiliar country hierarchy is discovered rather than dropped. The classify step compares the resolved row and the place rank against what was originally queried and assigns a match label. The type step declares explicit string types so postcodes with leading zeros survive. Four steps, and only the third one is a judgement chain first present key wins ordered by specificity capture keep unclaimed keys discovers new countries classify queried vs returned a label, not a score type explicit string dtypes leading zeros survive Step two is what keeps this code honest over time: without it, a country you have never seen just produces empty columns.
Only the classification is a judgement call, and it is deliberately a label so downstream code can branch on it.
The five match labels this parser emits and what each one means for the pipeline Three panels grouping the match labels. The accepted group covers house and street level matches, where the queried component appears in the returned address and the place rank is fine enough. The review group covers settlement level matches and fallbacks, where the geocoder answered with a containing place because nothing finer matched, so the coordinate is a centroid rather than a location. The reject group covers settlement mismatches and coarse results, where the returned place does not correspond to what was asked for at all. Labels, not scores, so downstream code can branch Accept house: number and road present street: road present, fine rank Queried component came back Coordinate is a real location Safe to store and join on Review settlement: only a place matched fallback_settlement: street asked Coordinate is a centroid Not the address you wanted Route to a human or reject Reject settlement_mismatch: wrong place coarse: nothing usable matched Looks complete, is not Storing these poisons joins Fail the row explicitly The middle group is the dangerous one: a settlement centroid is a valid coordinate that is simply not the answer to the question asked.
Splitting into three actions rather than a single confidence number is what lets the pipeline route rows without a threshold argument.

Verification Jump to heading

  • No column is entirely empty for a country. Group by country_code and count non-null values per column; an all-null settlement for one country means a missing key in the chain.
  • extra_keys is 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_settlement means 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_mismatch rows 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 addressdetails enabled, a Nominatim result includes an address object 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 the addressdetails parameter.

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.

Up one level: Nominatim Geocoding Pipelines.