Current section
Files
Jump to
Current section
Files
lib/postal.ex
defmodule Postal do
@moduledoc """
Elixir bindings for [libpostal](https://github.com/openvenues/libpostal), a fast
statistical parser/normalizer for street addresses around the world.
Postal wraps libpostal via a Rust NIF using [Rustler](https://github.com/rusterlium/rustler),
providing memory-safe, high-performance address parsing and normalization.
## Features
- **Address parsing** — decompose free-text addresses into structured components
(house number, road, city, state, postcode, country, etc.)
- **Address expansion** — normalize addresses by expanding abbreviations, converting
to canonical forms, and handling international formats
## Setup
libpostal requires a one-time initialization that loads its data files (~2GB) into memory.
This happens automatically on the first call to `parse_address/1` or `expand_address/2`,
but you can pre-initialize at application boot to avoid the first-call latency:
# In your Application.start/2
def start(_type, _args) do
:ok = Postal.setup()
children = [
# ...
]
Supervisor.start_link(children, strategy: :one_for_one)
end
## Prerequisites
- **Rust toolchain** — install via [rustup](https://rustup.rs/)
- **libpostal C library** — must be compiled and installed on your system with data files
downloaded. See the [libpostal README](https://github.com/openvenues/libpostal#installation)
for instructions.
## Examples
# Parse a free-text address into components
{:ok, result} = Postal.parse_address("123 Main St, New York, NY 10001")
# => {:ok, %{house_number: "123", road: "main st", city: "new york", state: "ny", postcode: "10001"}}
# Expand/normalize an address
{:ok, expansions} = Postal.expand_address("123 Main St NYC")
# => {:ok, ["123 main street new york city", ...]}
# Use language hints for better expansion accuracy
{:ok, expansions} = Postal.expand_address("Av. Paulista, 1578", languages: ["pt"])
"""
@typedoc """
Options accepted by `expand_address/2`.
- `:languages` — a list of ISO 639-1 language codes (e.g. `["en"]`, `["fr", "de"]`) to hint
the language(s) of the address. Improves normalization accuracy for multilingual regions.
"""
@type expand_option :: {:languages, [String.t()]}
@typedoc """
A parsed address represented as an atom-keyed map.
Common keys returned by libpostal include:
- `:house_number` — street number (e.g. `"123"`)
- `:road` — street name (e.g. `"main street"`)
- `:suburb` — neighbourhood or suburb
- `:city` — city or town name
- `:city_district` — borough or district within a city
- `:state` — state or province
- `:state_district` — sub-state administrative region
- `:postcode` — postal/zip code
- `:country` — country name
- `:unit` — apartment or unit number
- `:level` — floor or level
- `:staircase` — staircase identifier
- `:entrance` — entrance identifier
- `:po_box` — PO box number
- `:house` — named building or house
- `:category` — place category (e.g. `"restaurant"`)
- `:near` — nearby landmark or reference
- `:world_region` — continent or world region
- `:island` — island name
Not all keys will be present — only components identified in the input address are returned.
"""
@type parsed_address :: %{optional(atom()) => String.t()}
# libpostal returns a fixed set of ~16 labels (house_number, road, city, etc.)
# so String.to_atom/1 is safe here — no risk of atom table exhaustion.
@doc """
Pre-initializes libpostal by loading its data files into memory.
This is **optional** — if not called, initialization happens lazily on the first call to
`parse_address/1` or `expand_address/2`. However, initialization loads ~2GB of data files
and can take 1-2 seconds, so calling `setup/0` at application boot avoids unexpected
latency on the first request.
Idempotent — safe to call multiple times. Subsequent calls are no-ops.
## Examples
Postal.setup()
#=> :ok
## Typical usage
# In your Application module
def start(_type, _args) do
:ok = Postal.setup()
Supervisor.start_link(children, strategy: :one_for_one)
end
"""
@spec setup() :: :ok | {:error, String.t()}
def setup do
wrap_nif_call(fn -> Postal.Native.setup() end)
end
@doc """
Parses a free-text address string into labeled components.
Uses libpostal's statistical address parser to decompose an address into structured
fields like house number, road, city, state, postcode, and country. The parser supports
addresses from around the world in many languages and formats.
Returns `{:ok, map}` where the map has atom keys for each identified component,
or `{:error, reason}` if parsing fails.
## Examples
Postal.parse_address("123 Main St, New York, NY 10001")
#=> {:ok, %{house_number: "123", road: "main st", city: "new york", state: "ny", postcode: "10001"}}
Postal.parse_address("1 Rue de Rivoli, Paris")
#=> {:ok, %{house_number: "1", road: "rue de rivoli", city: "paris"}}
## Notes
- libpostal normalizes text to lowercase in its output
- The set of keys in the result depends on the input — only identified components are included
- Results are statistical best-guesses, not deterministic parses
"""
@spec parse_address(String.t()) :: {:ok, parsed_address()} | {:error, String.t()}
def parse_address(address) when is_binary(address) do
case wrap_nif_call(fn -> Postal.Native.parse_address(address) end) do
{:ok, components} ->
map =
Map.new(components, fn {label, value} ->
{String.to_atom(label), value}
end)
{:ok, map}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Same as `parse_address/1` but raises `Postal.Error` on failure.
## Examples
Postal.parse_address!("123 Main St, New York, NY 10001")
#=> %{house_number: "123", road: "main st", city: "new york", state: "ny", postcode: "10001"}
"""
@spec parse_address!(String.t()) :: parsed_address()
def parse_address!(address) do
case parse_address(address) do
{:ok, result} -> result
{:error, reason} -> raise Postal.Error, message: reason
end
end
@doc """
Expands and normalizes an address string into canonical forms.
Uses libpostal's address normalizer to expand abbreviations (e.g. "St" to "street",
"NYC" to "new york city") and produce one or more canonical representations of the
input address. This is useful for deduplication and matching.
Returns `{:ok, expansions}` where `expansions` is a list of normalized address strings,
or `{:error, reason}` if expansion fails.
## Options
- `:languages` — a list of ISO 639-1 language codes (e.g. `["en"]`, `["fr", "de"]`).
Hints the language(s) of the address to improve normalization accuracy.
## Examples
Postal.expand_address("123 Main St NYC")
#=> {:ok, ["123 main street new york city", ...]}
Postal.expand_address("Av. Paulista, 1578", languages: ["pt"])
#=> {:ok, ["avenida paulista 1578", ...]}
## Notes
- Multiple expansions may be returned when there are ambiguous abbreviations
- Output is normalized to lowercase
- Useful for address deduplication — expand both addresses and compare the results
"""
@spec expand_address(String.t(), [expand_option()]) ::
{:ok, [String.t()]} | {:error, String.t()}
def expand_address(address, opts \\ []) when is_binary(address) do
languages = Keyword.get(opts, :languages, [])
wrap_nif_call(fn -> Postal.Native.expand_address(address, languages) end)
end
@doc """
Same as `expand_address/2` but raises `Postal.Error` on failure.
## Examples
Postal.expand_address!("123 Main St NYC")
#=> ["123 main street new york city", ...]
"""
@spec expand_address!(String.t(), [expand_option()]) :: [String.t()]
def expand_address!(address, opts \\ []) do
case expand_address(address, opts) do
{:ok, result} -> result
{:error, reason} -> raise Postal.Error, message: reason
end
end
@libpostal_not_found_message """
libpostal C library not found. The NIF could not be loaded.
Install libpostal before using this package:
macOS: brew install libpostal
Linux: See https://github.com/openvenues/libpostal#installation
If libpostal is installed in a non-standard location, ensure it is
on your library path (LD_LIBRARY_PATH on Linux, DYLD_LIBRARY_PATH on macOS).
"""
defp wrap_nif_call(fun) do
fun.()
rescue
e in ErlangError ->
case e do
%ErlangError{original: :nif_not_loaded} ->
{:error, String.trim(@libpostal_not_found_message)}
_ ->
reraise e, __STACKTRACE__
end
end
end