A Query.Farm VGI worker for DuckDB.
vgi-libpostal · a Query.Farm VGI worker · powered by libpostal
International address parsing + normalization in DuckDB SQL, powered by
libpostal — the statistical
international street-address parser/normalizer trained on
OpenStreetMap — exposed through the
postal (pypostal) Python binding and
VGI (Vector Gateway Interface).
INSTALL vgi FROM community; LOAD vgi;
ATTACH 'postal' (TYPE vgi, LOCATION 'docker:https://github.com/proxy/ghcr.io/query-farm/vgi-libpostal:0.3.0');
-- Parse an address into a MAP of components (values are lower-cased)
SELECT postal.parse_address('1600 Pennsylvania Ave NW, Washington, DC 20500');
-- {road=pennsylvania ave nw, city=washington, state=dc, postcode=20500, house_number=1600}
SELECT postal.parse_address('10 Downing St, London SW1A 2AA, UK')['postcode']; -- 'sw1a 2aa'
SELECT postal.address_city('1600 Pennsylvania Ave NW, Washington, DC 20500'); -- 'washington'
-- Normalize / expand abbreviations
SELECT UNNEST(postal.expand_address('120 E 96th St')); -- '120 east 96th street', ...
-- Long-format parse: one (label, value) row per component
SELECT * FROM postal.parse_address_components('781 Franklin Ave, Brooklyn, NY 11216');
-- Discover the component labels libpostal can emit
SELECT label FROM postal.address_labels() ORDER BY label;Run it from the published Docker image — this is the supported way. The
worker needs the libpostal C library and its ~2 GB of trained data models,
which are not pip-installable, so uv/uvx alone cannot install it. The
image at ghcr.io/query-farm/vgi-libpostal (linux/amd64 + linux/arm64, cosign
signed) bakes in libpostal, its data, and the worker, so the only host
requirement is Docker (or Podman / nerdctl):
INSTALL vgi FROM community;
LOAD vgi;
ATTACH 'postal' AS postal (TYPE vgi, LOCATION 'docker:https://github.com/proxy/ghcr.io/query-farm/vgi-libpostal:0.3.0');
SELECT postal.parse_address('10 Downing St, London SW1A 2AA, UK');Change :0.3.0 to move to a newer release.
The vgi extension turns a docker:// (or oci://) LOCATION into
docker run -i --rm <image> stdio, pulls the image on first use, and keeps the
container warm in its worker pool across queries. Pin a release tag (:0.3.0)
for reproducibility; :latest tracks the newest release and :edge tracks
main. The image is large (~2.8 GB uncompressed), so pull it ahead of time if
you like: docker pull ghcr.io/query-farm/vgi-libpostal:0.3.0. libpostal loads
its models on the first parse/expand query (a few seconds), not at ATTACH.
-
Over HTTP: the same image also serves HTTP (
docker run -p 8000:8000 ghcr.io/query-farm/vgi-libpostal:0.3.0, thenATTACH ... LOCATION 'http://localhost:8000'). -
With
uvx(needs libpostal and its data installed on the host). ThepostalPython binding compiles against libpostal, so point the compiler at it for the first install:# macOS: brew install libpostal (library + ~1.9 GB data) # Linux: build from source -- https://github.com/openvenues/libpostal#installation export CFLAGS="-I$(brew --prefix libpostal)/include" LDFLAGS="-L$(brew --prefix libpostal)/lib" uvx --from git+https://github.com/Query-farm/vgi-libpostal@v0.3.0 vgi-libpostal-worker --help
uv caches the built environment, so afterwards the same command works as a LOCATION without the flags:
ATTACH 'postal' AS postal (TYPE vgi, LOCATION 'uvx --from git+https://github.com/Query-farm/vgi-libpostal@v0.3.0 vgi-libpostal-worker');
(On Linux, also make sure the libpostal shared library is on the loader path, e.g.
sudo ldconfigorLD_LIBRARY_PATH.) -
From a local checkout (
git clone https://github.com/Query-farm/vgi-libpostal), export the sameCFLAGS/LDFLAGS, runuv sync --extra devonce, and pointLOCATIONatuv run --project /path/to/vgi-libpostal --no-sync vgi-libpostal-worker—--no-syncreuses the prebuilt.venvinstead of recompilingpostalon every launch.
To build and test the worker yourself, see Development & testing.
| Function | Returns | Description |
|---|---|---|
parse_address(text) |
MAP(VARCHAR, VARCHAR) |
libpostal components keyed by label (road, city, state, postcode, country, house_number, unit, …). |
expand_address(text) |
VARCHAR[] |
Normalized expansions (St → street, E → east, …). |
address_house_number(text) |
VARCHAR |
The house_number component, or NULL. |
address_road(text) |
VARCHAR |
The road (street) component, or NULL. |
address_unit(text) |
VARCHAR |
The unit (apartment/suite) component, or NULL. |
address_city(text) |
VARCHAR |
The city component, or NULL. |
address_state(text) |
VARCHAR |
The state (province/region) component, or NULL. |
address_postcode(text) |
VARCHAR |
The postcode (ZIP) component, or NULL. |
address_country(text) |
VARCHAR |
The country component, or NULL. |
| Function | Yields | Description |
|---|---|---|
parse_address_components(text) |
(label VARCHAR, value VARCHAR) |
One row per parsed component (long format). |
address_labels() |
(label VARCHAR) |
Every component label libpostal can emit (discovery). |
- libpostal lower-cases its output.
address_state('… NY …')returns'ny', not'NY'. Filter and compare against lower case. - NULL input → NULL output (NULL map / NULL list / NULL string / no rows).
- Empty string → empty map / empty list / no rows — never an error.
- A missing component (e.g. no country in a bare US address) → NULL from the extractor, or simply absent from the map / long-format rows.
- libpostal labels can be subtle: a New York City borough like Brooklyn
comes back as
city_district(withcity= the larger municipality), and a neighbourhood assuburb. Useaddress_labels()to see the full label set.
export CFLAGS="-I$(brew --prefix libpostal)/include" LDFLAGS="-L$(brew --prefix libpostal)/lib"
uv sync --extra dev
uv run --no-sync pytest -q # unit (pure logic) + integration (Client RPC)
make test-sql # end-to-end SQL via haybarn-unittest (authoritative)
make test # both
uv run --no-sync ruff check . && uv run --no-sync mypy vgi_libpostal/make test-sql sets VGI_LIBPOSTAL_WORKER="uv run --no-sync libpostal_worker.py", puts ~/.local/bin on PATH, and runs
haybarn-unittest over test/sql/*. Install the runner once with
uv tool install --prerelease allow 'haybarn-unittest>=2.0.0rc11,<2.1'.
The SQL suite is the authoritative behaviour gate — the in-process pytest path can pass while the real ATTACH+SELECT wire path is broken. Run both.
libpostal_worker.py repo-root stdio entry point; PEP 723 header; main()
vgi_libpostal/
addresses.py pure parse/expand logic over the `postal` binding (no Arrow/VGI)
scalars.py per-row scalars: parse_address (MAP), expand_address (LIST), extractors
tables.py parse_address_components (long) + address_labels (discovery)
schema_utils.py pa.Field comment / column-doc helper
tests/ pytest: test_addresses (pure) + test_scalars (Client RPC) + test_tables
test/sql/*.test haybarn-unittest sqllogictest — authoritative E2E
Makefile test / test-unit / test-sql / lint
Permissive throughout:
- This worker — MIT (see LICENSE).
- pypostal (
postal) — MIT. - libpostal (C library + data models) — MIT.
vgi-python itself is under the Query Farm Source-Available License; see that
project for its terms.
Created by Query.Farm.
Written by Query.Farm.
Copyright 2026 Query Farm LLC - https://query.farm
