Nominatim vs Pelias for Self-Hosted Geocoding

Choose Nominatim when you want a single-container, Postgres-backed OpenStreetMap geocoder that is cheap to stand up and excels at full-address lookups, and choose Pelias when you need type-ahead autocomplete, blended data sources beyond OSM, and horizontal Elasticsearch scaling — both are covered as self-hosted options in the broader comparing geocoding accuracy across providers guide.

Decision Matrix

Self-hosting removes per-request cost and result-storage restrictions but shifts the burden onto your operations team. The choice between these two engines is really a choice about data breadth, autocomplete needs, and how much infrastructure you are willing to run. When either becomes a node in a multi-API routing and fallback chain, these properties decide where it sits.

Axis Nominatim Pelias
Data sources OpenStreetMap only OSM + OpenAddresses + WhosOnFirst + TIGER
Storage engine PostgreSQL + PostGIS Elasticsearch
Architecture Single service + DB Microservices (API, PIP, placeholder, importers)
Autocomplete Not a first-class feature Native /autocomplete endpoint
Import time (planet) Long single import into Postgres Parallel importers into Elasticsearch
Horizontal scaling Read replicas of Postgres Shard/replica across ES nodes
Hardware footprint Lower; one box can serve a country Higher; ES cluster + import pipeline
Best fit Full-address reverse/forward lookups Autocomplete, multi-source coverage

Data Sources and Coverage

Nominatim builds exclusively on OpenStreetMap. Where OSM is dense — most of Europe and well-mapped urban areas — its results are excellent, but where OSM lacks house-number coverage, Nominatim inherits those gaps directly. Pelias imports from several sources and reconciles them: OpenStreetMap for streets and POIs, OpenAddresses for authoritative point-level house numbers, WhosOnFirst for administrative hierarchy and place names, and TIGER for US street ranges. That blend means Pelias can return a rooftop-quality point from OpenAddresses in a region where OSM has no house number at all. The trade-off is that reconciling overlapping sources introduces its own deduplication and ranking complexity, which is precisely the kind of problem you also manage when deduplicating addresses by fuzzy canonical key.

Two Very Different Ingest Pipelines

The operational character of each system is set by how it gets its data in. Nominatim imports an OSM extract into PostgreSQL and builds its indexes there; Pelias runs a set of importers that write documents into Elasticsearch. That difference explains almost every downstream contrast — import time, hardware shape, update cadence and what it takes to add a second data source.

How each system loads and stores its data Two pipelines. Nominatim takes a single OSM PBF extract through osm2pgsql into PostgreSQL with PostGIS, then builds search indexes in the same database. Pelias runs separate importers for OpenStreetMap, OpenAddresses, Who's on First and a transit source, all writing documents into an Elasticsearch cluster fronted by an API service. Nominatim — one source, one database OSM extract (PBF) osm2pgsql import PostgreSQL + PostGIS search indexes in-database one process to operate Pelias — several sources, a search cluster OpenStreetMap OpenAddresses Who's on First custom importer Elasticsearch cluster plus API and placeholder several services to operate

The multi-source design is Pelias’s main advantage and its main cost. Adding an authoritative national address file is a matter of writing an importer, which is exactly what you want when OSM coverage is thin in your market — and it also means more moving parts to run, monitor and reindex than a single database.

Architecture and Autocomplete

Nominatim is comparatively monolithic: a PostgreSQL/PostGIS database plus a query service. That simplicity is its strength — you can run a country extract on a single well-provisioned VM and back it up like any Postgres database. It performs forward and reverse geocoding well but was never designed for keystroke-latency autocomplete; partial-string queries are not its sweet spot.

Pelias is a set of microservices — an API gateway, a point-in-polygon service, a placeholder service for coarse text, and a family of importers — all sitting on Elasticsearch. Elasticsearch’s inverted index is what gives Pelias a genuinely fast /autocomplete endpoint that returns ranked suggestions as a user types. If your product surface includes an address search box, that capability is difficult to replicate on Nominatim and is usually the deciding factor. Whichever engine you front, cache the normalized outputs so repeated lookups never hit the backend twice, following the patterns in Redis and Postgres caching patterns.

Autocomplete Is the Dividing Line

If the workload is batch normalisation, either engine will serve. If there is an interactive search box, the question is settled: type-ahead needs sub-100 ms responses to partial input, and only one of these architectures is designed for that. Batch and interactive workloads should be sized and, usually, deployed separately.

Batch and interactive workloads want different engines Two workload panels. Batch normalisation tolerates 200 to 500 millisecond responses, runs on a schedule and suits either engine. Interactive autocomplete needs responses under 100 milliseconds to partial input and suits the Elasticsearch-backed design. A note recommends deploying the two workloads separately so a batch run cannot degrade the search box. Batch normalisation complete strings, scheduled, retryable 200–500 ms per lookup is fine throughput matters, latency does not either engine serves this well Interactive autocomplete partial strings, one call per keystroke under 100 ms or the box feels broken prefix matching and ranking are the job the search-cluster design fits this Deploy them apart. A nightly batch saturating the same cluster that serves the search box turns a background job into a customer-visible latency incident, and the two workloads scale on different axes anyway.

Where both workloads exist, the common shape is a search cluster for the interactive path and a separate batch instance — or a commercial provider — for the nightly run. It costs more than one shared deployment and removes an entire category of incident where a data refresh makes the checkout form feel broken.

Throughput, Scaling, and Maintenance

Nominatim scales reads the way Postgres does: add read replicas, put PgBouncer in front, and cache aggressively. Its planet import is a long, largely single-threaded operation that is memory- and disk-intensive, but once imported, the maintenance model is familiar to any team that runs Postgres. Pelias scales by adding Elasticsearch nodes and tuning shards and replicas, and its importers run in parallel, but you are now operating an Elasticsearch cluster with all the heap-tuning, snapshot, and version-upgrade care that entails. For a small team, Nominatim’s operational surface is smaller; for a team that already runs Elasticsearch, Pelias adds less marginal burden.

Unified Python Client

The client below queries either self-hosted service over HTTP and normalizes both to one schema. Point base_url at your own instance — never at a public demo server for production traffic.

from __future__ import annotations

import logging
from dataclasses import dataclass
from typing import Optional

import requests

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger(__name__)


@dataclass
class SelfHostedResult:
    """Common schema for Nominatim and Pelias responses."""

    lat: float
    lon: float
    match_type: str          # e.g. house / street / locality
    confidence: float        # 0-1, normalized across engines
    label: str
    provider: str
    raw_input: str = ""

    @property
    def is_house_level(self) -> bool:
        """True when the result resolves to a specific address point."""
        return self.match_type in {"house", "address", "building"} and self.confidence >= 0.7


def parse_nominatim(address: str, payload: list) -> SelfHostedResult:
    """Parse a Nominatim /search JSON array (jsonv2 format)."""
    if not payload:
        raise ValueError("Nominatim returned no results")
    top = payload[0]
    # Nominatim 'importance' is roughly 0-1; use it as a confidence proxy.
    return SelfHostedResult(
        lat=float(top["lat"]),
        lon=float(top["lon"]),
        match_type=top.get("addresstype", top.get("type", "unknown")),
        confidence=float(top.get("importance", 0.0)),
        label=top.get("display_name", ""),
        provider="nominatim",
        raw_input=address,
    )


def parse_pelias(address: str, payload: dict) -> SelfHostedResult:
    """Parse a Pelias /v1/search GeoJSON FeatureCollection."""
    features = payload.get("features", [])
    if not features:
        raise ValueError("Pelias returned no results")
    top = features[0]
    props = top.get("properties", {})
    # Pelias coordinates are [longitude, latitude].
    lon, lat = top.get("geometry", {}).get("coordinates", [0.0, 0.0])
    return SelfHostedResult(
        lat=float(lat),
        lon=float(lon),
        match_type=props.get("layer", "unknown"),
        confidence=float(props.get("confidence", 0.0)),
        label=props.get("label", ""),
        provider="pelias",
        raw_input=address,
    )


def geocode(address: str, provider: str, base_url: str) -> SelfHostedResult:
    """Query a self-hosted Nominatim or Pelias instance and normalize it."""
    if provider == "nominatim":
        url = f"{base_url.rstrip('/')}/search"
        params = {"q": address, "format": "jsonv2", "limit": 1, "addressdetails": 1}
        resp = requests.get(url, params=params, timeout=10,
                            headers={"User-Agent": "addr-pipeline/1.0"})
        resp.raise_for_status()
        return parse_nominatim(address, resp.json())

    if provider == "pelias":
        url = f"{base_url.rstrip('/')}/v1/search"
        params = {"text": address, "size": 1}
        resp = requests.get(url, params=params, timeout=10)
        resp.raise_for_status()
        return parse_pelias(address, resp.json())

    raise ValueError(f"Unsupported provider: {provider!r}")

Vectorized pandas batch

Self-hosted engines have no per-request billing, so batch throughput is bounded by your own hardware. Keep concurrency modest against a single Nominatim box; scale it up only once you have added Postgres replicas or Elasticsearch nodes.

import pandas as pd


def geocode_frame(
    df: pd.DataFrame,
    address_col: str,
    provider: str,
    base_url: str,
) -> pd.DataFrame:
    """Geocode every row against a self-hosted engine and append columns."""

    def _safe(addr: str) -> dict:
        try:
            return geocode(addr, provider, base_url).__dict__
        except (ValueError, KeyError, requests.RequestException) as exc:
            logger.warning("geocode failed for %r: %s", addr, exc)
            return {"provider": provider, "raw_input": addr, "confidence": 0.0}

    expanded = df[address_col].map(_safe).apply(pd.Series)
    return df.join(expanded, rsuffix="_geo")

The Real Cost Is Operational

Self-hosting trades a per-call bill for staff time, and the trade is only favourable above a volume threshold that most estimates get wrong by ignoring the recurring work. The figures below are the recurring commitments, not the one-off setup.

What self-hosting commits you to, every month Four recurring commitments. Data refresh runs monthly or quarterly and needs a rebuild window. Index rebuilds take hours on a full planet extract and must not disrupt serving. Storage and memory scale with extract size, with a planet import needing substantial resources. Monitoring must cover import freshness, not just uptime, because a stale index serves confident wrong answers. Data refresh monthly or quarterly extract, plus diffs skipping it is how coverage silently rots budget a recurring maintenance window Index rebuild hours on a planet extract, less regionally rebuild beside the live index, then swap needs roughly double the storage briefly Footprint a planet import wants a large SSD and a lot of RAM; a country extract far less start regional, grow only if needed Monitoring freshness alert on import age, not only on uptime a stale index answers confidently and wrongly the failure nobody notices for months

The bottom-right box is the one that catches teams out. A self-hosted geocoder that has not been refreshed in a year does not fail — it returns results, quickly, for a world that has moved on, and new developments simply do not resolve. Export the import timestamp as a metric and alert on it exactly as you would on error rate.

Edge Cases

Nominatim importance is not a match confidence

Nominatim’s importance reflects how prominent a place is in OSM, not how well it matched your query — a famous landmark scores high even for a loose match. Do not treat it as a verification signal on its own. Corroborate house-level results by comparing the returned display_name components against your input, and calibrate any threshold empirically using confidence-scoring techniques.

Pelias source precedence surprises

Because Pelias blends sources, two runs can return points from different providers (OpenAddresses vs OSM) for the same query after a reimport, shifting a coordinate by several metres. Pin the source parameter (for example sources=openaddresses,osm) when you need deterministic behaviour, and record which source produced each result so a downstream diff does not look like a regression.

Integration Note

Self-hosted engines shine as the cost-free primary or as the final fallback tier in a chain, precisely because they carry no per-request fee or result-storage restriction. A common shape is Pelias fronting an autocomplete box while Nominatim handles server-side batch normalization, with a commercial API reserved for the residual set that neither resolves. Wire that escalation through the fallback chains for failed lookups pattern, and consult the parent comparing geocoding accuracy across providers guide to decide the confidence cut-off at which a self-hosted miss should escalate to a paid provider.