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.

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.

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")

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.