Nominatim vs Pelias for Self-Hosted Geocoding
Once you outgrow a public geocoding endpoint the question stops being "which API?" and becomes "which database am I willing to operate?" — because Nominatim and Pelias resolve the same OpenStreetMap addresses through completely different storage engines, with different import costs, different failure modes, and very different answers to a half-typed query. This guide is for teams standing up their own geocoder inside Geocoding & Address Pipelines, part of Geospatial Data Ingestion & Processing Workflows; if you are still calling a hosted service and only need to stay inside its quota, batch geocoding with GeoPy without rate-limit errors is the cheaper stop.
Why This Approach / What Goes Wrong
Nominatim is one PostgreSQL database. The importer replays an .osm.pbf file through osm2pgsql into a placex table, then spends most of its wall clock computing address hierarchies and filling search_name and word — the token tables that make a lookup a trigram-and-index problem rather than a scan. Everything after that is SQL against PostGIS. Pelias is a small mesh of services: language-independent importers pull OpenStreetMap, OpenAddresses, Who's on First and GeoNames into a single Elasticsearch index, while sidecar services handle address parsing (libpostal), point-in-polygon administrative lookup, coarse "placeholder" resolution, and house-number interpolation along street lines.
That structural difference decides query quality far more than data quality does. An Elasticsearch index built with edge n-gram analyzers can answer 12 elm st ann after the ninth keystroke because every prefix of every token is already indexed; a PostgreSQL trigram index answers whole-token queries well and prefixes poorly, which is why Nominatim has no autocomplete endpoint at all and why people bolt Photon onto a Nominatim database when they need one. Run the comparison the other way and Nominatim wins on authority: placex keeps the full administrative hierarchy and real OSM polygons, so a reverse lookup returns the containing object rather than the nearest indexed document, and you can drop into SQL for anything the HTTP API will not express.
The third axis is the one nobody budgets for: what you are on the hook for at 3 a.m. Nominatim is a single PostgreSQL instance, so the operational surface is one you probably already run — autovacuum on a write-heavy placex, a WAL and backup story, and index bloat after months of replication. Pelias is six or more containers where the Elasticsearch node is a JVM with a heap you must size, disk watermarks that flip the index to read-only when the volume fills, and separate importer, libpostal and point-in-polygon processes that can each fail independently and quietly degrade results rather than erroring. Neither is heavy by modern standards, but one of them is a database you already know how to nurse and the other is a small distributed system.
Throughput rarely separates them. A single well-provisioned box of either handles a few hundred lookups per second before latency climbs, and both scale the same way — stateless API replicas behind a load balancer, backed by a PostgreSQL read replica or an extra Elasticsearch data node. What does separate them at volume is the shape of the client: Nominatim's /search is the natural target for a bulk pass over a table of addresses, while Pelias' autocomplete route is designed for many tiny, latency-sensitive requests from browsers and expects to be hit on every keystroke.
Prerequisites
geopy>=2.4— ships client classes for both engines (Nominatimaccepts adomain=, and there is a dedicatedPeliasclass), so you are not hand-rolling URL buildersrequests>=2.31— needed for the Pelias/v1/autocompleteroute, whichgeopydoes not wrapgeopandas>=1.0andshapely>=2.0— to turn results into a projected layer and measure disagreement between enginesdockerwith Compose v2 — both projects ship a maintained container path (mediagis/nominatimand thepelias/dockerproject repo)- Hardware: a country extract wants 8 cores, 32 GB RAM and NVMe storage; a planet build wants 64 GB RAM and roughly 1.5 TB of fast disk for either engine
python -m pip install "geopy>=2.4" "requests>=2.31" "geopandas>=1.0" "shapely>=2.0"
Spinning disks are not a slow option here, they are a failed one — both imports are random-write bound, and an import that takes four hours on NVMe can take several days on network-attached storage. Below is the wall clock for a roughly 1.5 GB country extract on 8 cores with NVMe, which is the honest shape of both pipelines: comparable totals, but the time sits in very different places.
Step-by-Step Implementation
1. Build the Nominatim database. The maintained image does the whole import in one run and leaves a live API on port 8080. REPLICATION_URL is what makes the instance updatable later — omit it and you have frozen a snapshot.
docker run -it --shm-size=1g -p 8080:8080 --name nominatim \
-e PBF_URL=https://download.geofabrik.de/europe/great-britain-latest.osm.pbf \
-e REPLICATION_URL=https://download.geofabrik.de/europe/great-britain-updates/ \
-e IMPORT_WIKIPEDIA=false \
-e NOMINATIM_PASSWORD=change_me \
-v nominatim-data:/var/lib/postgresql/16/main \
mediagis/nominatim:4.4
2. Build the Pelias index. The pelias/docker repository holds one directory per project area; each step is a separate command so you can re-run a single importer without rebuilding the index.
git clone https://github.com/pelias/docker.git pelias-docker
cd pelias-docker/projects/portugal # copy this as the template for your own area
pelias compose pull
pelias elastic start && pelias elastic wait
pelias elastic create # apply the schema before importing anything
pelias download all # OSM, OpenAddresses, Who's on First, GeoNames
pelias prepare all # placeholder + point-in-polygon datasets
pelias import all
pelias compose up
3. Query Nominatim from Python. Point geopy's Nominatim class at your own host with domain= and scheme="http". Self-hosting removes the one-request-per-second usage policy, but keep a descriptive user_agent so your own logs and proxies can attribute traffic.
from geopy.geocoders import Nominatim
nominatim = Nominatim(
domain="geocoder.internal:8080", # your instance, not the public endpoint
scheme="http",
user_agent="parcel-pipeline/1.0 (gis@example.org)",
timeout=10,
)
# Free-form: one string, the parser does the work
hit = nominatim.geocode("221B Baker Street, London NW1 6XE", addressdetails=True)
print(hit.address, hit.latitude, hit.longitude)
# Structured: a dict of fields — measurably better on messy tabular data
hit = nominatim.geocode(
{"street": "221B Baker Street", "city": "London", "postalcode": "NW1 6XE",
"country": "gb"},
addressdetails=True,
exactly_one=True,
)
print(hit.raw["place_rank"], hit.raw["address"]["postcode"])
Structured mode is the one to prefer whenever your source is a spreadsheet with real columns: Nominatim searches each field against the matching level of the address hierarchy instead of guessing token roles, and a wrong city no longer drags the whole query to a different country.
4. Query Pelias from Python, including autocomplete. geopy wraps /v1/search and /v1/reverse; the typeahead route has no wrapper, so call it directly. Note that Pelias speaks GeoJSON, so coordinates arrive as [longitude, latitude] — the opposite order from geopy's .latitude / .longitude attributes, and the single most common source of points landing in the Gulf of Guinea.
import requests
from geopy.geocoders import Pelias
PELIAS = "http://pelias.internal:4000"
pelias = Pelias("pelias.internal:4000", scheme="http",
user_agent="parcel-pipeline/1.0", timeout=10)
hit = pelias.geocode("221B Baker Street, London", exactly_one=True)
print(hit.address, hit.latitude, hit.longitude)
def autocomplete(prefix, focus=(51.5074, -0.1278), size=5):
"""Prefix search for a UI box — Pelias only. Returns lon/lat, GeoJSON order."""
response = requests.get(
f"{PELIAS}/v1/autocomplete",
params={
"text": prefix,
"size": size,
"focus.point.lat": focus[0], # bias results near the map view
"focus.point.lon": focus[1],
"layers": "address,street,venue",
},
timeout=5,
)
response.raise_for_status()
return [
{
"label": feature["properties"]["label"],
"confidence": feature["properties"].get("confidence"),
"lon": feature["geometry"]["coordinates"][0],
"lat": feature["geometry"]["coordinates"][1],
}
for feature in response.json()["features"]
]
for suggestion in autocomplete("221b baker st"):
print(suggestion["label"], round(suggestion["confidence"] or 0, 2))
5. Normalise both into one projected layer. Whichever engine answers, the output is EPSG:4326 longitude/latitude. Build the Point with longitude first, keep the layer in 4326 for storage, and reproject to a local UTM zone only when you need metres — Web Mercator would inflate every distance you measure at British latitudes by more than half.
import geopandas as gpd
from shapely.geometry import Point
def to_layer(records):
"""records: list of {'query', 'label', 'lon', 'lat', 'engine'}"""
return gpd.GeoDataFrame(
records,
geometry=[Point(r["lon"], r["lat"]) for r in records], # x=lon, y=lat
crs="EPSG:4326",
)
rows = [{"query": q, **autocomplete(q, size=1)[0], "engine": "pelias"}
for q in ["221b baker st london", "10 downing st london"]]
addresses = to_layer(rows)
metric = addresses.estimate_utm_crs() # UTM 30N here, never EPSG:3857
addresses_m = addresses.to_crs(metric)
print(addresses_m.crs.axis_info[0].unit_name) # metre
6. Apply the decision rule. Query shape decides first, source coverage second, and freshness third. Volume enters only as a tie-breaker, for the reason above: the two engines scale along the same axis, so a request-per-second target almost never picks one over the other.
If the third branch is where your workload sits, consider skipping the geocoder entirely: a point-in-polygon join against boundary tables you already hold is faster and fully under your control, as covered in PostGIS Integration with Python and in reverse geocoding points to administrative boundaries.
Verification
Before either instance goes anywhere near production, confirm the data is present and current, then measure how far the two engines disagree on a gold set of known addresses. Disagreement in metres is the only comparison that survives review.
# Nominatim reports how far behind the OSM replication stream it is
curl -s "http://geocoder.internal:8080/status?format=json"
# {"status":0,"message":"OK","data_updated":"2026-07-31T22:00:00+00:00"}
# Pelias: confirm the index exists and holds the document count you expect
curl -s "http://pelias.internal:9200/_cat/indices/pelias?v"
# health status index docs.count store.size
# green open pelias 9412877 41.7gb
import numpy as np
gold = ["221B Baker Street, London", "10 Downing Street, London",
"Kings Cross Station, London"]
nom_rows, pel_rows = [], []
for query in gold:
n = nominatim.geocode(query)
p = pelias.geocode(query)
assert n is not None and p is not None, f"no result for {query!r}"
nom_rows.append({"query": query, "label": n.address, "lon": n.longitude,
"lat": n.latitude, "engine": "nominatim"})
pel_rows.append({"query": query, "label": p.address, "lon": p.longitude,
"lat": p.latitude, "engine": "pelias"})
nominatim_pts = to_layer(nom_rows)
pelias_pts = to_layer(pel_rows)
metric = nominatim_pts.estimate_utm_crs() # projected CRS, metres
offsets = (nominatim_pts.to_crs(metric).geometry
.distance(pelias_pts.to_crs(metric).geometry, align=False))
print(f"median disagreement: {offsets.median():.1f} m "
f"| worst: {offsets.max():.1f} m")
# median disagreement: 11.4 m | worst: 63.8 m
assert nominatim_pts.crs.to_epsg() == 4326
assert np.isfinite(offsets).all()
assert offsets.median() < 50, "engines disagree at street level — check sources"
A median of a few metres means both engines resolved the same OSM object and you can pick on operational grounds. Hundreds of metres means one of them fell back to a city or postcode centroid, which usually shows up as a coarse place_rank on the Nominatim side or a layer of locality rather than address on the Pelias side.
Edge Cases & Debugging
- Nominatim import stalls or is killed during "Indexing rank 26". That phase is address computation, not I/O — raise PostgreSQL
maintenance_work_memandshared_buffers, and always pass--shm-size=1gto the container or the parallel index workers die silently. - The Elasticsearch container exits with code 78 immediately. The kernel limit is too low:
sudo sysctl -w vm.max_map_count=262144, and persist it in/etc/sysctl.d/or the next reboot kills the index again. - Pelias returns city centroids where you expected house numbers. Only the OSM importer ran. Add
pelias download oa && pelias import oafor OpenAddresses coverage, and enable the interpolation service so odd house numbers between surveyed points are estimated rather than dropped. - Nominatim returns nothing for "Main St, Springfield". It does not normalise abbreviations the way libpostal does — expand
SttoStreetclient-side, or switch that call to the structured form from step 3. - Every geocoded point lands in the ocean south of Ghana. Coordinates were consumed in the wrong order: GeoJSON is
[lon, lat],geopyresult objects are.latitudethen.longitude, andPoint()takes x (longitude) first.
Frequently Asked Questions
Can I run both engines together? Yes, and for a user-facing product it is the common ending: Pelias serves the autocomplete box because nothing else answers a nine-character prefix in 30 ms, while Nominatim serves batch enrichment and reverse lookups because it holds real administrative polygons and can be updated continuously. Route by endpoint rather than by fallback chaining, and reconcile the two with the metre-level distance check from the verification section so you notice when they drift onto different objects.
How much disk and RAM does each really need? For a single-country extract, plan on 32 GB RAM and a few hundred gigabytes of NVMe for either — Nominatim's PostgreSQL data directory and Pelias' Elasticsearch index end up the same order of magnitude. A planet build is a different commitment: budget 64 GB RAM and roughly 1.5 TB of fast disk, plus days rather than hours of import, and remember Nominatim needs headroom on top of the final size for temporary osm2pgsql tables. Measure on your own extract before sizing the machine; regional density moves these numbers more than the software choice does.
Does Pelias require libpostal, and is it worth the memory?
Pelias runs libpostal as its own service to parse free-form input into labelled address components, and it wants a couple of gigabytes of RAM plus its trained data files. It is worth it precisely when your input is messy human text — abbreviations, missing commas, mixed languages — which is also the case where Nominatim's own parser is weakest. If every query arrives as clean structured fields, that advantage largely disappears and Nominatim's structured /search matches it.
How do updates differ in practice?
Nominatim applies OpenStreetMap change files in place, so a nominatim replication loop keeps an instance minutes behind the live map with no downtime and no second copy of the data. Pelias has no incremental path: you re-run the importers into a fresh index and swap the alias, which is safe and atomic but means double the disk during the rebuild and a refresh cadence measured in days. If somebody editing OSM needs to see their fix reflected the same afternoon, that requirement alone chooses Nominatim.