European address formats: postcodes, house numbers and the traps in each country

An address form built for one country quietly breaks in the next. The postcode has a different shape, the house number sits on the other side of the street name, and some places have no street at all. This guide lists what differs in the countries on the Addrova roadmap, so you can design a form and a validator that do not reject real addresses.

It is a field guide, not a specification. The authoritative reference is the Universal Postal Union's postal addressing documentation and each national post office. [À VÉRIFIER : relire chaque règle contre la fiche UPU du pays avant publication.]

At a glance

Country Postcode Where the house number goes Trap
France 5 digits, 90000 Before the street: 4 place d'Armes CEDEX and BP lines, lieux-dits with no street
Netherlands 4 digits and 2 letters, 1012 AB After the street: Damrak 1 Additions: a letter or a suffix after the number
Spain 5 digits, the first two give the province After the street: Calle de Alcalá, 10 s/n (no number), floor and door: 3º B
Czechia 5 digits, written 110 00 After the street, often two numbers: 837/1 Two numbers per building; villages without streets
Austria 4 digits, 1010 After the street Staircase and flat: Stiege 1, Top 5
Luxembourg 4 digits, often written L-1111 Varies by language [À VÉRIFIER] Three administrative languages for street names
Poland 5 digits with a hyphen, 00-001 After the street, flat after a slash: 10/12 Street type abbreviations: ul., al., pl.

Postcode patterns you can check in code

Validate the shape, never the existence: a well-formed code can still be unassigned. This runs on its own, without the API.

import re

POSTCODE = {
    "FR": r"\d{5}",
    "NL": r"\d{4} ?[A-Za-z]{2}",
    "ES": r"\d{5}",
    "CZ": r"\d{3} ?\d{2}",
    "AT": r"\d{4}",
    "LU": r"(L-)?\d{4}",
    "PL": r"\d{2}-\d{3}",
}

def looks_like_postcode(country: str, value: str) -> bool:
    return re.fullmatch(POSTCODE[country], value.strip()) is not None

assert looks_like_postcode("NL", "1012 AB")
assert looks_like_postcode("PL", "00-001")
assert looks_like_postcode("LU", "L-1111")
assert not looks_like_postcode("PL", "00001")
print("postcode shapes OK")

Design rules that follow

  1. One free-text field first. Let people type the address as they write it; structure it afterwards. Rigid street/number/city fields are what rejects 837/1 or 10/12.
  2. Never force a street. Some French lieux-dits and Czech villages are addressed by place and number only.
  3. Keep the country with the address. The same four digits are a valid postcode in several countries.
  4. Keep what you cannot parse. A flat, a staircase or an addition belongs to the delivery, not to the geocoder; store it, do not drop it.
  5. Do not uppercase or strip accents before geocoding. The registers keep them.

Geocoding the awkward cases

Send the whole string and the country code; the service structures it. For a French address with a CEDEX line, the result comes back at locality precision with components.cedex set: see precision levels.

curl -s https://api.addrova.eu/v1/geocode \
  -H "X-API-Key: $ADDROVA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"q": "4 place d'\''Armes 90000 Belfort", "country": "FR", "limit": 1}'

Which countries return results today is listed on the coverage page. A country on the roadmap has a page here, with its source and licence, but no coverage figure until it has been measured. [À VÉRIFIER : service ouvert ; pays réellement servis au lancement.]