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 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 Streetin the UK,High Street 14in Germany,14 rue de la Paixin 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.
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.
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.
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
streetwill reject them. - PO boxes are not locations. Geocode them and you get the sorting office.
Stis ambiguous โ Street or Saint. Expand it using position: leadingStis usually Saint, trailingStis 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'
cities500contain non-ASCII characters. Any pipeline that assumes ASCII will lose them or mangle them. - Keep
countryfirst 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.
Internal links
- Geocoding explained: from an address string to a coordinate โ what happens after parsing
- How to parse and normalise addresses in Python โ the working implementation
- Address matching explained: why exact string equality fails โ the matching side in detail
- How to deduplicate an address list in Python โ using the identity key
- How to clean CSV coordinates into a GeoDataFrame โ the coordinate side of the same file
- How to standardise attribute values โ the general normalisation pattern
- Spatial data quality dimensions โ where address quality sits in the wider picture
- Fixing addresses that break on accents and encoding โ when normalisation is the problem
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.