Parse an address into fields
Split an address typed as a single line into number, street, complement, postcode and town.
No AI needed
FreeInstantYour data never leaves
The verdict
N0, because the complement — building, staircase, residence, flat, CEDEX, PO box — has a finite set of keywords, exactly like the street type: a dictionary and an anchor handle it, and the result is unit testable. N1 was recommended for that one reason, and it pays dearly for it: trained on eighteen addresses, it breaks on 15 rue Victor Hugo 92100 Boulogne-Billancourt and 10 bd Saint-Michel, which N0 reads correctly. Move up to N2 the day foreign addresses arrive: libpostal was trained on addresses from every inhabited country, and it is everyone’s alternative, not only the foreign traveller’s — its price is a large model to load and keep in memory. And whatever the level, splitting is not checking: 8 rue des Lilas, 75011 Lyon comes back as clean fields and names no place at all. The Géoplateforme geocoding service is what answers that, free of charge and against the national reference; it is the step after the split, and it takes the address out of your infrastructure.
Postcode anchor, street-type dictionary and complement dictionary
No AI needed
Rule and classic algorithmN0FreeInstant
Python
"""
Parse a postal address into fields: regular expressions anchored on the
postcode.
Rung N0. Deterministic, standard library only.
Two things make this work.
First, the postcode is the anchor. Five digits in a row cut a French address in
two: what comes before is the street line, what comes after is the town. Trying
to recognise the town by its name would mean shipping a list of communes and
keeping it up to date.
Second, the street type and the complement are read from dictionaries rather
than guessed, so the abbreviations people actually type — "av.", "bd", "bât",
"appt" — come out as one canonical spelling. The complement is the part the
postal standard puts on lines of its own, and a one-line form receives mixed
into the street; its keywords are a closed list, which is why a dictionary
settles it.
What this does not do is check that the address exists. Splitting without
checking gives clean fields that can still be wrong: "8 rue des Lilas,
75011 Lyon" splits perfectly and names no real place. The national geocoding
service is what answers that, and it is a separate step.
"""
import re
import unicodedata
# The street types of a French address, with the abbreviations people type.
# This is trade knowledge, not example data, so it belongs in the snippet.
STREET_TYPES = {
"r": "rue", "rue": "rue",
"av": "avenue", "ave": "avenue", "avenue": "avenue",
"bd": "boulevard", "bld": "boulevard", "boul": "boulevard", "boulevard": "boulevard",
"imp": "impasse", "impasse": "impasse",
"all": "allée", "allee": "allée",
"ch": "chemin", "chemin": "chemin",
"pl": "place", "place": "place",
"rte": "route", "route": "route",
"quai": "quai", "cours": "cours", "crs": "cours",
"sq": "square", "square": "square",
"voie": "voie", "passage": "passage", "sentier": "sentier",
"chaussee": "chaussée", "fbg": "faubourg", "faubourg": "faubourg",
"cite": "cité", "villa": "villa", "esplanade": "esplanade",
}
# The complement keywords of a French address, with the abbreviations people
# type. Trade knowledge again, and a closed list: the postal standard gives
# each of these a line of its own, and a one-line form gets them mixed into the
# street.
COMPLEMENTS = {
"bat": "bâtiment", "batt": "bâtiment", "batiment": "bâtiment", "immeuble": "immeuble",
"esc": "escalier", "escalier": "escalier",
"app": "appartement", "apt": "appartement", "appt": "appartement", "appartement": "appartement",
"etage": "étage", "porte": "porte", "hall": "hall", "entree": "entrée",
"res": "résidence", "residence": "résidence", "lotissement": "lotissement",
"lieu-dit": "lieu-dit", "lieudit": "lieu-dit",
"bp": "BP", "cs": "CS", "tsa": "TSA", "cedex": "CEDEX", "chez": "chez",
}
FIELDS = ("number", "street_type", "street", "complement", "postcode", "city")
# A house number or a range of them, and the repetition index that may follow:
# 8, 8-10, 8 bis, 12B. A lone letter counts only when it touches the number, so
# the "r" of "8 r des Lilas" stays a street type.
HOUSE_NUMBER = re.compile(r"^(\d{1,4}(?:-\d{1,4})?)(?:\s*(bis|ter|quater)\b|([a-z])\b)?", re.IGNORECASE)
# A French postcode: five digits standing alone.
POSTCODE = re.compile(r"\b\d{5}\b")
def normalise(text: str) -> str:
"""Reduce commas, line breaks, exotic spaces and byte order marks to a single plain space."""
text = unicodedata.normalize("NFKC", text).replace(",", " ")
return re.sub(r"[\s\ufeff]+", " ", text).strip()
def fold(word: str) -> str:
"""Lowercase, drop the accents and the trailing dot, for lookup only."""
decomposed = unicodedata.normalize("NFD", word.lower())
return "".join(c for c in decomposed if not unicodedata.combining(c)).rstrip(".")
def _is_designator(word: str) -> bool:
"""What follows a complement keyword: a number, a single letter, another keyword."""
return word.isdigit() or (len(word) == 1 and word.isalpha()) or fold(word) in COMPLEMENTS
def _cut_complement(words: list[str]) -> tuple[list[str], list[str]]:
"""
Take the complement out of the street line, and return the two parts.
Mid-line, a complement keyword opens a complement that runs to the end of
the line — "8 rue des Lilas Bât C Apt 12" — but only when it is followed by
what a complement is followed by: a number, a single letter, another
keyword. Without that condition "rue de la Porte Maillot" would lose half
its name to the word "Porte".
At the start of the line, the complement closes where the street opens: the
first street type, and the house number in front of it if there is one.
"Résidence du Parc 3 rue de la Paix" cuts before the 3. With no street type
at all, the whole line is the complement: "Lieu-dit Les Granges".
"""
opening = next(
(i for i, word in enumerate(words)
if fold(word) in COMPLEMENTS and (i == 0 or i == len(words) - 1 or _is_designator(words[i + 1]))),
None,
)
if opening is None:
return words, []
if opening > 0:
return words[:opening], words[opening:]
street = next((i for i, word in enumerate(words[1:], 1) if fold(word) in STREET_TYPES), None)
if street is None:
return [], words
closing = street - 1 if street > 1 and HOUSE_NUMBER.fullmatch(words[street - 1]) else street
return words[closing:], words[:closing]
def parse(address: str) -> dict:
"""
Split an address into number, street type, street, complement, postcode and
town.
Every field is a string, empty when the address does not carry it. Returning
an empty string rather than nothing at all keeps the caller from having to
test each field before printing it.
"""
fields = dict.fromkeys(FIELDS, "")
text = normalise(address)
# The anchor. Take the last run of five digits: in a French address the
# postcode and the town close the address, so a five-digit number earlier in
# the line is not taken for the postcode.
postcodes = list(POSTCODE.finditer(text))
if postcodes:
found = postcodes[-1]
fields["postcode"] = found.group()
fields["city"] = text[found.end():].strip()
text = text[: found.start()].strip()
words, complement = _cut_complement(text.split())
fields["complement"] = " ".join(complement)
text = " ".join(words)
number = HOUSE_NUMBER.match(text)
if number:
fields["number"] = " ".join(part for part in number.groups() if part)
text = text[number.end():].strip()
words = text.split()
if words:
canonical = STREET_TYPES.get(fold(words[0]))
if canonical:
# Rewrite the abbreviation, so two spellings of one street compare
# equal downstream.
fields["street_type"] = canonical
words[0] = canonical
fields["street"] = " ".join(words)
return fieldsJavaScript
/**
* Parse a postal address into fields: regular expressions anchored on the
* postcode.
*
* Rung N0. Deterministic, no dependency.
*
* Two things make this work.
*
* First, the postcode is the anchor. Five digits in a row cut a French address
* in two: what comes before is the street line, what comes after is the town.
* Recognising the town by its name would mean shipping a list of communes and
* keeping it up to date.
*
* Second, the street type and the complement are read from dictionaries rather
* than guessed, so the abbreviations people actually type — "av.", "bd", "bât",
* "appt" — come out as one canonical spelling. The complement is the part the
* postal standard puts on lines of its own, and a one-line form receives mixed
* into the street; its keywords are a closed list, which is why a dictionary
* settles it.
*
* What this does not do is check that the address exists. Splitting without
* checking gives clean fields that can still be wrong: "8 rue des Lilas,
* 75011 Lyon" splits perfectly and names no real place. The national geocoding
* service is what answers that, and it is a separate step.
*/
// The street types of a French address, with the abbreviations people type.
// This is trade knowledge, not example data, so it belongs in the snippet.
export const STREET_TYPES = {
r: 'rue', rue: 'rue',
av: 'avenue', ave: 'avenue', avenue: 'avenue',
bd: 'boulevard', bld: 'boulevard', boul: 'boulevard', boulevard: 'boulevard',
imp: 'impasse', impasse: 'impasse',
all: 'allée', allee: 'allée',
ch: 'chemin', chemin: 'chemin',
pl: 'place', place: 'place',
rte: 'route', route: 'route',
quai: 'quai', cours: 'cours', crs: 'cours',
sq: 'square', square: 'square',
voie: 'voie', passage: 'passage', sentier: 'sentier',
chaussee: 'chaussée', fbg: 'faubourg', faubourg: 'faubourg',
cite: 'cité', villa: 'villa', esplanade: 'esplanade',
};
// The complement keywords of a French address, with the abbreviations people
// type. Trade knowledge again, and a closed list: the postal standard gives
// each of these a line of its own, and a one-line form gets them mixed into the
// street.
export const COMPLEMENTS = {
bat: 'bâtiment', batt: 'bâtiment', batiment: 'bâtiment', immeuble: 'immeuble',
esc: 'escalier', escalier: 'escalier',
app: 'appartement', apt: 'appartement', appt: 'appartement', appartement: 'appartement',
etage: 'étage', porte: 'porte', hall: 'hall', entree: 'entrée',
res: 'résidence', residence: 'résidence', lotissement: 'lotissement',
'lieu-dit': 'lieu-dit', lieudit: 'lieu-dit',
bp: 'BP', cs: 'CS', tsa: 'TSA', cedex: 'CEDEX', chez: 'chez',
};
export const FIELDS = ['number', 'street_type', 'street', 'complement', 'postcode', 'city'];
// A house number or a range of them, and the repetition index that may follow:
// 8, 8-10, 8 bis, 12B. A lone letter counts only when it touches the number, so
// the "r" of "8 r des Lilas" stays a street type.
const HOUSE_NUMBER = /^(\d{1,4}(?:-\d{1,4})?)(?:\s*(bis|ter|quater)\b|([a-z])\b)?/i;
// A French postcode: five digits standing alone.
const POSTCODE = /\b\d{5}\b/g;
/** Reduce commas, line breaks, exotic spaces and byte order marks to a single plain space. */
export function normalise(text) {
return text.normalize('NFKC').replaceAll(',', ' ').replace(/\s+/g, ' ').trim();
}
/** Lowercase, drop the accents and the trailing dot, for lookup only. */
export function fold(word) {
return word.toLowerCase().normalize('NFD').replace(/\p{M}/gu, '').replace(/\.+$/, '');
}
/** What follows a complement keyword: a number, a single letter, another keyword. */
function isDesignator(word) {
return /^\d+$/.test(word) || /^\p{L}$/u.test(word) || fold(word) in COMPLEMENTS;
}
/**
* Take the complement out of the street line, and return the two parts.
*
* Mid-line, a complement keyword opens a complement that runs to the end of the
* line — "8 rue des Lilas Bât C Apt 12" — but only when it is followed by what a
* complement is followed by: a number, a single letter, another keyword.
* Without that condition "rue de la Porte Maillot" would lose half its name to
* the word "Porte".
*
* At the start of the line, the complement closes where the street opens: the
* first street type, and the house number in front of it if there is one.
* "Résidence du Parc 3 rue de la Paix" cuts before the 3. With no street type at
* all, the whole line is the complement: "Lieu-dit Les Granges".
*/
function cutComplement(words) {
const opening = words.findIndex((word, i) => fold(word) in COMPLEMENTS
&& (i === 0 || i === words.length - 1 || isDesignator(words[i + 1])));
if (opening === -1) return [words, []];
if (opening > 0) return [words.slice(0, opening), words.slice(opening)];
const street = words.findIndex((word, i) => i > 0 && fold(word) in STREET_TYPES);
if (street === -1) return [[], words];
const before = words[street - 1];
const closing = street > 1 && /^\d{1,4}(?:-\d{1,4})?$/.test(before) ? street - 1 : street;
return [words.slice(closing), words.slice(0, closing)];
}
/**
* Split an address into number, street type, street, complement, postcode and
* town.
*
* Every field is a string, empty when the address does not carry it. Returning
* an empty string rather than nothing at all keeps the caller from having to
* test each field before printing it.
*/
export function parse(address) {
const fields = Object.fromEntries(FIELDS.map((name) => [name, '']));
let text = normalise(address);
// The anchor. Take the last run of five digits: in a French address the
// postcode and the town close the address, so a five-digit number earlier in
// the line is not taken for the postcode.
const postcodes = [...text.matchAll(POSTCODE)];
if (postcodes.length > 0) {
const found = postcodes.at(-1);
fields.postcode = found[0];
fields.city = text.slice(found.index + found[0].length).trim();
text = text.slice(0, found.index).trim();
}
const [line, complement] = cutComplement(text.split(' ').filter(Boolean));
fields.complement = complement.join(' ');
text = line.join(' ');
const number = HOUSE_NUMBER.exec(text);
if (number) {
fields.number = number.slice(1).filter(Boolean).join(' ');
text = text.slice(number[0].length).trim();
}
const words = text.split(' ').filter(Boolean);
if (words.length > 0) {
const canonical = STREET_TYPES[fold(words[0])];
if (canonical) {
// Rewrite the abbreviation, so two spellings of one street compare equal
// downstream.
fields.street_type = canonical;
words[0] = canonical;
}
fields.street = words.join(' ');
}
return fields;
}Try it
interactiveThe code above runs in your browser. Type in the box: the result is redone in front of you.
The split happens in your browser, with no model and no call: the postcode cuts the line, a dictionary of street types and a dictionary of complements do the rest. No address leaves the page.
Or start from an example
Free typing and the other examples need JavaScript. The example shown below was computed when the site was built.
4 matches
What the code picked up
15 rue Victor Hugo 92100 Boulogne-Billancourt
What it returns
| Field | What the snippet puts there |
|---|---|
| Number | 15 |
| Street | rue Victor Hugo |
| Complement | — nothing |
| Postcode | 92100 |
| Town | Boulogne-Billancourt |
Splitting only: nothing here checks that the address exists.
Here it breaksSee the level above
What this means for you
- Your data stays put
- Always the same result
- Unit-testable, case by case
- No provider in the loop
- Negligible footprint
What this adds to your regulatory scope
- No specific scope added: the address never leaves your infrastructure
Breaking point
Outside France the anchor fails. Hauptstrasse 5, 10115 Berlin leaves the number inside the street, because a German number is written after it; 42 Rowan Street, Bristol BS1 4TQ, with no run of five digits, comes back with neither postcode nor town. And even in France, splitting checks nothing: 8 rue des Lilas, 75011 Lyon comes back as five clean fields, with a Paris postcode on a town that is not its own.
When to move up a level
Foreign addresses start arriving, or the word that opens a complement is not in the dictionary, and you are adding one exception a week.
The four options
- RecommendedN0Postcode anchor, street-type dictionary and complement dictionaryFree · Instant
- N1Token labelling with a logistic regression on contextnégligeable · Instant
- N2Self-hosted statistical address parser (libpostal), everyone’s alternativeInexpensive · Instant
- N3Structured splitting through a general-purpose modelélevé · About a second
The other options
N1Token labelling with a logistic regression on contextnégligeable · Instant
Token labelling with a logistic regression on context
Lightweight classic modelN1négligeableInstant
Python
"""
Label every token of an address with a logistic regression on context traits.
Rung N1. N0 reads the address it expects; this one reads the address it is
given. Each word is labelled on its own — number, street type, street name,
complement, postcode, town — from what it looks like and from what sits on
either side of it. Fields are then rebuilt from the labels.
That is the whole gain: a complement in the middle of the line no longer
swallows the street, because "Bâtiment" is a word the model has seen in that
position, not an unexpected token in a fixed pattern.
The price is a labelled training set. The test and the tryout train it on
eighteen addresses tagged by hand, and it already reads a street, a residence
and a town it never saw; every convention absent from that set is a convention
the model does not know.
"""
import re
import unicodedata
from sklearn.feature_extraction import DictVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
# The labels are the fields, so the mapping back is a grouping and nothing more.
LABELS = ("number", "street_type", "street", "complement", "postcode", "city")
TOKEN = re.compile(r"[^\W_]+")
def tokenise(address: str) -> list[str]:
"""Words and numbers, punctuation dropped."""
return TOKEN.findall(unicodedata.normalize("NFKC", address))
def fold(word: str) -> str:
"""Lowercase and accent-free, so "Allée" and "allee" share a trait."""
decomposed = unicodedata.normalize("NFD", word.lower())
return "".join(c for c in decomposed if not unicodedata.combining(c))
def features(tokens: list[str], i: int) -> dict:
"""
What the token looks like, and what surrounds it.
The neighbours carry much of the signal: five digits followed by a
capitalised word look like a postcode and a town. Position counts as well:
on "75011 Paris, 8 rue des Lilas" the end of the line wins, and the town
comes out as "Lilas".
"""
token = tokens[i]
return {
f"token={fold(token)}": 1,
f"previous={fold(tokens[i - 1]) if i else '<start>'}": 1,
f"next={fold(tokens[i + 1]) if i + 1 < len(tokens) else '<end>'}": 1,
"digits": float(token.isdigit()),
"five_digits": float(token.isdigit() and len(token) == 5),
"short_number": float(token.isdigit() and len(token) <= 3),
"capitalised": float(token[:1].isupper()),
"position": i / len(tokens),
"last": float(i == len(tokens) - 1),
}
def train(examples):
"""
`examples` pairs an address with one label per token, in reading order.
Tagging is the work of this rung, and mistagging it is the usual way the
rung is made to fail, so a misaligned example is rejected rather than
quietly learnt.
"""
rows, targets = [], []
for address, labels in examples:
tokens = tokenise(address)
if len(tokens) != len(labels):
raise ValueError(f"{len(tokens)} tokens for {len(labels)} labels: {address!r}")
rows += [features(tokens, i) for i in range(len(tokens))]
targets += list(labels)
model = make_pipeline(
DictVectorizer(),
LogisticRegression(max_iter=1000, class_weight="balanced"),
)
model.fit(rows, targets)
return model
def parse(model, address: str) -> dict:
"""Group the labelled tokens back into fields, in reading order."""
fields = dict.fromkeys(LABELS, "")
tokens = tokenise(address)
if not tokens:
return fields
predicted = model.predict([features(tokens, i) for i in range(len(tokens))])
for token, label in zip(tokens, predicted):
fields[label] = f"{fields[label]} {token}".strip()
# The street type stays available on its own, and also opens the street, so
# the output can be compared field by field with the rung below.
fields["street"] = f"{fields['street_type']} {fields['street']}".strip()
return fieldsJavaScript
/**
* Label every token of an address with a logistic regression on context traits.
*
* Rung N1. N0 reads the address it expects; this one reads the address it is
* given. Each word is labelled on its own — number, street type, street name,
* complement, postcode, town — from what it looks like and from what sits on
* either side of it. Fields are then rebuilt from the labels.
*
* That is the whole gain: a complement in the middle of the line no longer
* swallows the street, because "Bâtiment" is a word the model has seen in
* that position, not an unexpected token in a fixed pattern.
*
* One binary regression per label, trained by plain gradient descent, and the
* strongest one wins. Written out rather than pulled from a library: no
* dependency to install for a model this size.
*/
// The labels are the fields, so the mapping back is a grouping and nothing more.
export const LABELS = ['number', 'street_type', 'street', 'complement', 'postcode', 'city'];
const TOKEN = /[\p{L}\p{N}]+/gu;
/** Words and numbers, punctuation dropped. */
export function tokenise(address) {
return address.normalize('NFKC').match(TOKEN) ?? [];
}
/** Lowercase and accent-free, so "Allée" and "allee" share a trait. */
export function fold(word) {
return word.toLowerCase().normalize('NFD').replace(/\p{M}/gu, '');
}
const isDigits = (token) => /^\d+$/.test(token);
/**
* What the token looks like, and what surrounds it.
*
* The neighbours carry much of the signal: five digits followed by a
* capitalised word look like a postcode and a town. Position counts as well:
* on "75011 Paris, 8 rue des Lilas" the end of the line wins, and the town
* comes out as "Lilas".
*/
export function features(tokens, i) {
const token = tokens[i];
return {
[`token=${fold(token)}`]: 1,
[`previous=${i > 0 ? fold(tokens[i - 1]) : '<start>'}`]: 1,
[`next=${i + 1 < tokens.length ? fold(tokens[i + 1]) : '<end>'}`]: 1,
digits: Number(isDigits(token)),
five_digits: Number(isDigits(token) && token.length === 5),
short_number: Number(isDigits(token) && token.length <= 3),
capitalised: Number(token[0] !== token[0].toLowerCase()),
position: i / tokens.length,
last: Number(i === tokens.length - 1),
};
}
/**
* `examples` pairs an address with one label per token, in reading order.
*
* Tagging is the work of this rung, and mistagging it is the usual way the rung
* is made to fail, so a misaligned example is rejected rather than quietly
* learnt.
*/
export function train(examples, { epochs = 300, rate = 0.3 } = {}) {
const rows = [];
const targets = [];
for (const [address, labels] of examples) {
const tokens = tokenise(address);
if (tokens.length !== labels.length) {
throw new Error(`${tokens.length} tokens for ${labels.length} labels: ${address}`);
}
tokens.forEach((_, i) => {
rows.push(features(tokens, i));
targets.push(labels[i]);
});
}
// Two labels at least, as scikit-learn demands: with one, every token would
// get it, and with none, parsing would have no label to pick.
if (new Set(targets).size < 2) throw new RangeError('train needs at least two different labels');
// One column per trait seen in training. A trait absent from this map is a
// word the model never met, and it simply contributes nothing at prediction.
const columns = new Map();
const vectors = rows.map((row) =>
Object.entries(row).map(([name, value]) => {
if (!columns.has(name)) columns.set(name, columns.size);
return [columns.get(name), value];
}));
const weights = {};
for (const label of new Set(targets)) {
const w = new Float64Array(columns.size + 1); // the last cell is the bias
for (let epoch = 0; epoch < epochs; epoch += 1) {
for (let i = 0; i < vectors.length; i += 1) {
const target = targets[i] === label ? 1 : 0;
const error = 1 / (1 + Math.exp(-score(w, vectors[i]))) - target;
for (const [column, value] of vectors[i]) w[column] -= rate * error * value;
w[columns.size] -= rate * error;
}
}
weights[label] = w;
}
return { columns, weights };
}
/** Group the labelled tokens back into fields, in reading order. */
export function parse(model, address) {
const fields = Object.fromEntries(LABELS.map((name) => [name, '']));
const tokens = tokenise(address);
tokens.forEach((token, i) => {
const vector = vectorise(features(tokens, i), model.columns);
const label = Object.keys(model.weights).reduce((best, candidate) =>
score(model.weights[candidate], vector) > score(model.weights[best], vector) ? candidate : best);
fields[label] = `${fields[label]} ${token}`.trim();
});
// The street type stays available on its own, and also opens the street, so
// the output can be compared field by field with the rung below.
fields.street = `${fields.street_type} ${fields.street}`.trim();
return fields;
}
function vectorise(row, columns) {
return Object.entries(row)
.filter(([name]) => columns.has(name))
.map(([name, value]) => [columns.get(name), value]);
}
function score(weights, vector) {
let total = weights[weights.length - 1];
for (const [column, value] of vector) total += weights[column] * value;
return total;
}What this means for you
- Your data stays put
- Always the same result
- Measured on a sample, not case by case
- Depends on a library
- Low footprint
What this adds to your regulatory scope
- Processing of personal data on your own infrastructure
- The training set is hand-tagged: built from real addresses, it belongs in your record of processing activities, where you keep one
Breaking point
The model regresses on the most ordinary French addresses, which N0 reads correctly. Trained on the eighteen addresses in the test: 15 rue Victor Hugo 92100 Boulogne-Billancourt comes back with the street “rue”, the complement “Victor Hugo Boulogne” and the town “Billancourt”; 10 bd Saint-Michel comes back with the street “bd”; Lieu-dit Les Granges 24200 Sarlat-la-Canéda comes back with the town “Canéda”. A street named after a person, a hyphenated town, an abbreviation: that is not an edge. And it never abstains: on Hauptstrasse 5, 10115 Berlin it labels the tokens anyway.
When to move up a level
You read the output and see street names cut where the model saw nothing like them in its tagged set.
N2Self-hosted statistical address parser (libpostal), everyone’s alternativeInexpensive · Instant
Self-hosted statistical address parser (libpostal), everyone’s alternative
Small self-hosted specialised modelN2InexpensiveInstant
Python
"""
Parse an address with a self-hosted statistical parser.
Rung N2. libpostal is a parser trained on over a billion addresses from every
inhabited country. It labels a token from its context, like N1, but where N1
knows the eighteen French addresses it was tagged on, libpostal's training
covers the conventions of other countries too. Whether it reads a given one
right, nothing here measures. It runs on your machine, so no address leaves it.
What you own on this rung is not the model. It is the batch, the size cap, the
mapping from its label set to yours, and the answer to "what does the code do
when the parser returns something we cannot use". The model is a black box
with a fixed list of labels and no confidence score, and the last function
below is where that becomes your problem.
"""
from __future__ import annotations
# A postal address is short: La Poste's rules allow six lines of 38 characters,
# which is 228 characters plus the separators. The cap is set above that, at
# 300, so that a line written with generous punctuation still goes through;
# anything past it is not an address, and is refused before parsing.
MAX_CHARACTERS = 300
FIELDS = ("number", "street", "complement", "postcode", "city")
# libpostal's label set is its own, and wider than ours. Five of its labels
# land in our complement, "house" among them: libpostal's name for a building
# or a venue, where a French residence name goes. Everything unmapped is dropped
# on purpose: an unmapped label that silently became a field would be a
# surprise in a letter.
COMPONENTS = {
"house_number": "number",
"road": "street",
"house": "complement",
"unit": "complement",
"level": "complement",
"staircase": "complement",
"entrance": "complement",
"postcode": "postcode",
"city": "city",
}
class ParsingUnavailable(Exception):
"""The parser failed, or answered something no caller can act on."""
class LibpostalParser:
"""The real parser: a C library and its data files, loaded once per process on import."""
def __init__(self) -> None:
from postal.parser import parse_address # a large local install
self._parse_address = parse_address
def predict(self, addresses: list[str]) -> list[dict]:
"""One label-to-value mapping per address, in the order given."""
return [self._merge(self._parse_address(a)) for a in addresses]
@staticmethod
def _merge(components) -> dict:
"""libpostal yields (value, label) pairs, and repeats a label freely."""
merged: dict[str, str] = {}
for value, label in components:
merged[label] = f"{merged.get(label, '')} {value}".strip()
return merged
def parse_addresses(addresses, parser=None, *, attempts: int = 2) -> list[dict]:
"""
Parse a batch of addresses into fields.
`parser` is injected so this can be tested without installing the model. In
production it defaults to the real one above.
The whole batch goes to the parser object in one call, but libpostal has no
batching: `LibpostalParser.predict` parses the addresses one after the other.
"""
batch = list(addresses)
for address in batch:
if len(address) > MAX_CHARACTERS:
raise ValueError(f"address longer than {MAX_CHARACTERS} characters")
if not batch:
return []
parser = parser or LibpostalParser()
rows = _predict(parser, batch, attempts)
if len(rows) != len(batch):
raise ParsingUnavailable("the parser returned one row per address, and did not")
return [_to_fields(row) for row in rows]
def _predict(parser, batch: list[str], attempts: int) -> list[dict]:
"""
Retry a failed parse once. Loading is not retried: a parser that cannot
load its data files fails before this point, with its own error.
"""
last_error: Exception | None = None
for _ in range(attempts):
try:
return parser.predict(batch)
except Exception as error: # noqa: BLE001 - any model failure is retried
last_error = error
raise ParsingUnavailable(str(last_error))
def _to_fields(row) -> dict:
"""Keep the labels we mapped, join those that share a field, drop the rest."""
if row is None:
row = {}
if not isinstance(row, dict):
raise ParsingUnavailable(f"the parser returned a {type(row).__name__}, not a mapping")
fields = dict.fromkeys(FIELDS, "")
for label, value in row.items():
field = COMPONENTS.get(label)
if field and isinstance(value, str) and value.strip():
fields[field] = f"{fields[field]} {value.strip()}".strip()
return fieldsJavaScript
/**
* Parse an address with a self-hosted statistical parser.
*
* Rung N2. libpostal is a parser trained on over a billion addresses from every
* inhabited country. It labels a token from its context, like N1, but where N1
* knows the eighteen French addresses it was tagged on, libpostal's training
* covers the conventions of other countries too. Whether it reads a given one
* right, nothing here measures. It runs on your machine, so no address leaves
* it.
*
* What you own on this rung is not the model. It is the batch, the size cap,
* the mapping from its label set to yours, and the answer to "what does the
* code do when the parser returns something we cannot use". The model is a
* black box with a fixed list of labels and no confidence score, and the last
* function below is where that becomes your problem.
*/
// A postal address is short: La Poste's rules allow six lines of 38 characters,
// which is 228 characters plus the separators. The cap is set above that, at
// 300, so that a line written with generous punctuation still goes through;
// anything past it is not an address, and is refused before parsing.
export const MAX_CHARACTERS = 300;
export const FIELDS = ['number', 'street', 'complement', 'postcode', 'city'];
// libpostal's label set is its own, and wider than ours. Five of its labels
// land in our complement, "house" among them: libpostal's name for a building
// or a venue, where a French residence name goes. Everything unmapped is dropped
// on purpose: an unmapped label that silently became a field would be a
// surprise in a letter.
const COMPONENTS = {
house_number: 'number',
road: 'street',
house: 'complement',
unit: 'complement',
level: 'complement',
staircase: 'complement',
entrance: 'complement',
postcode: 'postcode',
city: 'city',
};
export class ParsingUnavailable extends Error {}
/** The real parser: a native binding and its data files, loaded once per process on import. */
export class LibpostalParser {
static async load() {
// node-postal is CommonJS: imported from a module, its exports sit under
// `default`, and `parser` is not detected as a named export.
const { parser } = (await import('node-postal')).default;
return new LibpostalParser((address) => parser.parse_address(address));
}
constructor(parseAddress) {
this.parseAddress = parseAddress;
}
/** One label-to-value mapping per address, in the order given. */
async predict(addresses) {
return addresses.map((address) => merge(this.parseAddress(address)));
}
}
/** libpostal yields {component, value} pairs, and repeats a component freely. */
function merge(components) {
const merged = {};
for (const { component, value } of components) {
merged[component] = `${merged[component] ?? ''} ${value}`.trim();
}
return merged;
}
/**
* Parse a batch of addresses into fields.
*
* `parser` is injected so this can be tested without installing the model. In
* production it defaults to the real one above.
*
* The whole batch goes to the parser object in one call, but libpostal has no
* batching: `LibpostalParser.predict` parses the addresses one after the other.
*/
export async function parseAddresses(addresses, parser, { attempts = 2 } = {}) {
const batch = [...addresses];
for (const address of batch) {
// Code points, as Python counts them: an emoji is one character, not two.
if ([...address].length > MAX_CHARACTERS) {
throw new RangeError(`address longer than ${MAX_CHARACTERS} characters`);
}
}
if (batch.length === 0) return [];
const model = parser ?? (await LibpostalParser.load());
const rows = await predict(model, batch, attempts);
if (rows.length !== batch.length) {
throw new ParsingUnavailable('the parser returned one row per address, and did not');
}
return rows.map(toFields);
}
/**
* Retry a failed parse once. Loading is not retried: a parser that cannot load
* its data files fails before this point, with its own error.
*/
async function predict(parser, batch, attempts) {
let lastError;
for (let i = 0; i < attempts; i += 1) {
try {
return await parser.predict(batch);
} catch (error) {
lastError = error;
}
}
throw new ParsingUnavailable(String(lastError));
}
/** Keep the labels we mapped, join those that share a field, drop the rest. */
function toFields(row) {
if (row !== null && row !== undefined && (typeof row !== 'object' || Array.isArray(row))) {
throw new ParsingUnavailable('the parser returned something other than a mapping');
}
const fields = Object.fromEntries(FIELDS.map((name) => [name, '']));
for (const [label, value] of Object.entries(row ?? {})) {
const field = COMPONENTS[label];
if (field && typeof value === 'string' && value.trim()) {
fields[field] = `${fields[field]} ${value.trim()}`.trim();
}
}
return fields;
}What this means for you
- Your data stays on your own machines
- Always the same result
- Measured on a sample, not case by case
- Depends on a library
- Moderate footprint
What this adds to your regulatory scope
- Processing of personal data on your own infrastructure
- No transfer to a third party: the model and its data files are installed on your own machine
Breaking point
It splits, it does not check, and it costs a large model to load at start-up and keep in memory: the latency class shown here is per call, once the model is loaded, not on the first one. libpostal returns labels, neither a score nor a refusal. If a line that is not an address at all — the meeting is at ten in room four — comes back labelled as a house number and a street, or 8 rue des Lilas, 75011 Lyon, which puts a Paris postcode on another town, comes back as clean fields — two answers the test simulates —, nothing in the code tells that result from a good one.
When to move up a level
What you receive is no longer an address line but free text — an email signature, a message where the address sits inside a sentence — and there is no line left to split.
N3Structured splitting through a general-purpose modelélevé · About a second
Structured splitting through a general-purpose model
General-purpose LLM APIN3élevéAbout a second
Python
"""
Split an address by asking a general-purpose model.
Rung N3. It is here so you can see what it costs, not because this entry
recommends it.
Note what the code has to do that N0 did not: cap the input size, retry on
failure, parse an answer that is only probably valid JSON, and check that the
fields it hands back were actually in the address. That last point is specific
to extraction: a value the address does not contain is dropped, because a
plausible postcode is worse than an empty field — nothing downstream will ever
question it.
That plumbing is the real cost of this rung, and it is the part your tests have
to cover, because the model itself is not testable.
"""
from __future__ import annotations
import json
import re
import unicodedata
# The provider named here is an example, not a recommendation: the reasoning
# holds for any general-purpose model API, and the client is swappable. Pass
# any object with a `complete(prompt=..., temperature=...)` method.
MODEL = "gpt-4.1-mini" # an example id: check the parameters your model accepts
FIELDS = ("number", "street", "complement", "postcode", "city")
PROMPT = (
"Split the postal address below into fields.\n"
"Answer with JSON only: an object with the keys `number`, `street`,\n"
"`complement`, `postcode` and `city`. Copy the text exactly as it is\n"
"written, and leave a key empty when the address does not carry it.\n\n"
"Address:\n{address}"
)
# An address is a short line. The cap is a cost control: the provider bills the
# tokens of the prompt as well as those of the answer.
MAX_CHARACTERS = 300
class ProviderClient:
"""The one call this snippet makes, on top of the provider's SDK."""
def __init__(self, sdk=None, model: str = MODEL):
if sdk is None: # pragma: no cover - needs a key and a network
from openai import OpenAI
sdk = OpenAI()
self.sdk, self.model = sdk, model
def complete(self, *, prompt: str, temperature: float) -> str:
response = self.sdk.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": prompt}],
temperature=temperature,
)
return response.choices[0].message.content
class ParsingUnavailable(Exception):
"""The provider could not be reached, or answered something unusable."""
def parse(address: str, client=None, *, attempts: int = 3) -> dict:
"""
Split an address into number, street, complement, postcode and town.
`client` is injected so this function can be tested without a network call.
In production it defaults to a real provider client.
"""
if len(address) > MAX_CHARACTERS:
raise ValueError(f"address longer than {MAX_CHARACTERS} characters")
client = client or ProviderClient()
answer = _ask(client, address, attempts)
source = _words(address)
taken = [False] * len(source)
values = {key: _text(answer.get(key)) for key in FIELDS}
fields = dict.fromkeys(FIELDS, "")
# Kept only if the model copied it from the address, as whole words not
# already claimed by another field. Longest first, so a postcode "12" cannot
# take its digits out of "Appartement 12". What it made up is dropped, and an empty
# field is a question a human can see.
for key in sorted(FIELDS, key=lambda k: -len(_words(values[k]))):
words = _words(values[key])
for start in range(len(source) - len(words) + 1) if words else ():
end = start + len(words)
if source[start:end] == words and not any(taken[start:end]):
taken[start:end] = [True] * len(words)
fields[key] = values[key].strip()
break
return fields
def _ask(client, address: str, attempts: int) -> dict:
last_error: Exception | None = None
for _ in range(attempts):
try:
# Temperature zero, because an address that splits differently
# between two identical calls cannot be reconciled with anything.
answer = client.complete(prompt=PROMPT.format(address=address), temperature=0)
# No content (a refusal) is as unusable as prose.
parsed = json.loads(answer) if isinstance(answer, str) else None
if isinstance(parsed, dict):
return parsed
last_error = ValueError("the model answered something that is not an object")
except Exception as error: # noqa: BLE001 - any provider failure is retried
last_error = error
raise ParsingUnavailable(str(last_error))
def _text(value) -> str:
"""A string as it came, a whole number as its digits (75011), anything else as nothing."""
if isinstance(value, (int, float)) and not isinstance(value, bool) and float(value).is_integer():
return str(int(value))
return value if isinstance(value, str) else ""
def _words(text: str) -> list[str]:
"""Case, spacing and punctuation are the model's to change; the words are not."""
return re.findall(r"[^\W_]+", unicodedata.normalize("NFKC", text).lower())JavaScript
/**
* Split an address by asking a general-purpose model.
*
* Rung N3. It is here so you can see what it costs, not because this entry
* recommends it.
*
* Note what the code has to do that N0 did not: cap the input size, retry on
* failure, parse an answer that is only probably valid JSON, and check that the
* fields it hands back were actually in the address. That last point is
* specific to extraction: a value the address does not contain is dropped,
* because a plausible postcode is worse than an empty field — nothing
* downstream will ever question it.
*
* That plumbing is the real cost of this rung, and it is the part your tests
* have to cover, because the model itself is not testable.
*/
// The provider named here is an example, not a recommendation: the reasoning
// holds for any general-purpose model API, and the client is swappable. Pass
// any object with a `complete({ prompt, temperature })` method.
export const MODEL = 'gpt-4.1-mini'; // an example id: check the parameters your model accepts
export async function providerClient(sdk, model = MODEL) {
if (!sdk) {
const { OpenAI } = await import('openai');
sdk = new OpenAI();
}
return {
async complete({ prompt, temperature }) {
const response = await sdk.chat.completions.create({
model,
messages: [{ role: 'user', content: prompt }],
temperature,
});
return response.choices[0].message.content;
},
};
}
export const FIELDS = ['number', 'street', 'complement', 'postcode', 'city'];
const PROMPT = [
'Split the postal address below into fields.',
'Answer with JSON only: an object with the keys `number`, `street`,',
'`complement`, `postcode` and `city`. Copy the text exactly as it is',
'written, and leave a key empty when the address does not carry it.',
'',
'Address:',
].join('\n');
// An address is a short line. The cap is a cost control: the provider bills the
// tokens of the prompt as well as those of the answer.
export const MAX_CHARACTERS = 300;
export class ParsingUnavailable extends Error {}
/**
* Split an address into number, street, complement, postcode and town.
*
* @param {string} address
* @param {object} options
* @param {{complete: Function}} [options.client] injected so this can be tested
* without a network call; defaults to a real provider client
* @param {number} [options.attempts]
*/
export async function parse(address, { client, attempts = 3 } = {}) {
// Code points, as Python counts them: an emoji is one character, not two.
if ([...address].length > MAX_CHARACTERS) {
throw new RangeError(`address longer than ${MAX_CHARACTERS} characters`);
}
client ??= await providerClient();
const answer = await ask(client, address, attempts);
const source = words(address);
const taken = source.map(() => false);
const values = Object.fromEntries(FIELDS.map((key) => [key, text(answer[key])]));
const fields = Object.fromEntries(FIELDS.map((name) => [name, '']));
// Kept only if the model copied it from the address, as whole words not
// already claimed by another field. Longest first, so a postcode "12" cannot
// take its digits out of "Appartement 12". What it made up is dropped, and an empty
// field is a question a human can see.
for (const key of [...FIELDS].sort((a, b) => words(values[b]).length - words(values[a]).length)) {
const wanted = words(values[key]);
for (let start = 0; wanted.length && start + wanted.length <= source.length; start += 1) {
const end = start + wanted.length;
if (wanted.every((w, i) => source[start + i] === w) && !taken.slice(start, end).some(Boolean)) {
taken.fill(true, start, end);
fields[key] = values[key].trim();
break;
}
}
}
return fields;
}
async function ask(client, address, attempts) {
let lastError;
for (let i = 0; i < attempts; i += 1) {
try {
const answer = await client.complete({
prompt: `${PROMPT}\n${address}`,
// Temperature zero, because an address that splits differently between
// two identical calls cannot be reconciled with anything.
temperature: 0,
});
// No content (a refusal) is as unusable as prose.
const parsed = typeof answer === 'string' ? JSON.parse(answer) : null;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
lastError = new Error('the model answered something that is not an object');
} catch (error) {
lastError = error;
}
}
throw new ParsingUnavailable(String(lastError));
}
/** A string as it came, a whole number as its digits (75011), anything else as nothing. */
function text(value) {
if (Number.isInteger(value)) return String(value);
return typeof value === 'string' ? value : '';
}
/** Case, spacing and punctuation are the model's to change; the words are not. */
function words(value) {
return value.normalize('NFKC').toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
}What this means for you
- Your data goes to a third party
- The result varies from one call to the next
- Hard to test
- Depends on an outside provider
- High footprint
What this adds to your regulatory scope
- Transfer of personal data to a processor, with the contractual framing that implies
- Processing location to be confirmed with the provider
- Does not excuse you from your own duties of transparency and minimisation
Breaking point
The snippet’s guard proves where a value came from, not that it belongs in that field. If, on 12 rue de Lille, 59000 Lille, the model swaps the street and the town — the test simulates that answer —, both values were indeed copied from the address, so both pass the check, and the answer comes back neatly structured and wrong.
When to move up a level
There is no level above this one.
Further reading
- libpostal — analyseur d’adresses international, entraîné sur données ouvertes
- Géoplateforme — service de géocodage des adresses françaises, qui remplace l’API de la Base Adresse Nationale
- Michael Tandy — Falsehoods programmers believe about addresses
- OpenStreetMap — clé addr, le vocabulaire des composants d’adresse
Where the figures come from
- Géoplateforme, guide du géocodage (consulté) : point d’accès https://data.geopf.fr/geocodage/search, service public gratuit, usage limité à cinquante requêtes par seconde depuis une même adresse IP ; l’API Adresse de la Base Adresse Nationale y est intégrée
- La Poste, SP 8855 volume 2 — adressage des plis : l’adresse d’un particulier en six lignes, code postal et localité en dernière ligne, 38 caractères par ligne
- libpostal — README : entraîné sur plus d’un milliard d’adresses de tous les pays habités, étiquettes de l’analyseur
- Yassine et al., Deepparse (2023) — temps moyen d’analyse par adresse de libpostal, et absence de traitement par lots
- OpenAI — tarifs de l’API, prix par million de jetons en entrée et en sortie
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.