Skip to content

Localization

Purpose

How every place shows fully localized (name + city + country + region) in any supported language, and is searchable by a localized query, at catalog scale (millions of locations). This is the system-wide contract for place localization; it applies to every location regardless of source.

Principle: store each localized geography name once, reference it, assemble at read

Copying Japan → 日本 onto every row in Japan does not scale: it grows as locations × fields × languages. Localized names are instead bounded by geography, not by locations.

location.localizationsits OWN name, N langs
unique per row — unavoidable, small
↓ references (store once)
city_idself-ref → city row
region_id→ regions
country_code→ countries
Each localized geography name is stored once and referenced; storage is O(unique geography), not O(locations).
  • A location stores its own name localizations in Location.Localizations (a BCP-47 keyed map). A place's name is unique, so this cannot be shared.
  • Shared geography is referenced, not copied. Cities are location rows, so city_id is a self-reference to the city's own row, and the localized city name is that row's own localizations. Countries and regions are small reference tables, each carrying a localizations map, keyed by country_code / region id.

Read-time assembly

A localized card for viewer locale L resolves: name = own.localizations[L]; city = cities[city_id].localizations[L]; country = countries[country_code].localizations[L]; region = regions[region_id].localizations[L], each falling back to the base value when a language is missing.

  • Countries (a small fixed set) are cached in memory; localized country is an O(1) lookup with no database round-trip.
  • Cities are resolved by a batched, indexed lookup per page or viewport (the distinct city_ids of a result set), never per row.
  • Fixing a localized name once in the gazetteer updates every location that references it; there is no per-row copy to drift.

Capturing localizations

Localizations are captured from each source's native multilingual data, at the point the data enters the catalog:

Source Provides
Wikidata labels (wbgetentities) curated + reference names across the supported languages
GeoNames alternateNamesV2 city names across the supported languages
OpenStreetMap name:xx tags POI names per language

Photon reverse-geocoding (its multilingual planet index) is an optional gap-filler for a place or language a source did not cover. It is never a hard dependency: the source data already carries the bulk of coverage.

The supported set is the languages the Photon planet index serves; the catalog is multilingual independent of which UI locales the app ships, and API payloads carry only the languages relevant to the viewer.

The same localization store powers search, via two paths:

  1. Name search, any language. A per-location search_text combines the base name and all of its localized names, indexed by PGroonga (normalized with NormalizerNFKC150 for width/kana folding). One index tokenizes Latin and CJK alike, so a query in any language or script matches, and a cost-gated fuzzy pass adds edit-distance typo tolerance. See Locations → Search & ranking.
  2. Geo-scoped queries. A localized place name that denotes a city or country ("京都", "in Japan") resolves against the gazetteer's localized names, yielding a geography id that filters locations by city_id / country_code.
localizationsown + gazetteer
displaylocalizedCard
name searchPGroonga
geo searchgazetteer → id filter
One localized-name store, three consumers.

Invariants & gotchas

  • Localized geography is never copied onto a location. Cities/countries/regions are referenced by id; only a location's own name is stored on the row.
  • Cities are locations, so city_id is a self-reference; there is no separate cities table.
  • Capture at ingest, not at read. Localizations land with the data; the read path assembles and caches, it does not fetch translations.
  • Timezone is not a localization — it is derived from coordinates. See Timezones.