The Address Data Model: Why an Address Is Not a String

On this page

Problem statement

An address arrives in a CSV as one column of text and gets treated as one value. Then somebody asks a question that the column cannot answer:

  • how many customers are on the same street?
  • which of these two rows are the same building?
  • why did 12% of the file fail to geocode?

All three questions need the address broken into parts, and all three are unanswerable while it is a single string. Worse, the string is not even a stable representation of the place: Flat 2, 14 High St., 14a High Street, 14 HIGH STREET FLAT 2 and 14 High Street, Apartment 2 can all be the same front door, and none of them are equal to each other.

The measured cost of pretending otherwise: in a controlled test that gave 5,000 GeoNames place names one everyday variation each, exact string equality matched 1.1% of them โ€” and those were the wrong places. The same comparison after normalising matched 71.9%.

Quick answer

Model an address as a record of typed components, not as text. The minimum useful set:

from dataclasses import dataclass, field


@dataclass
class Address:
    # sub-building, building, street: the three that decide identity
    unit: str | None = None            # Flat 2, Apt 4B, Suite 100
    house_number: str | None = None    # 14, 14a, 221B
    street: str | None = None          # High Street
    # locality: what disambiguates the street
    locality: str | None = None        # Camden Town
    town: str | None = None            # London
    region: str | None = None          # Greater London
    postcode: str | None = None        # NW1 8QP
    country: str = "GB"
    # provenance: never thrown away
    raw: str = ""
    parse_confidence: float | None = None

    def key(self) -> tuple:
        """The identity of the front door, for joining and deduplication."""
        return (self.country, (self.postcode or "").upper().replace(" ", ""),
                (self.street or "").lower(), (self.house_number or "").lower(),
                (self.unit or "").lower())

The key() method is the point of the whole exercise: two rows are the same address when their keys are equal, and no amount of string cleaning gives you that from one column.

The three jobs an address line does: identity, context and delivery. one string, three different kinds of fieldidentity โ€” belongs in the keyโœ“unit / flat numberโœ“house numberโœ“street nameโœ“postcode (fine-grained systems)context and delivery โ€” does notโœ“locality, town, regionโœ“country (key it separately)โœ“PO box, care-of, floorโœ“company nameTown in the key splits one door in two; unit out of the key merges twenty flats into one spatialworkflow.io
Identity decides whether two rows are the same place. Everything else only helps find it.

The three jobs of an address string, country schemas, normalisation

1. Separate the three jobs the string is doing

An address line carries three different kinds of information, and they behave differently:

Part Examples Behaviour
Identity unit, house number, street Decides whether two records are the same place
Context locality, town, region, country Disambiguates the identity; often optional and often wrong
Delivery postcode, PO box, care-of, floor Routes post; may not be a location at all

A deduplication that includes the town in the key will split 14 High Street, Camden from 14 High Street, London even though they are the same door. A geocoder that ignores the town will return the wrong High Street out of the several hundred in the country.

2. Accept that the schema is country-specific

There is no universal address schema, and any model that claims to be one is a European or American model with optional fields bolted on. The differences are structural, not cosmetic:

  • House number position. 14 High Street in the UK, High Street 14 in Germany, 14 rue de la Paix in France.
  • Number formats. 221B, 14a, 12-14, 4/7 (a unit-building compound in Australia), 1-2-3 (a Japanese block address that has no street at all).
  • Administrative depth. Japan and Korea address by nested blocks; large parts of the world have no formal street addressing.
  • Postcodes. Six characters in the UK, five or nine digits in the US, absent in Ireland before 2015 and in several countries still.

The practical consequence: store country first and branch parsing on it. A parser tuned for one country applied to another does not fail loudly, it fails by putting the street name in the locality field.

3. Normalise, but keep the original

Normalisation is a lossy transformation applied for matching. Applied in place, it destroys evidence:

raw:        "Flat 2, 14 High St., CAMDEN, NW1 8QP"
normalised: unit="flat 2" number="14" street="high street" postcode="NW18QP"

Keep both. The normalised form is what you join on; the raw form is what you show a human when the join produced something surprising, and what you re-parse when the parser improves.

4. Treat the postcode as a strong but separate signal

The postcode is the most useful single field in most address files: it is short, standardised, checkable against a published list, and independently sourced. It is also the field most often wrong, because it is the one people type from memory.

Use it as a check, not as the identity. If the parsed town and the postcode disagree, that is a flag on the record rather than a reason to overwrite either.

5. Record how confident the parse was

A parser that returns components with no confidence forces every downstream step to assume the parse was right. Return a score โ€” even a crude one, such as the fraction of the input tokens that were assigned to a component โ€” and let the pipeline route low-confidence rows to review instead of into the map.

How the address schema differs between the United Kingdom, Germany, France and Japan. there is no universal address schemaUKDEFRJPnumber positionbeforeafterbefore!no streetpostcode6โ€“8 chars5 digits5 digits7 digitsstreetnamednamednamed!blockBranch the parser on country, or the street name lands in the locality field spatialworkflow.io
A parser tuned for one country does not fail loudly on another โ€” it fills the wrong fields.

A rule-based parser and an identity key

The data used on this page

The Food Standards Agency's food hygiene rating open data for four local authorities โ€” Camden, Westminster, Manchester and Leeds โ€” with each establishment's address lines and postcode joined into one address column, the way an address list usually arrives. The register changes as councils update it, so counts drift a little between runs.

import os

import pandas as pd
import requests

AREAS = {"Camden": "506", "Westminster": "533", "Manchester": "415", "Leeds": "413"}   # FSA authority codes

if not os.path.exists("customers.csv"):
    rows = []
    for area, code in AREAS.items():
        # the FSA register file as saved on 9 October 2026 (updated daily); live:
        # ratings.food.gov.uk/api/open-data-files/FHRS{code}en-GB.json
        r = requests.get(f"https://www.spatialworkflow.io/data/fhrs-{code}-2026-10-09.json", timeout=300)
        r.raise_for_status()
        found = r.json()["FHRSEstablishment"]["EstablishmentCollection"]
        for e in (found if isinstance(found, list) else found["EstablishmentDetail"]):
            lines = [e.get(f"AddressLine{i}") or "" for i in (1, 2, 3, 4)] + [e.get("PostCode") or ""]
            rows.append({"id": e["FHRSID"], "area": area, "postcode_recorded": e.get("PostCode") or "",
                         "address": ", ".join(p.strip() for p in lines if p.strip())})
    pd.DataFrame(rows).to_csv("customers.csv", index=False)

df = pd.read_csv("customers.csv", dtype=str).fillna("")
print(f"{len(df):,} addresses:", df.area.value_counts().to_dict())
24,105 addresses: {'Leeds': 7460, 'Manchester': 6599, 'Westminster': 5753, 'Camden': 4293}

Example 1 โ€” a rule-based parser for one country

import re

import pandas as pd

STREET_TYPES = {
    "st": "street", "str": "street", "rd": "road", "ave": "avenue",
    "av": "avenue", "ln": "lane", "dr": "drive", "cl": "close",
    "ct": "court", "pl": "place", "sq": "square", "cres": "crescent",
}
UNIT_WORDS = r"(?:flat|apt|apartment|unit|suite|room|floor)"
UK_POSTCODE = re.compile(r"\b([A-Z]{1,2}\d[A-Z\d]?)\s*(\d[A-Z]{2})\b", re.I)


def parse_uk(raw: str) -> dict:
    """Pull components out of a UK address line. Order of removal matters:
    take the unambiguous things out first so what is left is smaller."""
    out = {"raw": raw}
    text = " ".join(raw.split())

    m = UK_POSTCODE.search(text)
    if m:
        out["postcode"] = f"{m.group(1).upper()} {m.group(2).upper()}"
        text = text[: m.start()] + text[m.end():]

    m = re.search(rf"\b{UNIT_WORDS}\.?\s*([\w\-/]+)", text, re.I)
    if m:
        out["unit"] = m.group(0).strip(" ,.")
        text = text[: m.start()] + text[m.end():]

    parts = [p.strip() for p in text.split(",") if p.strip()]
    if parts:
        m = re.match(r"^(\d+[a-z]?(?:-\d+[a-z]?)?)\s+(.*)$", parts[0], re.I)
        if m:
            out["house_number"], street = m.group(1), m.group(2)
        else:
            street = parts[0]
        tokens = street.lower().split()
        if tokens:
            tokens[-1] = STREET_TYPES.get(tokens[-1].strip("."), tokens[-1])
        out["street"] = " ".join(tokens)
        if len(parts) > 1:
            out["town"] = parts[-1]
        if len(parts) > 2:
            out["locality"] = parts[-2]

    assigned = sum(len(str(v).split()) for k, v in out.items() if k != "raw")
    out["parse_confidence"] = round(min(1.0, assigned / max(1, len(raw.split()))), 2)
    return out


print(parse_uk("Flat 2, 14 High St., Camden, London, NW1 8QP"))
parsed = pd.DataFrame(list(df["address"].map(parse_uk)))
filled = parsed[df["address"].str.strip() != ""]
print(f"{len(filled):,} non-empty addresses:",
      {field: f"{filled[field].notna().mean():.1%}" for field in ["postcode", "unit", "house_number", "street", "town"]})
{'raw': 'Flat 2, 14 High St., Camden, London, NW1 8QP', 'postcode': 'NW1 8QP', 'unit': 'Flat 2', 'house_number': '14', 'street': 'high street', 'town': 'London', 'locality': 'Camden', 'parse_confidence': 1.0}
21,542 non-empty addresses: {'postcode': '99.4%', 'unit': '12.6%', 'house_number': '62.9%', 'street': '100.0%', 'town': '98.9%'}

A regex parser is a starting point, not a destination. On the four councils' food hygiene register it finds a postcode on 99.4% of the 21,542 non-empty addresses and a house number on only 62.9%, because a market hall or a station has none and a building name often comes first. It is good enough to normalise a national file and bad at anything unusual โ€” which is exactly why it must report confidence.

Example 2 โ€” the identity key, and what it costs to get wrong



def address_key(row) -> tuple:
    postcode = (row.get("postcode") or "").upper().replace(" ", "")
    return (
        row.get("country", "GB"),
        postcode,
        (row.get("street") or "").lower(),
        (row.get("house_number") or "").lower().lstrip("0"),
        (row.get("unit") or "").lower(),
    )


def duplicate_report(df: pd.DataFrame) -> pd.DataFrame:
    keys = df.apply(address_key, axis=1)
    dupes = keys[keys.duplicated(keep=False)]
    print(f"{len(df):,} rows -> {keys.nunique():,} distinct addresses")
    print(f"{len(dupes):,} rows share a key with another row")
    return df.assign(address_key=keys)


report = duplicate_report(parsed.fillna(""))       # NaN is truthy: `x or ""` would keep it
24,105 rows -> 19,465 distinct addresses
5,819 rows share a key with another row

The lstrip("0") is not decoration. Files that have been through a spreadsheet routinely carry 007 where the source had 7, and a key that treats those as different addresses silently splits a customer in two.

Example 3 โ€” validating components against each other

def cross_check(a: dict, postcode_areas: dict) -> list[str]:
    """Return the reasons this parsed address should not be trusted."""
    problems = []
    if a.get("postcode"):
        area = a["postcode"].split()[0]
        expected_town = postcode_areas.get(area)
        if expected_town and a.get("town") and expected_town.lower() != a["town"].lower():
            problems.append(f"postcode {area} is {expected_town}, not {a['town']}")
    if a.get("house_number") and not a.get("street"):
        problems.append("house number with no street")
    if not a.get("postcode") and not a.get("town"):
        problems.append("no locality of any kind โ€” cannot be disambiguated")
    if a.get("parse_confidence", 1) < 0.6:
        problems.append(f"low parse confidence {a['parse_confidence']}")
    return problems


outward = parsed["postcode"].str.split().str[0]
postcode_areas = parsed.assign(outward=outward).dropna(subset=["outward", "town"]) \
    .groupby("outward").town.agg(lambda t: t.str.title().mode().iloc[0]).to_dict()      # the usual town
problems = parsed.apply(lambda r: cross_check(r.dropna().to_dict(), postcode_areas), axis=1)
kind = lambda p: "postcode disagrees with town" if p.startswith("postcode") else re.split(r" โ€”| \d", p)[0]
print(pd.Series([kind(p) for ps in problems for p in ps]).value_counts().to_string())
for p in [p for ps in problems for p in ps if p.startswith("postcode")][:5]:
    print(" ", p)
low parse confidence            2592
no locality of any kind         2566
postcode disagrees with town     880
  postcode NW6 is London, not West Hampstead London
  postcode NW1 is London, not Phoenix Camden London
  postcode NW3 is London, not Finchley Road
  postcode NW1 is London, not Wrotham Road
  postcode W1T is London, not Stephen Street London

Cross-checks between components are what turn a parser into a validator. Any single field can be wrong; two fields disagreeing is evidence โ€” but of a problem somewhere, not necessarily in the data. The postcode-and-town disagreements above are mostly the parser's own: without a comma between them, a street or a neighbourhood lands in the town slot. The check found the parser's weak spot as well as the data's, which is the other reason to run it.

Share of non-empty food hygiene addresses in four councils where a rule-based parser found each component. what parse_uk found, 21,542 non-empty addressescomponentfoundwhy not alwayspostcode99.4%strict format, easy to findstreet100.0%something always lands theretown98.9%the last part, right or nothouse number62.9%markets, stations, names firstunit12.6%most premises have none24,105 rows, 19,465 distinct keys; 5,819 rows share a keyA slot being filled is not the slot being right: the street and town columns are always full spatialworkflow.io
The fields that are always filled are the ones to distrust.

Why exact matching fails so badly

Why exact matching fails so badly

The 1.1% figure at the top comes from a controlled experiment, published as code in the matching guide: take a real place name, apply one realistic variation โ€” uppercase it, strip its accents, abbreviate Saint to St, drop a character, replace a hyphen with a space, add stray whitespace โ€” then look it up.

Every one of those variations is something a human types or a spreadsheet does. Individually they are trivial. Collectively they break almost every lookup, because string equality has no notion of "nearly".

Normalisation โ€” lowercase, strip accents, collapse punctuation and whitespace, expand abbreviations โ€” recovered the match rate to 71.9%. Almost all of that came from case and whitespace; abbreviation expansion added nothing measurable.

Why the remaining 28% is the interesting part

In the experiment the residue after normalisation is typos โ€” a dropped letter โ€” and in real files it is typos plus genuinely missing reference data. Fuzzy matching found the right place for 184 of 200 attempts at a 0.85 similarity cutoff, but at about 80 milliseconds per lookup against a fraction of a microsecond for a dictionary hit.

That ratio is the design rule for every address pipeline: normalise everything, fuzzy-match only the residue. Fuzzy matching a whole file is not a better algorithm, it is the same algorithm run a thousand times more expensively on rows that did not need it.

Why normalisation creates its own ambiguity

Normalisation merges. Measured in address matching: of 198,362 normalised place-name keys in the reference, 17,958 โ€” 9.1% โ€” map to more than one distinct place. Folding accents alone adds 785 shared keys to the 17,133 left after case folding, because Saint-ร‰tienne and Saint-Etienne are the same place but two names that differ only by an accent are not always.

So a normalised match is not automatically a correct match. In the same experiment, 50 of the matched rows pointed at a different place with the same normalised name, about 1.4% of the matches โ€” small, invisible, and permanent once it is in the database.

Why the unit belongs in the key and the town does not

Identity is the smallest set of fields that distinguishes one deliverable location from another. Flat 2 and Flat 3 are different locations; Camden and London are two names for the context of the same location.

Putting context in the key splits records that should join. Leaving the unit out of the key merges records that should not. Both errors are silent, and the second one is worse, because it produces a smaller, cleaner-looking table.

Match rate rising from 1.1 percent with exact matching to 71.9 percent after normalisation. 5,000 varied names against 236,014 GeoNames placesverbatim1.1%+ lowercase31.2%+ strip accents35.7%+ collapse punctuation71.9%+ expand abbreviations71.9%0100%Whitespace and punctuation carried 36.2 points; abbreviations, which people fix first, carried none spatialworkflow.io
The invisible variations โ€” case, accents, whitespace โ€” do almost all the damage.

Places without addresses, PO boxes and ranges

  • Many places have no street address. Japan addresses by block; large parts of the world have no formal addressing at all. A schema that requires street will reject them.
  • PO boxes are not locations. Geocode them and you get the sorting office.
  • St is ambiguous โ€” Street or Saint. Expand it using position: leading St is usually Saint, trailing St is usually Street.
  • Ranges and compounds โ€” 12-14 High Street, 4/7 Smith St โ€” need a rule, not a parser. Decide whether they are one address or two and record which.
  • Case is not free. Turkish dotless ฤฑ breaks naive .lower(); use casefold and be careful with locale.
  • 20.4% of the place names in GeoNames' cities500 contain non-ASCII characters. Any pipeline that assumes ASCII will lose them or mangle them.
  • Keep country first in the key. It is the only field that changes the meaning of all the others.
  • Never overwrite the raw string. It is the only thing you can re-parse when the parser improves.

FAQ

Why can I not just use the full address string as a key?

Because the same door is written several ways. In a controlled test, exact string equality matched 1.1% of place names given one everyday variation; normalised, 71.9% matched.

What are the minimum fields an address model needs?

Country, postcode, street, house number and unit for identity; town and region for context; and the raw string plus a parse confidence for provenance.

Should the postcode be part of the identity key?

Yes, as a strong disambiguator for the street โ€” but check it against the town rather than trusting it. It is the field people most often mistype.

Is there a standard address schema I can adopt?

There are several โ€” OASIS xAL, the OpenAddresses schema, ISO 19160 โ€” and all of them are supersets that make almost every field optional. They are useful as a checklist, not as a table definition.

Do I need a machine-learning parser?

Only if your data is genuinely international and unstructured. A rule-based parser per country handles national files well and is debuggable; statistical parsers such as libpostal earn their place on mixed multi-country input.

How do I handle addresses with no street?

Keep the model but leave street empty and rely on locality plus a local identifier. Do not force the block or village name into the street field โ€” that is what breaks the geocoder later.