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

snippets/parse-address-into-fields/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
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 fields

JavaScript

snippets/parse-address-into-fields/n0.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * 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

interactive

The 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.

yours to edit

Recomputed on every keystroke.

Or start from an example

Free typing and the other examples need JavaScript. The example shown below was computed when the site was built.

What the code makes of it

4 matches

What the code picked up

15 rue Victor Hugo 92100 Boulogne-Billancourt

What it returns

FieldWhat the snippet puts there
Number15
Streetrue Victor Hugo
Complement— nothing
Postcode92100
TownBoulogne-Billancourt

Splitting only: nothing here checks that the address exists.

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

  1. RecommendedN0Postcode anchor, street-type dictionary and complement dictionaryFree · Instant
  2. N1Token labelling with a logistic regression on contextnégligeable · Instant
  3. N2Self-hosted statistical address parser (libpostal), everyone’s alternativeInexpensive · Instant
  4. 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

snippets/parse-address-into-fields/n1.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
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 fields

JavaScript

snippets/parse-address-into-fields/n1.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * 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

snippets/parse-address-into-fields/n2.pyTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
"""
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 fields

JavaScript

snippets/parse-address-into-fields/n2.jsTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
/**
 * 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

snippets/parse-address-into-fields/n3.pyTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
"""
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

snippets/parse-address-into-fields/n3.jsTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
/**
 * 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

Where the figures come from

Metadata