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
- 2019 census urban and rural population and religious affiliation for each county and for Kenya
- 2019 census living conditions for each county and for Kenya: households, household size, electricity, piped water, internet use, mobile phone ownership and school attendance
- 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
- mapping-kenya, a website built on the package
- No dependencies outside the standard library; safe for concurrent use
See CHANGELOG.md for what changed in each release.
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 577 offices with a known location are considered, so the nearest result can be a few kilometres off where an office without a location is closer. From the KICC, the nearest are City Square and Nairobi GPO.
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. api/README.md is the full reference, with parameters and example responses for every endpoint. The same file is embedded in the package as api.Docs, and mapping-kenya renders it as its API page, so the website's docs always match the version of the API it serves.
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, 2019 census urban and rural population, living conditions and religious affiliation |
data/kenya.json |
urban and rural population, living conditions and religious affiliation for Kenya as a whole, from Data.National |
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 for 577 of the 890 offices. GeoNames places 553 of them precisely; the other 24 come from OpenStreetMap.
location_source says which: geonames, openstreetmap, or empty when the location is unknown. county_source says how each office's county was decided: geonames, location, manual or neighbours.
- An OpenStreetMap post office is used only when it matches exactly one office without a location: by postal code and name, by the same name, or by a name that is part of the office's name (
Hardy for Langata Hardy). A match is skipped when the point lies in a different county, when another office could claim it, or when a name-only match sits within 300 m of an office GeoNames already places. That leaves out, for example, Kamiti and Kenyatta University, which OpenStreetMap places just inside Nairobi while GeoNames files them under Kiambu.
- Living conditions are copied from the KNBS 2019 census tables, not estimated. Average household size is worked out from the Volume I population in households and number of households, because that table's own column has lost its decimal point (
39 for 3.9). Piped water counts water piped into the dwelling or to the yard or plot; public taps are left out. School attendance adds the 6–13 and 14–17 age groups; Kenya's figure is worked out from the county totals, since that table has no national row. The tests check the national figures against KNBS's published 50.4 % electricity, 22.6 % internet use and 47.3 % mobile phone ownership, and that the county households add up to the national 12,143,913.
- 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
| Source |
Used for |
Licence |
| KNBS 2019 Kenya Population and Housing Census, Volumes I, II and IV |
county population by sex, land area, households and living conditions |
CC BY |
| geoBoundaries gbOpen KEN ADM1/ADM2/ADM3 |
ISO codes, centroids, bounding boxes, ward boundaries |
public domain |
| GeoNames postal codes |
post offices |
CC BY 4.0 |
| OpenStreetMap via Overpass API |
post office locations GeoNames lacks |
ODbL 1.0 |
| IEBC county and ward lists |
codes, names, headquarters, wards, 2022 voters |
public record |
Post office data is © GeoNames, used under CC BY 4.0. Some post office locations are © OpenStreetMap contributors, available under the Open Database License.
data/source/osm-postoffices.json is a saved Overpass API result, so generating the data doesn't depend on Overpass being up. To refresh it, run this and then regenerate:
curl -sS https://overpass-api.de/api/interpreter --data-urlencode 'data=[out:json][timeout:180];area["ISO3166-1"="KE"][admin_level=2]->.ke;(node["amenity"="post_office"](area.ke);way["amenity"="post_office"](area.ke);relation["amenity"="post_office"](area.ke););out center tags;' -o data/source/osm-postoffices.json
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.
Releasing
Releases are Git tags such as v0.3.0. The Go module proxy and pkg.go.dev pick a tag up on their own; nothing is uploaded anywhere.
-
Make sure main has everything for the release and CI is green on it.
-
In a pull request, change the top of CHANGELOG.md from "not yet tagged" to the release date, and check "version" in api/openapi.json and the version named at the top of api/README.md match the new tag. Merge it.
-
Tag the merge commit on main and push the tag:
git checkout main
git pull
git tag -a v0.3.0 -m "v0.3.0"
git push origin v0.3.0
-
On GitHub, open Releases → Draft a new release, pick the tag, and paste that version's section of the changelog as the notes.
-
Ask the module proxy for the new version, so go get and pkg.go.dev see it straight away:
GOPROXY=https://proxy.golang.org go list -m github.com/rmiyoyo/kenya-geo@v0.3.0
-
Move projects that use the package onto the tag, for example mapping-kenya:
go get github.com/rmiyoyo/kenya-geo@v0.3.0
go mod tidy
Pick the number by what changed since the last tag: a new minor version (v0.4.0) for new features, a patch (v0.3.1) for fixes only. Until 1.0, a change that breaks existing code also goes in a minor version, and the changelog says so under Breaking.