A reverse-geocoding microservice in Go. Given coordinates (latitude/longitude), it returns the nearest city plus its first-level region (admin1 — province/state), second-level region (admin2 — county/district) and country. Fully offline — no external API calls. Data comes from GeoNames.
- Single-point lookup:
GET /reverse - Batch lookup (up to 1000 points):
POST /reverse/batch - OpenAPI 3.0.3 spec as the single source of truth; Go types and the server interface are generated from it with
oapi-codegen. - Nearest-neighbour search via a self-contained 3D KD-tree on unit-sphere coordinates; reported distances use the haversine great-circle formula.
make download-data # fetch GeoNames files into ./data
make run # start the server on :8080
curl "localhost:8080/reverse?lat=34.05&lon=-118.24"{
"city": "Los Angeles",
"admin1": "California",
"admin1_code": "CA",
"admin2": "Los Angeles County",
"admin2_code": "037",
"country": "United States",
"country_code": "US",
"distance_km": 0.42,
"geoname_id": 5368361
}admin2 (county/district) is empty for cities that have no second-level code in GeoNames — e.g. Moscow:
curl "localhost:8080/reverse?lat=55.75&lon=37.61"
# {"admin1":"Moscow","admin1_code":"48","admin2":"","admin2_code":"",
# "city":"Moscow","country":"Russia","country_code":"RU",
# "distance_km":0.54,"geoname_id":524901}The OpenAPI 3.0.3 spec (openapi.yaml) is the source of truth and is not served at runtime; render it with any external viewer (e.g. npx @redocly/cli preview-docs openapi.yaml) or make validate-spec.
make download-data downloads three files into ./data:
| File | Purpose |
|---|---|
cities500.txt |
populated places (population ≥ 500) |
admin1CodesASCII.txt |
first-level region (province/state) names |
admin2Codes.txt |
second-level region (county/district) names |
countryInfo.txt |
country names |
To use a smaller/larger cities file, pass CITIES:
make download-data CITIES=cities15000 # ~34k cities, faster startup
# then run with the matching file name:
CITIES_FILE=cities15000.txt make runManual download (equivalent): grab cities500.zip (unzip it), admin1CodesASCII.txt, and countryInfo.txt from https://download.geonames.org/export/dump/ into ./data.
make run # uses ./data and cities500.txt
PORT=9000 CITIES_FILE=cities15000.txt make runThe image is a static binary on distroless. GeoNames data is not baked in — provide it at runtime in one of two ways:
Option A — mount a volume (recommended):
make docker-build
docker run --rm -p 8080:8080 \
-v "$(pwd)/data:/data" \
coordinates-resolver:latestOption B — bake data in a separate build step. Add a stage to the Dockerfile that downloads the files into /data (or COPY ./data /data) and keep GEONAMES_DIR=/data. This trades a larger image for a self-contained one; prefer it only when you cannot mount a volume.
curl "localhost:8080/reverse?lat=35.66&lon=139.70"{
"city": "Dōgenzaka",
"admin1": "Tokyo",
"admin1_code": "40",
"admin2": "Shibuya-ku",
"admin2_code": "1852582",
"country": "Japan",
"country_code": "JP",
"distance_km": 0.32,
"geoname_id": 10866710
}Invalid coordinates return 400:
curl -i "localhost:8080/reverse?lat=91&lon=10"
# HTTP/1.1 400 Bad Request
# {"code":"invalid_coordinates","message":"latitude out of range [-90, 90]"}curl -X POST localhost:8080/reverse/batch \
-H 'Content-Type: application/json' \
-d '{"points":[{"lat":34.05,"lon":-118.24},{"lat":91.0,"lon":-74.0}]}'[
{
"index": 0,
"lat": 34.05,
"lon": -118.24,
"match": {
"city": "Los Angeles",
"admin1": "California",
"admin1_code": "CA",
"admin2": "Los Angeles County",
"admin2_code": "037",
"country": "United States",
"country_code": "US",
"distance_km": 0.42,
"geoname_id": 5368361
},
"error": null
},
{
"index": 1,
"lat": 91.0,
"lon": -74.0,
"match": null,
"error": "latitude out of range [-90, 90]"
}
]Partial failures do not fail the whole request: invalid points get match: null + an error, valid points still resolve, and the HTTP status stays 200. Order is preserved (index matches the input position).
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
func main() {
// Build a 100-point request and post it.
type point struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
points := make([]point, 100)
for i := range points {
points[i] = point{Lat: 55.75 + float64(i)*0.001, Lon: 37.61 + float64(i)*0.001}
}
body, _ := json.Marshal(map[string]any{"points": points})
resp, err := http.Post("http://localhost:8080/reverse/batch",
"application/json", bytes.NewReader(body))
if err != nil {
panic(err)
}
defer resp.Body.Close()
var results []json.RawMessage
if err := json.NewDecoder(resp.Body).Decode(&results); err != nil {
panic(err)
}
fmt.Println(len(results), "results")
// -> 100 results
}| Endpoint | Description |
|---|---|
GET /health |
{"status":"ok","points_loaded":N} |
GET /ready |
200 once data is loaded and indexed, else 503 |
| Var | Default | Purpose |
|---|---|---|
PORT |
8080 |
HTTP listen port |
GEONAMES_DIR |
./data |
Directory with GeoNames files |
CITIES_FILE |
cities500.txt |
Cities file name |
MAX_BATCH_SIZE |
1000 |
Max points per batch request |
MAX_BODY_BYTES |
1048576 |
Max request body size (1 MB) |
BATCH_WORKERS |
GOMAXPROCS |
Worker-pool size for batch resolution |
LOG_LEVEL |
info |
info / debug |
make generate # regenerate types/server from the OpenAPI file
make verify-generated # CI guard: fails if generated code is stale
make validate-spec # lint the spec (redocly via npx; skipped if Node is absent)
make test # run all tests
make bench # run benchmarks- Returns the nearest city by distance, not the city/region by administrative boundary. There is no point-in-polygon test, so a coordinate just across a border may resolve to a nearby city in the neighbouring region/country.
- Accuracy and coverage depend on the chosen cities file:
cities500.txtis the most complete;cities15000.txtis smaller and faster but coarser. - City
admin1/admin2/countrynames are only as good as the GeoNames lookup tables; unknown codes resolve to empty strings. admin2(county/district) is not assigned to every place in GeoNames — many cities (including capitals like Moscow) have no admin2 code, soadmin2/admin2_codecome back empty. GeoNames provides no name lookup beyond admin2 (no admin3/admin4 files), so deeper levels are not exposed.