TL;DR: German addresses put the number after the street, glue the street type onto the
name, and use hyphenated ranges and letter suffixes freely. Anchor the number at the end of
the line, split the compound suffix separately, and keep ß handling in the match key rather
than in the parser. The regional context is
parsing European address conventions.
The Pattern
from __future__ import annotations
import re
# Street types that appear glued to the end of the name, longest first so
# "strasse" is preferred over "str" when both would match.
STREET_TYPES = (
"straße", "strasse", "str", "gasse", "weg", "allee", "platz", "ring",
"damm", "ufer", "chaussee", "steig", "pfad", "markt",
)
_TYPES_ALT = "|".join(sorted(STREET_TYPES, key=len, reverse=True))
DE_LINE_RE = re.compile(
r"""
^(?P<street>.+?) # lazy: stops at the number
\s+
(?P<number>
\d+ # 12
(?:\s*[-/]\s*\d+)? # 12-14 or 12/14 (a range)
\s*[a-zA-Z]? # 12a
)
\s*$ # the number closes the line
""",
re.VERBOSE,
)
SUFFIX_RE = re.compile(rf"(?P<base>.+?)(?P<suffix>{_TYPES_ALT})$", re.IGNORECASE)
The lazy .+? on the street combined with the end-anchored number is what makes this
robust. A greedy street group would consume the number, and an unanchored number group would
match the first digits it found — which in Straße des 17. Juni 12 is the wrong ones
entirely.
Compound Suffixes Are the Real Difference
The symptom of getting this wrong is subtle: the street name is captured correctly, the address renders correctly, and the suffix column is empty. Nothing fails until a join or a deduplication key that expects a suffix quietly stops matching.
The Parser
from dataclasses import dataclass
@dataclass(frozen=True)
class GermanAddress:
street_base: str # "Bahnhof"
street_suffix: str # "straße"
street_full: str # "Bahnhofstraße" — what goes on the label
house_number: str # "12a" or "12-14", always text
def parse_de(line: str) -> GermanAddress | None:
"""Parse a German-convention delivery line into its components."""
text = " ".join(line.replace(",", " ").split())
m = DE_LINE_RE.match(text)
if m is None:
return None
street = m.group("street").strip()
number = re.sub(r"\s+", "", m.group("number"))
sm = SUFFIX_RE.match(street)
if sm is None:
return GermanAddress(street, "", street, number)
return GermanAddress(
street_base=sm.group("base").strip(),
street_suffix=sm.group("suffix"),
street_full=street,
house_number=number,
)
Keeping street_full alongside the split components avoids a common regression: rebuilding
the name by concatenation loses the original capitalisation and any internal hyphen, and a
label printed from the reconstruction differs subtly from what the customer typed.
Where the Pattern Can Go Wrong
Numbers That Are Not Integers
| Input | Meaning | Store as |
|---|---|---|
12 |
a single building | "12" |
12a |
a subdivided plot | "12a" |
12-14 |
one entity spanning three plots | "12-14" |
12/1 |
building 12, staircase 1 — Austrian | "12/1" |
12 a |
the same as 12a, spaced |
normalise to "12a" |
Every row in that table breaks if the column is typed as an integer, and the failure is
silent: the row is either dropped at load or truncated to 12, and both look like clean
data afterwards. Text storage plus a normalisation of internal whitespace covers all five.
Austria and Switzerland Differ Slightly
The German convention extends across Austria and German-speaking Switzerland with two practical differences worth handling explicitly.
Austrian addresses commonly carry a staircase and door after the house number — 12/3/14
meaning building 12, staircase 3, door 14 — and those trailing components are delivery
information rather than decoration. Capture them as unit components rather than letting them
fall into the house-number group, where they will confuse any downstream range logic.
Swiss addresses use the same street-then-number order but do not use ß at all; it is
always written ss. That means a Swiss corpus needs no eszett folding, and, more usefully,
that a record containing ß is unlikely to be Swiss — a small signal, but a free one when
country inference is uncertain.
Both countries also use four-digit postcodes rather than Germany’s five, which is the strongest available discriminator among the three when the country field is missing.
Testing Against Real Street Names
Edge Cases and Failure Modes
Street names ending in a number. Straße des 17. Juni has a number inside the name.
The end anchor handles it correctly; a pattern searching for the first digit does not.
A house number written before the street. Data entered by an Anglophone user sometimes
arrives as 12 Bahnhofstraße. Detect it by the leading digits and swap, but record that the
swap happened — silent reordering makes a later mismatch impossible to explain.
Abbreviated suffixes. Bahnhofstr. is common and the trailing period must be tolerated.
The suffix list includes str for exactly this reason, and the period is stripped before
matching.
Eszett in the match key. Straße and Strasse must produce the same key, which is a
folding concern rather than a parsing one — see
removing diacritics from addresses in Python.
The parser should leave the character exactly as it found it.
Why the Suffix Split Is Worth the Effort
A parser that captures the street name and leaves the suffix empty produces addresses that render perfectly and match badly, which is the most expensive combination.
The reason is that abbreviation is common in German data and unpredictable in direction.
The same street appears as Bahnhofstraße, Bahnhofstr., Bahnhofstrasse and occasionally
Bahnhofstr, and only a parser that knows where the name ends and the type begins can
normalise all four onto one key. Without the split, they are four distinct strings and four
distinct records.
The split also enables the checks that catch a regression. On a German sample, the share of records with a recognised street type should be high and stable — well above ninety percent — and a drop is an immediate signal that the suffix list or the splitting logic has broken. That assertion is far more sensitive than a parse-rate check, because a broken splitter does not reduce the parse rate at all.
Finally, it makes the suffix list maintainable. Regional street types — Steig, Pfad,
Chaussee — can be added as they appear in a market, and the effect of each addition is
measurable in that same coverage figure rather than being a matter of judgement.
Integration Note
This parser is selected by country, which means the country must be determined before it
runs — from an explicit field where one exists, and from the postcode shape otherwise, as
described in
parsing European address conventions.
Its output feeds the same canonical-key derivation as every other market, and the
street_base and street_suffix split is what lets that key normalise Bahnhofstr. and
Bahnhofstraße onto one value without a special case in the key builder itself.
The same coverage figure is the fastest way to evaluate a candidate addition: add the type, re-run the sample, and keep the change only if the recognised-type share moves. That turns the suffix list from a matter of opinion into something with a test behind it.
Rendering Back Out
Parsing is only half the round trip, and the rendering side has one rule that is easy to get wrong: the street name and number go back together in the original order, with the number last, and the street type stays glued to the name.
That sounds obvious and is routinely broken by code that was written for a US schema and
reused. A renderer that emits number-then-street produces 12 Bahnhofstraße, which is
comprehensible to a human, unusual on a German label, and a mismatch against every stored
form of the same address.
Keeping the reconstructed line beside the components — the street_full field in the parser
above — removes the temptation to rebuild it at all. The label uses the stored line, the
matching uses the components, and neither has to trust the other’s formatting.
Related
- Parsing European Address Conventions — the regional differences this parser implements.
- Automating Address Component Extraction With spaCy — a hybrid approach for markets where rules run out.
- Removing Diacritics From Addresses in Python — the eszett folding this parser deliberately avoids.