Skip to content

fix: replace Nominatim reverse geocoding with pg-nearest-city - #7287

Merged
ramyaragupathy merged 2 commits into
hotosm:developfrom
munzzyy:feature/6905-replace-nominatim-with-pg-nearest-city
Aug 11, 2026
Merged

fix: replace Nominatim reverse geocoding with pg-nearest-city#7287
ramyaragupathy merged 2 commits into
hotosm:developfrom
munzzyy:feature/6905-replace-nominatim-with-pg-nearest-city

Conversation

@munzzyy

@munzzyy munzzyy commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

What type of PR is this? (check all applicable)

  • 🍕 Feature
  • 🐛 Bug Fix
  • 📝 Documentation
  • 🧑‍💻 Refactor
  • ✅ Test
  • 🤖 Build or CI
  • ❓ Other (please specify)

Related Issue

Fixes #6905

Describe this PR

Project.set_country_info reverse-geocodes a project's centroid by making a blocking requests.get call to the public Nominatim server, inside an async FastAPI request path. That's an external dependency for something that doesn't need one, it blocks the event loop while it waits on the network, and it adds load to a server the OSM community donates for free.

This swaps that call for pg-nearest-city, the local PostGIS-backed reverse geocoder HOTOSM already built for Field-TM. It resolves the nearest city (and its country) straight from the database with a spatial query, no outbound HTTP, no external service.

Changes:

  • Added pg-nearest-city>=2.0.0 to pyproject.toml (pulls in psycopg as a dependency; libpq is already present in the runtime image via postgresql-client, so nothing extra needed there).
  • requires-python narrowed from >=3.9,<=3.11 to >=3.10,<=3.11 since pg-nearest-city needs 3.10+. Doesn't change anything for actual deploys, the Docker image already runs 3.10.
  • set_country_info is now async and queries AsyncNearestCity with the project's centroid instead of hitting Nominatim. It reads location.country_name (the full country name from the package's dataset) rather than location.country (an ISO-style code like USA), specifically to avoid breaking existing country filters/charts, which expect long names. For the UK test fixture this still resolves to "United Kingdom", matching the old Nominatim-based expectation exactly.
  • Removed OSM_NOMINATIM_SERVER_URL from backend/config.py, example.env, and the three AWS ECS env hcl files, since it's no longer read anywhere.
  • 2.0.0 specifically added country-boundary matching (ST_Covers on the country polygon before finding the nearest city within it), which is what fixes the cross-border mismatch reported in this issue's thread (a Nepal AOI near the India border resolving to the wrong country). I checked the actual query pg-nearest-city runs and confirmed it scopes the nearest-city search to the country whose polygon covers the point, so it shouldn't reintroduce the "closest city regardless of border" bug.
  • Left the on-disk PostGIS data import out of scope here. AsyncNearestCity already handles this itself: on first connection it checks for its country/geocoding tables, and if they're missing it auto-imports the bundled compressed CSVs (guarded by a Postgres advisory lock so concurrent requests don't race each other). So the first project create/update after this ships will take longer (the package's docs say 30s-4m), everything after that is fast. No separate migration or deploy step needed for that part.

Alternative Approaches Considered

The issue thread raised using ISO country codes instead of country names for the whole country field, to sidestep the Nominatim-vs-pg-nearest-city naming mismatch entirely. That's a bigger change (DB column, API responses, frontend filters all currently key off the long name), so I left it out of this PR and just picked the field (country_name) that matches the existing naming convention as closely as pg-nearest-city allows. Happy to follow up separately if that's still wanted.

I also didn't add a data-backfill migration for existing projects' country values (the repo has precedent for this, see migrations/versions/7937dae319b5_.py from 2020). Existing projects already have country set from Nominatim and this PR only affects new lookups going forward, so I don't think a backfill is required for this fix, but flagging it in case reviewers disagree.

Review Guide

  • backend/models/postgis/project.py: Project.set_country_info is the core change.
  • backend/services/project_admin_service.py and the update method in project.py: both call sites now await the method.
  • tests/api/unit/models/postgis/test_project.py: replaced the Nominatim-mocking test with one that patches AsyncNearestCity and asserts country gets set from location.country_name, plus a new test for the lookup-failure path (make sure it doesn't blow up project creation, just leaves country unset).

Testing done

  • Verified against the actual pg_nearest_city package (installed it locally, read the real source, not just the README) to confirm the class names, query() signature, and the Location dataclass fields (country vs country_name) before writing the integration.
  • uv lock resolves cleanly with the new dependency.
  • flake8 and black --check pass on all touched files.
  • Confirmed backend/models/postgis/project.py and backend/services/project_admin_service.py import cleanly with all real dependencies installed, and that set_country_info is genuinely a coroutine now.
  • Ran the new unit test logic directly against the real Project.set_country_info method (mocking AsyncNearestCity the same way the test file does) and confirmed both the success and failure paths work.
  • Did not run the full pytest suite (tests/api/unit/, tests/api/integration/) locally. It needs the docker-compose Postgres/PostGIS stack, which isn't available in my environment. Deferring to CI for that part.

Environment variable changes

Removed OSM_NOMINATIM_SERVER_URL everywhere it was referenced - backend/config.py, example.env, and the three AWS terragrunt hcl files under scripts/aws/infra/*/purgeable/. It's no longer read anywhere in the backend. If there's an infra-side reason to keep the var defined regardless, happy to revert that part.

Project.set_country_info made a blocking requests.get call to the
public Nominatim server inside an async request path. Swap it for
pg-nearest-city, which resolves the nearest city and country from
the project's own PostGIS database instead of a remote HTTP call.

set_country_info is now async and reads location.country_name to
keep the existing long-form country naming convention. Removed
OSM_NOMINATIM_SERVER_URL since it's no longer read anywhere.

Fixes hotosm#6905
@munzzyy

munzzyy commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Looked into the backend test failure since it's blocking on a red X.

Both run attempts fail before any test actually executes. Attempt 1 was action_required (fork approval gate). Attempt 2, after approval, fails at the docker build/push step:

#30 ERROR: failed to push ghcr.io/hotosm/tasking-manager/backend:pr-7287: denied: installation not allowed to Write organization package

That's the test-img-build / build-image job trying to push the PR's test image to GHCR so the later run-tests jobs can pull it. Both pytest-unit / run-tests and pytest-integration / run-tests then fail right after with "Failed to pull the image" - they never got a test image to run against. None of the actual pytest suite ran.

The fork PR token isn't carrying package-write to the org registry, and it's separate from anything in the diff - #7282 (also a fork PR) hit the same approval gate on attempt 1. flake8/black pass locally on the touched files per the PR description, and SonarCloud's quality gate on this PR is green.

Happy to rebase/retrigger if there's a token or workflow change on the CI side, just flagging that a plain retry won't fix this one since it's failing at the same push step both times.

@prabinoid

Copy link
Copy Markdown
Collaborator

Hi @munzzyy , Thank you so much for this contribution! Much needed. Since we'll be using the pg-nearest-city, I think the country's name differs from Nominatim which are used on country filters and other places maybe. So, in order to make it consistent and follow the same naming convention, I think we might want to add a migration file to backfill/change the existing data's country info for which I have written a migration file too. If we merge this, then the migration file should be added too? Let me know your thoughts if you see any issues or blockers with this. Thanks again! 🙏

@munzzyy

munzzyy commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, and yes, I think that migration needs to go with this.

Nominatim's name:en and the Natural Earth list pg-nearest-city bundles disagree on enough countries that without it you'd end up with both spellings side by side in GET /countries/ and the project search filter, split by whether a project predates the change.

They also need to land together rather than in sequence. Migration first without the code and new projects keep writing the old names, so it drifts right back. Code first without the migration and the dropdown stays split until it catches up.

Your 4ee8b1efdebd sits on a1b2c3d4e5f6, which is already on develop, and my branch touches no migrations at all, so there's no conflict either way. I'm happy to cherry-pick your commit onto this branch so it goes in as one change, or if you'd rather keep it as your own PR, merge yours and I'll rebase on top. Either is fine by me, I just don't think the two should ship apart.

@prabinoid

Copy link
Copy Markdown
Collaborator

Sure @munzzyy , it would be great if you can cherry pick the commit onto this PR itself. We could test and ship the entire feature with this PR. Thanks a lot! 🙏

@munzzyy

munzzyy commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Done — cherry-picked it onto this branch as 5aea7cb, your authorship kept as-is. Checked the alembic chain after the pick: 4ee8b1efdebd sits on a1b2c3d4e5f6 and is the single head, so upgrades walk straight through it. Ready to test whenever you are.

@sonarqubecloud

Copy link
Copy Markdown

@prabinoid

Copy link
Copy Markdown
Collaborator

Hi @munzzyy , Thank you for this big contribution! I tested this pr as a branch including migration and I tried with a few dataset with countries United States mapped to United States of America and Ivory Cost to Côte d'Ivoire the countries seem to have mapped.. The filters and all seem to be working well too. Seems good. What do you think? Merging it whenever you're okay.

@prabinoid

Copy link
Copy Markdown
Collaborator

Looping in @manjitapandey too for any flags she notices.

@prabinoid
prabinoid requested a review from manjitapandey August 3, 2026 10:39
@ramyaragupathy
ramyaragupathy merged commit f41c8a8 into hotosm:develop Aug 11, 2026
7 of 9 checks passed
@spwoodcock

Copy link
Copy Markdown
Member

Really great work guys!! I just saw this 😄

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file scope: backend scope: infrastructure

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Replace usage of Nominatim with pg-nearest-city

5 participants