GSoC 2026: Working on Category Support in Nominatim

Hey everyone!

I’m Rupam Golui - you can call me Agasta too, I prefer it :)

I’ve been selected for Google Summer of Code 2026 with OpenStreetMap and I’ll be working on adding proper native category support to OSM’s search engine Nominatim under the mentorship of @lonvia and @mtmail

Right now Nominatim relies on a single class/type pair per place which causes duplicate entries, makes admin boundaries annoying to handle, and limits advanced search (like “vegan restaurants” or “wheelchair accessible places”). The goal is to introduce a clean category system (starting with osm.amenity.restaurant, cuisine.italian, etc.) so search becomes much more powerful while cleaning up the database at the same time. So instead of just searching “restaurant near me” you could do something like:

/search?q=restaurant+berlin&include=cuisine.italian&exclude=food.fast_food

Super excited for this! If you use Nominatim and have thoughts on what category filtering would be useful for your workflows… drop them here, it’ll genuinely be helpful.

I’ll keep posting updates and progress throughout the summer in this thread.

Cheers,
Agasta

8 Likes

Community Bonding Updates


Hey! First update of the summer :)

The main task for community bonding was getting a planet-scale Nominatim instance running for benchmarking. Marc set up the server - a genuinely heavy-spec machine - and I SSH’d in and kicked off the planet import. Believe me when I say I underestimated how long that would take… ~3 days for the full import to complete. Just logs for 3 days straight.

After that I set up the Nominatim UI with Caddy + SSL certificate. I’ve worked with VPS setups before but this was my first time handling a 2TB server solo - it was fun tbh, learned a lot. Once the planet was up I did some actual benchmarking..so yes we’re going with ltree[]. The native hierarchical operators (<@, ~) are just cleaner and more efficient indeed.

Big thanks to @mtmail for all the admin work on the VPS setup side…Next up - WP1: schema changes.

1 Like

Week 1 & 2 - First PR open, schema + Lua category generation


Okay, a lot happened these two weeks so buckle up a bit.

What’s in PR #4106:

Originally the plan was to land schema changes first (just adding the categories column), then do data population in a separate PR. But after the first review, sarah suggested we combine both into this PR - so that’s what we’re doing. When this merges, categories will actually be populated, not just an empty column. Places will look like:

| osm_id   | class   | type       | categories                   |
|----------|---------|------------|------------------------------|
| 94452880 | amenity | restaurant | osm.amenity.restaurant       |
| 94452862 | highway | bus_stop   | osm.highway.bus_stop         |

The duplicate row problem:

Nominatim currently creates separate rows for objects with multiple main tags. A place tagged both tourism=hotel and amenity=restaurant produces two rows with the same osm_id:

| osm_id      | class   | type       |
|-------------|---------|------------|
| 12716838771 | tourism | hotel      |
| 12716838771 | amenity | restaurant |

With categories, this should eventually become a single row:

| osm_id      | class   | type       | categories                               |
|-------------|---------|------------|------------------------------------------|
| 12716838771 | amenity | restaurant | osm.tourism.hotel, osm.amenity.restaurant|

I built a merge model (merge_row + row_precedes) in Lua for this. The current winner selection uses alphabetical order - amenity beats tourism - so amenity becomes the main class/type row while tourism gets preserved in extratags and both identities go into categories. The alphabetical ordering is a placeholder - it’ll be refined later once the API queries categories directly rather than class/type.

Now my initial approach generated 2 rows in Lua and then merged them. Sarah pointed out this is an unnecessary round-trip - much better to just generate 1 row from the start with both categories collected. That’s the rework happening next week.

We also found a real issue, A way tagged both boundary=administrative and waterway=river - the merge model picks boundary as winner (alphabetical), but later ranking.sql drops it because a boundary on a non-area way isn’t rankable. The river ends up in extratags with no searchable row - it just disappears. That’s why we’re planning to keep this PR open for a bit. We’ll make a few changes to the SQL files so they use the category columns instead of the existing class and type columns.

Next week i will rework the Lua file to generate single rows from the start (no round-trip), fix the sql files dependency so it checks osm.* categories instead of raw class/type, and get the failing tests to pass.

PR is open here: #4106

3 Likes

It’s a real server, not virtual. That’s why the RAID setup took me several attempts.

1 Like

Weeks 3 & 4 - Reworking the whole model

Honestly this was the most technically satisfying part of the project so far.

Mapping out the codebase

First thing this week, I did was go through the entire Nominatim codebase and document every place that touches class and type columns. The rule we landed on: anything touching place_classtype_* tables stays untouched for now - that’s a bigger refactor for later. Everything else that filters on class/type gets switched to query the categories column instead.


The N Tags → 1 Row rework

Last update I mentioned the round-trip problem - generating N rows then merging them back. This week I fixed it. The old model was straightforward but wasteful:

OSM Object: tourism=hotel, amenity=restaurant
    -> write_place(tourism, hotel)   -> INSERT #1
    -> write_place(amenity, restaurant) -> INSERT #2
    -> place: 2 rows

The new model does a single pass through all main tags, collects every category into an array, picks one winner for class/type (alphabetical for now), and inserts exactly one row:

OSM Object: tourism=hotel, amenity=restaurant
    -> categories = [osm.amenity.restaurant, osm.tourism.hotel]
    -> winner: amenity (alphabetical)
    -> INSERT #1: class=amenity, type=restaurant, categories={osm.amenity.restaurant, osm.tourism.hotel}
    -> place: 1 row

OLD: N tags → N rows. NEW: N tags → 1 row — all categories collected in a single Lua pass.


The ranking fix

This was the tricky one. Nominatim assigns address_rank and search_rank to every place by looking at class/type. With the old model each row had exactly one class/type so this was fine. With the new model a single row might have osm.boundary.administrative AND osm.place.city in its categories - and the ranking code didn’t know what to do with that.

Now the problem was: compute_place_rank was iterating all categories and picking the lowest address rank. osm.place.city (address_rank=16) was beating osm.boundary.administrative (address_rank=20), which was pulling rank_search down incorrectly and breaking tests.The fix ended up being simpler than I initially thought - just check categories @> 'osm.boundary.administrative' on the array directly instead of pre-scanning. I was doing a more complex pre-scan before which was… less efficient lol …

Looking back, it might sounds simple enough…but oh man, ranking.sql & placex triggers absolutely killed me :smiling_face_with_tear:… The ranking function now had to iterate over an array of categories instead of a single class/type , pick the “best” one, handle the boundary-vs-place tiebreak, and deal with ?<@ returning an ltree value instead of a boolean (which confused me for way too long). Then there were the triggers: dozens of class/type checks that all had to be converted to ltree operators, plus the boundary-linking logic that extracts a place type from the category array and feeds it back into the ranking function…That led to multiple conversations like “wait, why is this test still failing?” moments, and at least one “I put parent_address_level in the wrong branch” incident that took forever to track down.
It took a lot of back and forth, debugging, and experimenting before we finally got this working… you should just check our crazy pr.

rabbit gif

Switching SQL to ltree operators

All the remaining SQL filters that used to check class/type now use ltree containment operators. Before:

WHERE class = 'boundary' AND type = 'administrative'

After:

WHERE categories <@ 'osm.boundary.administrative' 

And for multi-value checks like waterways, ltree alternation:

WHERE categories ~ 'osm.waterway.river|stream|canal|drain|ditch'::lquery

Much cleaner tbh.


The lazy backfill insight

This one came from sarah and saved a lot of work… My original plan was to proactively backfill every existing row during migration. She pointed out we don’t actually need to - placex_update can lazily fill the category from class/type when it encounters a row that still has categories = NULL. The only rows that need upfront backfill are places and waterways with rank_address <= 25 - those get looked up by other rows during linking and need categories to be present. That’s roughly 20% of placex.

So the final strategy:

  • Lazy fill in placex_update for ~80% of rows
  • Proactive backfill only for rank_address < 26 rows during migration
2 Likes

placex triggers absolutely killed me

I have respect for anybody coding Pg/pgSQL. Sure it’s SQL with methods, but they can return multiple records, that another method needs to iterate over. The debug features, the typing, the loops, the testing (not unit tests), everything is different to what I’m used to.

1 Like

fr… can’t agree more :raising_hands:

Week 5 & 6 - Migration, panic, & some good news :)


These two weeks were mostly testing, patches and making sure we don’t break anything in prod.

Started with a dry run on planet inside a BEGIN/ROLLBACK so I couldn’t accidentally nuke anything - came out at ~63 minutes. After some optimization (disabling triggers, building indexes after backfill etc etc) got it down to ~47 minutes. We tried some different approaches as well, but this is the best we could achieve.

Ran the actual migration. 22M rows, ~42 minutes. Quick spot checks looked fine. Then I ran the geocoder tester…

7925 failed, 11107 passed, 3264 skipped in 16980.18s (4:43:00)

4 hours 43 minutes. I panicked. Genuinely. The confusing part was manual queries using curl were all <=0.7s - nothing like the 269s the logs were showing.

Now we have weekly meets every Monday… after some discussing, guess what? I was running the tester from my local machine pointing at the remote server. Every single request was going over the internet. I feel stupid thinking about it now lol.

SSH’d in, ran it properly and boom:

| Branch    | Time    |
|-----------|---------|
| master    | 30m 33s |
| PR branch | 29m 19s |

PR branch was actually faster. And Sarah’s fresh planet import with our code confirmed it - running about 10% faster than master, likely from the smaller placex table due to the new merge model.

This week was mostly cleaning up commit history. Pretty chill after the chaos of last week.

Oh and… PR #4106 got merged today!! :tada:

Schema, Lua category generation, N→1 row model, ranking fixes, SQL on ltree operators, lazy backfill - all landed. Big one ( literally). On to the search side changes next!

2 Likes

Hey everyone… We’re about halfway through summer, and I just published a diary post summarizing what we’ve built so far for Nominatim and what’s planned for the second half of the gsoc project.

I’d love to hear your thoughts if you have a chance to read it:

Thanks :blush:

2 Likes