Skip to content
Go back

libpostal: Address Parsing Done Right

By SumGuy 12 min read
libpostal: Address Parsing Done Right
Contents

The Regex You Wrote in 2019

It started small. A form field that accepted addresses. Users typed stuff in, you needed structure back. So you wrote a regex. Something like this:

pattern = r"(\d+)\s+([\w\s]+),\s*([\w\s]+),\s*([A-Z]{2})\s+(\d{5})"

It worked great. For US addresses. In the exact format your test cases used.

Then someone entered 123 Main St Apt 4B. Your “unit” was missing. Then someone from the UK entered 14 Grosvenor Square, Mayfair, London W1K 6AH. No state abbreviation. Regex dead. Then a Canadian address showed up with a postal code like K1A 0B1 instead of five digits, and that was the day you realized you’d built a lie.

This is the universal address parsing experience. You patch the regex, add a branch, patch again, add an exception list, and six months later you have three hundred lines of heuristics that still can’t handle c/o Jane Smith or a French address that stacks the department above the city. It’s not your fault. Addresses are just genuinely chaotic.

The right tool for this job is libpostal, a C library trained on hundreds of millions of real-world addresses that turns a raw address string into structured fields without a single handwritten rule.

Why Addresses Are a Nightmare

Before getting into the solution, it’s worth sitting with why this is hard. The short answer is that addresses are a human coordination system that evolved independently across hundreds of countries over centuries, and nobody told the countries to agree on a format.

In the US, you expect: number, street, unit, city, state, ZIP. In Japan, the hierarchy runs backwards, prefecture, city, ward, block, building. In the UK, there’s no concept of a ZIP-style postal code inside the city name, it’s appended after. In Brazil, the street name comes before the number. In Ireland, many rural addresses have no street number at all, just a townland name and a county. And that’s before you get into abbreviations.

“St” means Street in Chicago and Saint in “St. Paul.” “Apt” and “Unit” and ”#” and “Suite” and “Ste” and “No.” all mean roughly the same thing. “NW” is a directional suffix on “14th Street NW” in DC but part of the street name in “NW 23rd Avenue” in Portland. Handling these consistently across a dataset of mixed international addresses with a regex is not a matter of skill, it’s physically impossible to do correctly.

What you actually need is a model that learned address structure from examples rather than rules.

What libpostal Actually Is

libpostal is an open-source C library built by the team at Mapbox (now maintained as a community project). The core of it is a Conditional Random Field (CRF) model, a type of sequence labeling model that assigns a tag to each token in an address string.

Instead of writing rules, the Mapbox team trained the model on address data derived from OpenStreetMap, GeoNames, and OpenAddresses. The training set covers over four hundred million addresses across more than two hundred countries. That’s a lot of Apt, Flat, appartement, appartamento, apartamento to learn from.

The output of that training is a set of model weights (the “data” download, around 2 GB) that libpostal ships with. Give it any address string in any of those languages and it returns a list of labeled tokens: house_number, road, unit, city, state, postcode, country, and more.

It does two things:

parse_address, splits a raw string into labeled components. Give it "120 E 96th St, New York, NY 10128" and you get back a dict of fields.

expand_address, normalizes and expands abbreviations. "123 Main St NW Apt 4b" becomes ["123 main street northwest apartment 4b"] plus a bunch of variations. This is useful for deduplication and building search queries that handle messy addresses.

Neither of these is geocoding. libpostal doesn’t know coordinates. It just knows structure. Think of it as the preprocessing step that makes everything downstream, including your Nominatim queries, actually reliable.

Installation Reality Check

Let’s be honest about the install experience so you’re not surprised.

libpostal is a C library. The install process is:

  1. Clone the repo
  2. Run ./bootstrap.sh and ./configure
  3. make && make install
  4. Wait while it downloads 2+ GB of model data
  5. Then install a language binding
Terminal window
git clone https://github.com/openvenues/libpostal
cd libpostal
./bootstrap.sh
./configure --datadir=/opt/libpostal_data
make -j4
sudo make install
sudo ldconfig

The model data download happens during make install. It’s not a small file. On a slow connection this takes a while. Plan accordingly.

For Python:

Terminal window
pip install postal

That installs the postal package (note: it’s postal, not libpostal). It links against the C library you just built. If you get a linker error, you either skipped ldconfig or the library landed somewhere Python can’t find it.

For Go:

Terminal window
go get github.com/openvenues/gopostal

Same deal, CGo bindings, same library underneath.

If any of that sounds like more friction than you want, skip to the Docker section. There’s a clean REST wrapper image that handles all of this.

Using It: Parse and Expand

Here’s the core usage in Python. Install postal, then:

parse_example.py
from postal.parser import parse_address
from postal.expand import expand_address
# Basic US address
result = parse_address("120 E 96th St, New York, NY 10128")
print(result)
# [('120', 'house_number'), ('e 96th st', 'road'), ('new york', 'city'),
# ('ny', 'state'), ('10128', 'postcode')]
# Unit in the middle
result = parse_address("123 Main St Apt 4B, Austin, TX 78701")
print(dict(result))
# {'house_number': '123', 'road': 'main st', 'unit': 'apt 4b',
# 'city': 'austin', 'state': 'tx', 'postcode': '78701'}
# UK address — no state, postal code format is different
result = parse_address("14 Grosvenor Square, Mayfair, London W1K 6AH")
print(dict(result))
# {'house_number': '14', 'road': 'grosvenor square', 'suburb': 'mayfair',
# 'city': 'london', 'postcode': 'w1k 6ah', 'country': 'gb'}
# Expand: normalize abbreviations for dedup / search
variants = expand_address("123 Main St NW Apt 4b")
print(variants[:3])
# ['123 main street northwest apartment 4b',
# '123 main street nw apartment 4b',
# '123 main st northwest apartment 4b']

Same thing in Go if that’s your stack:

parse_example.go
package main
import (
"fmt"
expand "github.com/openvenues/gopostal/expand"
parser "github.com/openvenues/gopostal/parser"
)
func main() {
addr := "123 Main St Apt 4B, Austin, TX 78701"
parsed := parser.ParseAddress(addr)
for _, component := range parsed {
fmt.Printf("%s: %s\n", component.Label, component.Value)
}
expanded := expand.ExpandAddress("123 Main St NW Apt 4b")
fmt.Println("Variants:", expanded[:2])
}

A few things worth knowing: parse_address returns a list, not a dict, because the same label can appear more than once (a hyphenated house number range like 123-125 will still parse, just oddly). The dict() conversion is fine for the 99% case but be aware of it. Also, the model is loaded once when you first call any function, there’s a noticeable startup delay (a second or two) on cold invocation because it’s loading that 2 GB of weights. In a server process, this is fine. In a CLI tool invoked thousands of times, it’s annoying. Hence: keep it as a long-running service.

The Docker Route

If you don’t want to deal with C library installs and want to expose this as a REST endpoint, pelias/libpostal-service is a clean image that wraps libpostal in an HTTP API:

Terminal window
docker run -d \
--name libpostal \
-p 4400:4400 \
pelias/libpostal-service

That’s it. The container bundles the library and model data. It’ll take a minute to start as it loads the model. Then:

Terminal window
# Parse — GET with an `address` query param
curl -s "http://localhost:4400/parse?address=120+E+96th+St,+New+York,+NY+10128"
# Expand
curl -s "http://localhost:4400/expand?address=123+Main+St+NW+Apt+4b"

Parse response:

[
{"label": "house_number", "value": "120"},
{"label": "road", "value": "e 96th st"},
{"label": "city", "value": "new york"},
{"label": "state", "value": "ny"},
{"label": "postcode", "value": "10128"}
]

This is the integration pattern that makes the most sense for anything beyond a one-shot script: one libpostal container, call it from your app over HTTP, parse addresses consistently across your entire stack regardless of what language each service is written in. Put it behind your internal network, not exposed to the outside world.

Pairing It With Nominatim

Here’s where this becomes genuinely useful. If you’ve self-hosted Nominatim, you already know that freeform string geocoding is inconsistent. Nominatim’s /search?q=<string> endpoint does its best, but it’s trying to figure out which part of your input is the road vs the city vs the postcode at the same time it’s searching. The more ambiguous the input, the worse the results.

The fix is to parse first, then geocode with structured parameters. Here’s the pattern:

parse_then_geocode.py
import requests
from postal.parser import parse_address
def geocode(raw_address: str, nominatim_url: str = "http://localhost:8080") -> dict:
# Step 1: parse the raw string into components
parsed = dict(parse_address(raw_address))
# Step 2: build a structured Nominatim query
params = {
"format": "json",
"addressdetails": 1,
"limit": 1,
}
field_map = {
"house_number": "housenumber",
"road": "street",
"city": "city",
"state": "state",
"postcode": "postalcode",
"country": "country",
}
for libpostal_key, nom_key in field_map.items():
if libpostal_key in parsed:
params[nom_key] = parsed[libpostal_key]
# If we got a house number and road, combine them for Nominatim's `street` param
if "house_number" in parsed and "road" in parsed:
params["street"] = f"{parsed['house_number']} {parsed['road']}"
params.pop("housenumber", None)
resp = requests.get(f"{nominatim_url}/search", params=params, timeout=10)
results = resp.json()
return results[0] if results else {}
# Usage
print(geocode("123 Main St Apt 4B, Austin, TX 78701"))
print(geocode("14 Grosvenor Square, Mayfair, London W1K 6AH"))

In practice this raises geocoding hit rates on messy input data. The structured query tells Nominatim exactly what each token is, instead of making it guess. For batch jobs, enriching a database of customer addresses, for instance, the difference between 70% hit rate on freeform queries and 90%+ on structured queries can mean the difference between a useful dataset and garbage.

The unit field from libpostal doesn’t map to anything in Nominatim, by the way. Nominatim works at building level, not unit level. Parse it, store it, use it in your own systems. Just don’t expect Nominatim to care about Apt 4B.

What libpostal Can’t Do

Be clear on the limits or you’ll build the wrong thing.

It’s a parser, not a geocoder. It returns structure, not coordinates. If you need coordinates you still need Nominatim, Photon, or a commercial API downstream.

It doesn’t validate addresses. 999999 Fake Street, Imaginationland, XX 00000 will parse cleanly into house_number, road, city, state, and postcode. Whether that place exists is not libpostal’s problem.

The model reflects OSM coverage biases. The training data skews toward countries and regions with strong OSM data. North America, Western Europe, Australia, excellent. Parts of Africa, Central Asia, rural South America, coverage is thinner and accuracy drops. If your address dataset is predominantly from regions with sparse OSM data, do spot-checks.

Unusual address types can confuse it. Intersections (Broadway & 42nd St), PO Boxes (P.O. Box 1234, Arlington, VA), and military addresses (APO AE 09012) parse with varying quality. Intersections it handles reasonably. PO Boxes it mostly gets. Military addresses are inconsistent. Test your edge cases.

Single-call startup cost. The model needs ~2 seconds to load on first call. This is fine in a server process, annoying in a short-lived CLI. The Docker REST service pattern sidesteps this.

It’s not actively maintained by Mapbox anymore. The community keeps it alive and it’s stable, but the model weights haven’t been retrained recently. It’s not getting worse, address formats don’t change that fast, but it’s not improving either.

When This Fits Into Your Stack

The real value of libpostal shows up in three scenarios:

Cleaning messy input data. You have a spreadsheet of ten thousand addresses from a form, users typed whatever they wanted, and you need them in a database with structured fields. Parse the whole thing through libpostal in a batch job. Even with the false positives and edge cases, you’ll end up with far better structure than trying to regex your way through it.

Improving geocoding hit rates. As shown above, parse first, then send structured queries to Nominatim or any other geocoder. Higher hit rate, fewer fallbacks, cleaner data.

Address deduplication and normalization. expand_address generates normalized variants that you can use as lookup keys. 123 Main St and 123 Main Street and 123 main st all expand to a shared canonical form. Hash that canonical form and you have a reliable dedup key across different input sources.

If your use case is real-time address autocomplete as a user types, libpostal isn’t the right tool, it’s a batch/pipeline tool, not a sub-10ms typeahead engine. For that you want something backed by a search index like Photon or Pelias.

Wrapping Up

Address parsing is one of those problems that looks simple until it isn’t. The regex you wrote will work fine until it doesn’t, and “until it doesn’t” usually happens exactly when you have real user data from more than one country.

libpostal is a clean solution to a genuinely hard problem: a trained CRF model that understands address structure across hundreds of languages and formats, open source, and fast enough to run in any real pipeline. The install is a little rough, the Docker route smooths it out. Pair it with Nominatim for structured geocoding and you’ve got a solid address processing stack without a single third-party API involved.

Your 2 AM self will appreciate not debugging a regex.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
Synthetic Browser Monitoring with Playwright + Grafana

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts