Skip to content

Build1 publisher3 min readPublished

Nominatim swaps one class/type pair per place for an array of ltree category paths

Agasta's Google Summer of Code work puts the place hierarchy inside PostgreSQL's ltree extension, so one query can match everything under osm.amenity. The old class and type columns stay as the fields the API returns.

The Engineer · Build desk

What happened

  • Nominatim, the geocoder that turns OpenStreetMap names and addresses into coordinates, allowed only one class/type pair per place, so a hotel containing a restaurant could end up as two database rows.
  • Agasta's Google Summer of Code project replaced that with a categories column carrying both identities on one row, and changed the import pipeline, schema, ranking and trigger logic, search indexes, migration path, API and SQLite adaptor.
  • Categories are stored as PostgreSQL ltree paths, so a query can match everything below osm.amenity without the importer storing every prefix explicitly.
  • PR #4106 added the categories ltree[] column to place and placex, generated the values in the Lua import code, updated the SQL ranking and trigger functions and added migration support.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • capability A filter for everything under osm.amenity is now one containment operator against the column, instead of prefix lists an application has to expand and maintain.
  • constraint Anyone who wants that filtering inherits an extension dependency, and the label alphabet of the oldest PostgreSQL version Nominatim supports decides what the column can hold.
  • exposure Tag values the importer cannot represent land in a yes label, so code that filters on categories alone sees a coarser place than the one recorded in extratags.
  • decision Holding class and type as the API's presentation fields puts the upgrade cost on the database side, so integrators are not forced to rewrite their readers in the same release.

The hierarchy lives in the column because ltree understands a path. Matching descendants is `WHERE categories <@ 'osm.amenity'::ltree`, and an exact test is `WHERE 'osm.amenity.restaurant'::ltree = ANY(categories)` [10]. The importer never writes the prefixes out [9]. Agasta had tried a `TEXT[]` column with prefix expansion and a GIN index, which avoids the extension dependency but moves hierarchy handling into application code and stores more data [11]. Agasta wrote that `ltree[]` fit better after testing the alternatives on real Nominatim data; the post does not include the comparison measurements [12].

Normalization is where the model gets lossy. Some of the PostgreSQL versions Nominatim supports restrict which characters an ltree label can hold, and OSM tag values use more than that [13]. So the importer rewrites hyphens as underscores and substitutes `yes` for values it cannot represent, leaving the original reachable through class, type and extratags [14]. A fallback label of `yes` is not a category anyone would design, and it is one every supported PostgreSQL version can store. Both rules are many-to-one, so two different tag values can end up as the same category path [16].

`class` and `type` are Nominatim's internal encoding of an OSM main tag such as `amenity=restaurant` [2], and they stay in API responses for compatibility while categories take over filtering and classification [8]. A place with several main tags still needs one pair to win, and the choice comes from a fixed ordering. "The current rule is deliberately boring: use a stable ordering so that the same set of tags always produces the same legacy value," Agasta wrote [21]. A randomly changing winner, Agasta wrote, would make an update look like a different place to downstream logic even when the OSM tags had not changed [22].

Sarah's review made the import cheaper. The first version produced one row per main tag and merged the rows afterwards, which she flagged as creating rows only to collapse them again [18]. The merge moved into `process_tags()`, where the Lua code collects the categories and issues a single insert [19]. For an object with more than one main tag, that is one insert instead of several and a merge [20].

Ranking took more work than Agasta expected, and `search_rank` and `address_rank` are computed from the place classification [23]. A row holding several categories therefore needs a rule for which category sets the rank [24]. Administrative boundaries had needed special handling through `admin_level` [4].

For an operator the upgrade is a schema migration with an extension attached, and PR #4106 shipped migration support next to the new column on `place` and `placex` [17]. Integrators reading class and type out of the API get the same fields as before [8].

What to watch

  • Whether existing installs can run PR #4106's migration without a full reimport of the data.
  • Whether Nominatim's install documentation makes the ltree extension a hard prerequisite.
  • Publication of the ltree[] against TEXT[]-plus-GIN comparison Agasta ran on real Nominatim data.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories