Normalizing International Addresses with libpostal

TL;DR: Call parse_address(raw_string) to decompose an address into labelled components and expand_address(raw_string) to generate canonical, abbreviation-resolved variants — both from the pypostal bindings to the libpostal C library. This page is part of the International Address Format Standardization workflow.


How libpostal Splits Parsing from Expansion

libpostal separates normalization into two operations that serve distinct pipeline roles:

  • parse_address — tokenizes a raw string and assigns semantic labels (house_number, road, city, postcode, country, state, suburb, and more). Returns a list of (value, label) tuples.
  • expand_address — resolves abbreviations, normalizes casing, and applies regional formatting rules (StStreet, AptApartment, PlPlaza). Returns a list of normalized strings suitable for deduplication or geocoding.

The model is trained on OpenStreetMap and OpenAddresses data, covering addressing conventions from German compound street names to Japanese prefecture ordering to Brazilian neighborhood hierarchies. Because the engine is statistical rather than rule-based, it handles typos, missing fields, and mixed-language inputs gracefully — situations where rigid regex patterns for US address parsing would fail silently.

libpostal parse_address and expand_address data flow A raw address string enters libpostal. The parse_address branch outputs labelled components (house_number, road, city, postcode). The expand_address branch outputs canonical string variants for deduplication or geocoding. Raw address string "123 Main St, Apt 4B" parse_address house_number: "123" road: "main street" unit: "4b" expand_address "123 main street apt 4b" "123 main street apartment 4b" Structured record → geocoding payload Canonical variants → deduplication key

Parse and Expand Solve Different Problems

The two entry points look interchangeable and are not. parse_address labels the parts of one string and returns structure. expand_address returns a list of alternative surface forms of the same string, so two records that differ only in abbreviation collapse onto a shared key. Using expansion where you needed parsing gives you strings when you wanted fields; using parsing where you needed expansion leaves you comparing ST to STREET.

parse_address versus expand_address on the same input One input string feeds two branches. The upper branch, parse_address, returns labelled components such as house number, road and city, and is used to populate a structured schema. The lower branch, expand_address, returns several normalised string variants, and is used to build a deduplication key. 781 Franklin Ave Brooklyn NY parse_address() CRF sequence labelling one label per token expand_address() dictionary-driven rewriting one string per variant house_number: 781 road: franklin ave city: brooklyn 781 franklin avenue… 781 franklin av… → pick one as the dedup key

In practice a pipeline calls both, in that order: parse to populate the schema, then expand the reconstructed street line to derive the canonical key used by fuzzy deduplication. Calling expand on the raw string instead of the reconstructed one is a subtle mistake, because any noise the parser discarded — a stray recipient name, a floor note — stays in the key and splits records that should have merged.

Installation Spec

libpostal is a C library. pypostal provides Python bindings but does not bundle compiled binaries or training data, so you must build the core library first.

System requirements

Resource Minimum
RAM 1.8 GB (model stays resident after first import)
Disk 10 GB for training data at --datadir path
Storage type SSD recommended for I/O-heavy batch loads
Python 3.9 – 3.13

Linux (Ubuntu / Debian)

sudo apt-get install -y curl autoconf automake libtool pkg-config
git clone https://github.com/openvenues/libpostal.git
cd libpostal
./bootstrap.sh
./configure --datadir=/opt/libpostal_data
make -j$(nproc)
sudo make install
sudo ldconfig
pip install pypostal

macOS (Intel and Apple Silicon)

brew install autoconf automake libtool pkg-config
# Clone and build same as Linux.
# On Apple Silicon add --disable-sse2 if compilation fails:
./configure --datadir=/opt/libpostal_data --disable-sse2
make -j$(nproc)
sudo make install
pip install pypostal

Docker

Build a custom image from Ubuntu and run the Linux steps above. No official pre-built image exists. Mount application code into the image and install pypostal in the same layer so the shared library is on LD_LIBRARY_PATH.

Component Label Reference

parse_address assigns labels from libpostal’s taxonomy. The table below covers the labels most relevant to production pipelines:

Label What it captures Example value
house_number Street number "123"
road Street name including type "main street"
unit Apartment, suite, or floor "apt 4b"
city Municipality "berlin"
state Region, province, or Bundesland "berlin"
postcode Postal or ZIP code "10115"
country Country name or ISO code "germany"
suburb Neighbourhood or borough "mitte"
house Named building or POI "empire state building"
po_box PO box number "po box 44"

All output values are lowercased by default. Reconstruct display casing at the application layer with str.title() or locale-aware routines.

Minimal Runnable Implementation

from postal.parser import parse_address
from postal.expand import expand_address
from typing import Optional
import logging

logger = logging.getLogger(__name__)

REQUIRED_LABELS: frozenset[str] = frozenset({"house_number", "road", "city", "postcode"})


def normalize_address(raw: str) -> dict:
    """
    Parse and expand a raw international address string.

    Returns a dict with:
      - status: 'success' | 'low_confidence' | 'error'
      - confidence: float 0.0–1.0 based on required-label coverage
      - components: dict[label, value] from parse_address
      - canonical_variants: list[str] from expand_address
      - missing_labels: list[str] of required labels absent from the parse
    """
    raw = raw.strip()
    if len(raw) < 5:
        return {"status": "error", "reason": "Input too short"}

    try:
        parsed = parse_address(raw)
        components: dict[str, str] = {label: value for value, label in parsed}

        missing = sorted(REQUIRED_LABELS - components.keys())
        confidence = 1.0 - len(missing) / len(REQUIRED_LABELS)

        canonical_variants: list[str] = expand_address(raw)

        return {
            "status": "success" if confidence >= 0.75 else "low_confidence",
            "confidence": round(confidence, 2),
            "components": components,
            "canonical_variants": canonical_variants,
            "missing_labels": missing,
        }
    except Exception as exc:
        logger.error("libpostal parse failed for %r: %s", raw, exc)
        return {"status": "error", "reason": str(exc)}

Vectorized pandas variant

Pre-warm libpostal once at import time; the bindings load the model into RAM on first call and reuse it for all subsequent calls in the process.

import pandas as pd
from typing import Any


def normalize_series(series: pd.Series) -> pd.DataFrame:
    """
    Apply normalize_address to every row of a Series.
    Returns a DataFrame with columns: status, confidence, components,
    canonical_variants, missing_labels.
    """
    results: list[dict[str, Any]] = series.apply(normalize_address).tolist()
    return pd.DataFrame(results)


# Usage:
# df = pd.read_csv("addresses.csv")
# normalized = normalize_series(df["raw_address"])
# df = pd.concat([df, normalized], axis=1)

Apply normalize_series after any upstream Unicode and character normalization step so that libpostal receives clean UTF-8 rather than mixed-encoding fragments.

Memory Is the Real Deployment Constraint

libpostal’s accuracy comes from a large trained model, and that model is loaded into process memory on first use. This is the fact that decides your deployment shape: it is fine on a long-lived worker and painful on a per-request serverless function, where every cold start pays the full load. The table below is the sizing we plan against.

libpostal deployment shapes and their cost Three deployment shapes compared. A long-lived worker pool loads the model once and amortises it, and is recommended. A container that handles many requests per instance is workable when the image ships the data files. A per-request serverless function reloads the model on every cold start and is not recommended. Long-lived worker ~2 GB resident, loaded once load cost amortised over the whole batch recommended size the pool by RAM, not CPU Container service bake data files into the image ~30 s added to cold start, then steady workable keep min instances above zero Per-request function model reloaded per cold start often exceeds the memory ceiling outright avoid call a shared parse service instead

If a function-shaped deployment is non-negotiable, put libpostal behind a small internal HTTP service that holds the model resident and expose parse and expand as endpoints. That keeps the memory cost in one place, makes the model version an explicit deployment artefact, and lets every language in the estate call it — which matters, because the Python bindings are only one of several front-ends onto the same C library.

Edge Cases and Failure Modes

1. PO boxes and military addresses

libpostal recognises po_box as a label but its confidence on US-style PO BOX 44 is lower than on street addresses, and APO/FPO/DPO military designators often parse with missing city and postcode. Pre-screen inputs with a lightweight pattern before calling libpostal:

import re

_PO_BOX_RE = re.compile(
    r"\b(P\.?\s?O\.?\s?BOX|POST\s+OFFICE\s+BOX)\s+\d+\b",
    re.IGNORECASE,
)


def route_before_parse(raw: str) -> str:
    """Return 'po_box', or 'standard' to signal the downstream handler."""
    if _PO_BOX_RE.search(raw):
        return "po_box"
    return "standard"

See the dedicated handling PO boxes and rural routes page for the complete extraction workflow.

2. Mixed-language inputs

An address like "Potsdamer Platz 1, 10785 Berlin, Germany" may parse correctly, but a hybrid like "Potsdamer Platz 1 Berlim Alemanha" (Portuguese city/country spellings) can drop postcode. Use expand_address output as the deduplication key rather than re-assembling raw components, because expansion normalizes across language variants.

3. Addresses without house numbers

Venue-only inputs ("Eiffel Tower, Paris") label house but omit house_number and road. These produce a confidence of 0.5 against the four-field requirement. Route them to a geocoding provider directly rather than attempting component assembly — see implementing fallback chains for failed lookups for the routing pattern.

Integration Note

In a full International Address Format Standardization pipeline, libpostal sits between raw input sanitization and geocoding API dispatch. The components dict it returns maps directly onto the structured address fields expected by most geocoding providers, while the canonical_variants list provides a deterministic key for deduplication before records reach the geocoder. When confidence falls below threshold, routing ambiguous records to a secondary provider prevents silent data loss without blocking the main pipeline.

For batch workloads, run normalization in a background queue rather than synchronously — libpostal’s model is not async-safe and should live in a dedicated worker process. Pairing the worker with a Redis LRU cache for expand_address results (which are deterministic for identical inputs) cuts redundant C-library calls by 30–60% on typical address datasets with repeated values.