kenya-geo

Kenya's 47 counties, 290 constituencies, 1,450 wards and 890 post offices for Go, with 2019 census figures and map coordinates. The data is compiled into your binary, so there is nothing to download or configure at runtime.
Features
- Every county, constituency and ward, with IEBC ward codes and 2022 registered voters
- 2019 census population by sex, land area and density for each county
- ISO 3166-2 codes, former provinces and neighbouring counties
- Centroids and bounding boxes for counties, centroids for wards
- Find the ward, constituency and county for any coordinate, or just the county from county boundaries
- Ward and county boundaries as GeoJSON
- 890 post offices by postal code, each placed in a county, and the nearest offices to any point
- A postal address parser that checks the box, postal code and town agree
- Forgiving name lookups: case, apostrophes, dashes and a trailing "County" don't matter
- Fuzzy search across counties, constituencies, wards and post offices
- A JSON HTTP API and a ready-to-run server
- No dependencies outside the standard library; safe for concurrent use
Install
go get github.com/rmiyoyo/kenya-geo
Requires Go 1.24 or later.
Usage
package main
import (
"fmt"
kenyageo "github.com/rmiyoyo/kenya-geo"
)
func main() {
geo := kenyageo.Default()
c, ok := geo.CountyByName("murang'a")
if !ok {
return
}
fmt.Println(c.Code, c.Name, c.Headquarters)
for _, name := range geo.Constituencies(c.Code) {
fmt.Println(name, len(geo.WardsInConstituency(name)))
}
}
This prints 21 Murang'a County Murang'a, then each of Murang'a's constituencies with its number of wards.
Counties
| Call |
Returns |
geo.Counties() |
all 47 counties, ordered by county code |
geo.CountyByCode(47) |
Nairobi County |
geo.CountyByName("nairobi") |
the same county |
c.Population, c.Density() |
4397073, about 6247 people per km² for Nairobi |
c.ISOCode, c.FormerProvince |
"KE-30", "Nairobi" |
geo.NeighbouringCounties(47) |
Machakos, Kiambu and Kajiado |
Constituencies and wards
| Call |
Returns |
geo.Constituencies(32) |
Nakuru's 11 constituency names, alphabetical |
geo.WardsInCounty(32) |
Nakuru's wards as a []Ward |
geo.WardsInConstituency("Kibra") |
Kibra's wards |
geo.WardByCode("0040") |
WAA ward, in Matuga, Kwale |
w.Centroid |
a *LatLng, nil when unknown |
w.RegisteredVoters2022 |
an *int, nil when unknown |
Ward names repeat across the country (there are several CENTRAL and TOWNSHIP wards), so store ward codes, not names.
Registered voters
Voter totals add up the 2022 ward figures:
v, ok := geo.CountyVoters(47)
v, ok = geo.ConstituencyVoters("Kibra")
n := geo.NationalVoters()
For Nairobi, CountyVoters(47) gives {RegisteredVoters2022: 2415310, Wards: 85, Complete: true}. NationalVoters adds up all 1,450 wards.
Complete is false when a ward in the total has no figure. Only Della ward (Eldas, Wajir) is missing one, so the Eldas, Wajir and national totals are slightly low. The national total is 22,096,344, a little under IEBC's published 22,120,458, which also counts voters registered in the diaspora and in prisons.
Which ward is this point in?
w, ok := geo.WardAt(-1.2884, 36.8233)
fmt.Println(w.Name, w.Constituency, w.CountyName)
The arguments are latitude then longitude; this point is the KICC, and it prints NAIROBI CENTRAL Starehe Nairobi County. WardAt returns false for points outside Kenya, in the sea, or in one of the few areas without a ward boundary (see the accuracy notes). The boundaries are loaded the first time you call it, which takes about 30 ms; after that a lookup takes a few microseconds.
CountyAt does the same with county boundaries, so it also works where no ward boundary is known:
c, ok := geo.CountyAt(-0.0917, 34.768)
That point is in Kisumu County.
The two use different boundary sets, so within a few kilometres of a county line they can disagree: the ward's CountyName follows the IEBC ward list, while CountyAt follows the county outline.
Boundaries as GeoJSON
WardBoundary and CountyBoundary return the outline as a GeoJSON Feature, ready for Leaflet, Mapbox or QGIS:
f, ok := geo.WardBoundary("1439")
b, _ := json.Marshal(f)
b holds {"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[...]},"properties":{"ward_code":"1439",...}}.
The geometry is always a MultiPolygon with closed rings, outer rings counterclockwise and holes clockwise, as RFC 7946 asks. Properties is the Ward or County, so the JSON carries the same fields as the other lookups. Each call returns a fresh copy, so changing it does not affect later lookups. Wards without a known boundary return false.
Post offices
geo.PostOffices("00100") returns Nairobi GPO, and geo.PostOfficesInCounty(27) returns every post office in Uasin Gishu. A few postal codes are shared by two offices, so PostOffices returns a slice.
for _, p := range geo.PostOfficesNear(-0.2833, 36.0667, 3) {
fmt.Println(p.Code, p.Name, p.DistanceKm)
}
From central Nakuru this prints 20100 Nakuru 2.7, then Lanet and Kabatini. PostOfficesNear returns the closest post offices to a point, nearest first, with the straight-line distance in kilometres. Only the 553 offices with a known location are considered, so the nearest result can be a few kilometres off where an office without a location (such as Nairobi GPO) is closer.
Postal addresses
ParseAddress reads a Kenyan postal address and checks it against the post office list:
a, err := geo.ParseAddress("P.O. Box 30100, 00100 GPO Nairobi")
fmt.Println(a.Box, a.PostalCode, a.PostOffice.Name)
fmt.Println(a)
This prints 30100 00100 Nairobi Gpo, then the tidied address P.O. Box 30100-00100 Nairobi.
It accepts the usual ways of writing one: P.O. Box, P. O. BOX, PO Box, Box and Private Bag, with the box number and postal code joined by a dash, a comma or a space, in any case, with a name on the lines before and "Kenya" at the end. When the postal code is missing it is taken from the town, so Box 45, Kisumu becomes P.O. Box 45-40100 Kisumu.
The town is checked against the post office for that code. It matches when it is the office's name (one typo allowed) or the county the office is in, so P.O. Box 123-00101 Nairobi is fine even though 00101 is the Jamia office. String prints the address in the standard P.O. Box 123-00100 Nairobi form.
Errors can be checked with errors.Is:
| Error |
Meaning |
ErrNoBox |
no P.O. Box or Private Bag in the text, such as a street address, or a P.O. Box without a number |
ErrUnknownPostOffice |
the postal code doesn't exist, or there is no code and the town isn't a post office |
ErrTownMismatch |
the town doesn't belong to the postal code; the address is still returned, with the office for the code |
Search
for _, m := range geo.Search("nakru", 10) {
fmt.Println(m.Kind, m.Name, m.Score)
}
Results are ranked by how closely they match, and each carries the code of the county it belongs to. A limit of 0 or less returns every match.
Your own data
Load builds the same lookups from any readers, for example a newer copy of the files in data/:
geo, err := kenyageo.Load(countiesFile, wardsFile, postOfficesFile)
err = geo.LoadWardShapes(wardShapesFile)
err = geo.LoadCountyShapes(countyShapesFile)
WardAt and WardBoundary need the ward shapes; CountyAt and CountyBoundary need the county shapes.
go install github.com/rmiyoyo/kenya-geo/cmd/kenyageo@latest
| Command |
Prints |
kenyageo county nakuru |
neighbours, voters, constituencies and ward counts |
kenyageo postcode 30100 |
the post office for a postal code |
kenyageo search kibera |
fuzzy search results |
kenyageo at -1.2884 36.8233 |
the ward, constituency and county for a point |
kenyageo near -0.2833 36.0667 |
the five closest post offices |
kenyageo boundary 1439 |
a ward or county outline as GeoJSON |
kenyageo address "P.O. Box 123-00100 Nairobi" |
the parsed and checked postal address |
Add -json before the command to get JSON instead of text, for scripts and jq:
kenyageo -json county nakuru | jq '.constituencies[] | {name, voters: .voters.registered_voters_2022}'
kenyageo -json at -1.2884 36.8233 | jq -r .ward.name
county gives the county with its voter total and each constituency's; at gives {"ward": ..., "county": ...}, with ward null where no ward boundary is known; the other commands give the same JSON as the HTTP API. boundary always prints GeoJSON.
The exit status is 0 on success, 1 when nothing matches (or an address's town and postal code disagree, in which case the parsed address is still printed) and 2 for a bad command or argument.
HTTP API
The api package serves the same data as JSON, and kenyageo-server runs it:
go install github.com/rmiyoyo/kenya-geo/cmd/kenyageo-server@latest
kenyageo-server -addr :8080
It listens on $PORT when that is set (as Cloud Run, Fly.io and Heroku set it), and otherwise on :8080; -addr overrides both. On SIGTERM or Ctrl-C it stops taking new connections and finishes the requests in flight before exiting.
Docker
docker build -t kenyageo-server .
docker run -p 8080:8080 kenyageo-server
The image is about 18 MB: a static binary on the distroless base, running as a non-root user, with no shell or package manager. Its health check runs kenyageo-server -healthcheck, which asks the running server for a county and exits non-zero if it doesn't answer. CI builds the image and checks a running container on every pull request.
| Endpoint |
Returns |
GET /counties |
all 47 counties |
GET /counties/{county} |
one county, by code (32) or name (nakuru) |
GET /counties/{county}/constituencies |
constituency names |
GET /counties/{county}/neighbours |
counties that share a border with it |
GET /counties/{county}/boundary |
the county outline as a GeoJSON Feature |
GET /counties/{county}/voters |
registered voters, ward count and whether every ward has a figure |
GET /counties/{county}/wards |
wards in the county |
GET /counties/{county}/postoffices |
post offices in the county |
GET /constituencies/{name}/wards |
wards in a constituency, e.g. /constituencies/kibra/wards |
GET /constituencies/{name}/voters |
registered voters in a constituency |
GET /voters |
the national total |
GET /wards/{code} |
one ward |
GET /wards/{code}/boundary |
the ward outline as a GeoJSON Feature |
GET /postcodes/{code} |
post offices with that postal code |
GET /search?q=nakru&limit=10 |
fuzzy search results with kind, name, code, county_code and score |
GET /addresses?q=P.O.%20Box%20123-00100%20Nairobi |
the parsed address with its post office; 400 if there is no box, 404 for an unknown office, 422 if the town and code disagree |
GET /at?lat=-1.2884&lng=36.8233 |
the ward at that point |
GET /counties/at?lat=-0.0917&lng=34.768 |
the county at that point |
GET /postoffices/near?lat=-0.2833&lng=36.0667&limit=5 |
the closest post offices, nearest first, with distance_km |
GET /openapi.json |
the OpenAPI description of this API |
GET /openapi.json returns an OpenAPI 3.1 description of every endpoint, with the shape of each response. Load it into Swagger UI, Postman or a client generator such as oapi-codegen. The file lives at api/openapi.json; tests check that it lists exactly the routes the server has and that real responses match its schemas, so it can't quietly drift out of date.
Boundary responses use the application/geo+json content type. Errors come back as {"error": "..."} with status 400 for a bad query and 404 when nothing matches. Responses allow requests from any origin, so a web page can call the API directly.
To add the endpoints to your own server, mount the handler:
mux.Handle("/geo/", http.StripPrefix("/geo", api.New(kenyageo.Default())))
Data
| File |
Contents |
data/counties.json |
code, name, headquarters, ISO code, former province, population by sex, area, centroid, bounding box, neighbouring county codes |
data/wards.json |
ward code, name, constituency, county, 2022 registered voters, centroid |
data/postoffices.json |
postal code, name, county, how the county was decided, location |
data/wardshapes.json |
ward code and boundary polygons, as GeoJSON-style [lng, lat] rings |
data/countyshapes.json |
county code and boundary polygons, in the same format |
The same JSON files can be used from any language.
Accuracy notes
- Neighbouring counties are worked out from the county boundaries: two counties are neighbours when their outlines run within about a kilometre of each other for at least three boundary points. Borders across Lake Victoria count, so Siaya and Homa Bay are neighbours.
- Ward centroids and boundaries are known for 1,437 of the 1,450 wards. The other 13 have names too different from the boundary data to match safely, so
WardAt finds nothing in those areas.
- Boundaries are simplified, so a point within a few hundred metres of a ward line can land in the neighbouring ward. A handful of wards on county borders have centroids that fall just across the line.
- Post office locations are given only for the 553 offices matched to a real place.
county_source says how each office's county was decided: geonames, location, manual or neighbours.
- The source data had several errors, such as wards filed under the wrong constituency, mixed census years and a malformed ward code. They are corrected when the data is generated; see
internal/gendata/fixes.go.
Sources
Post office data is © GeoNames, used under CC BY 4.0.
Contributing
Issues and pull requests are welcome. To regenerate the data after changing a source or a fix, run the generator and then the tests. The generator downloads the raw sources, applies the fixes and writes data/*.json.
go run ./internal/gendata
go test ./...
Don't edit data/*.json by hand. Change the sources in data/source/ or the fixes in internal/gendata/ instead.
CI runs these on every pull request, so run them before pushing. gofmt -l . should print nothing.
gofmt -l .
go vet ./...
go run honnef.co/go/tools/cmd/staticcheck@2025.1.1 ./...
go test -race ./...
CI also runs each fuzz test for 15 seconds. They throw random input at normalize, ParseAddress and Search and check properties that must always hold: normalizing twice gives the same result, a parsed address prints back to the same address, search results are sorted and scored between 0 and 1. To fuzz one for longer:
go test -run '^$' -fuzz FuzzParseAddress -fuzztime 5m .
A failing input is saved under testdata/fuzz/ and from then on runs as an ordinary test case, so commit it with the fix.
Pull requests that touch internal/gendata/ or data/ also regenerate the data from scratch and fail if the committed data/*.json doesn't match. The geoBoundaries files are downloaded from the latest release, so that check can also fail when geoBoundaries changes upstream; regenerating and committing the result fixes it.