kenyageo

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 10, 2026 License: MIT Imports: 15 Imported by: 1

README

kenya-geo

Go Reference CI

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
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.

Command-line tool

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

Source Used for Licence
KNBS 2019 Kenya Population and Housing Census, Volume I county population by sex, land area open data
geoBoundaries gbOpen KEN ADM1/ADM2/ADM3 ISO codes, centroids, bounding boxes, ward boundaries public domain
GeoNames postal codes post offices CC BY 4.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.

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.

Documentation

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	ErrNoBox             = errors.New("no P.O. Box or Private Bag found")
	ErrUnknownPostOffice = errors.New("unknown post office")
	ErrTownMismatch      = errors.New("town does not match the postal code")
)

Functions

This section is empty.

Types

type Address added in v0.2.0

type Address struct {
	Box        string      `json:"box,omitempty"`
	PrivateBag bool        `json:"private_bag"`
	PostalCode string      `json:"postal_code"`
	Town       string      `json:"town,omitempty"`
	PostOffice *PostOffice `json:"post_office"`
}

func (Address) String added in v0.2.0

func (a Address) String() string

type County

type County struct {
	Code           int    `json:"code"`
	Name           string `json:"name"`
	ISOCode        string `json:"iso_code"`
	FormerProvince string `json:"former_province"`
	Headquarters   string `json:"headquarters"`

	Population         int     `json:"population"`
	PopulationMale     int     `json:"population_male"`
	PopulationFemale   int     `json:"population_female"`
	PopulationIntersex int     `json:"population_intersex"`
	AreaKm2            float64 `json:"area_km2"`

	Centroid   LatLng     `json:"centroid"`
	BBox       [4]float64 `json:"bbox"`
	Neighbours []int      `json:"neighbours"`
}

func (County) Density

func (c County) Density() float64

type Data

type Data struct {
	// contains filtered or unexported fields
}

func Default

func Default() *Data

func Load

func Load(counties, wards, postOffices io.Reader) (*Data, error)

func (*Data) Constituencies

func (d *Data) Constituencies(countyCode int) []string

func (*Data) ConstituencyVoters added in v0.2.0

func (d *Data) ConstituencyVoters(name string) (VoterTotal, bool)

func (*Data) Counties

func (d *Data) Counties() []County

func (*Data) CountyAt added in v0.2.0

func (d *Data) CountyAt(lat, lng float64) (County, bool)
Example
c, ok := Default().CountyAt(-0.0917, 34.768)
fmt.Println(c.Code, c.Name, ok)
Output:
42 Kisumu County true

func (*Data) CountyBoundary added in v0.2.0

func (d *Data) CountyBoundary(code int) (Feature[County], bool)
Example
f, _ := Default().CountyBoundary(47)
b, _ := json.Marshal(f)
fmt.Println(f.Properties.Name, f.Geometry.Type, len(b) > 1000)
Output:
Nairobi County MultiPolygon true

func (*Data) CountyByCode

func (d *Data) CountyByCode(code int) (County, bool)

func (*Data) CountyByName

func (d *Data) CountyByName(name string) (County, bool)
Example
c, ok := Default().CountyByName("taita taveta")
fmt.Println(c.Code, c.Name, c.Headquarters, ok)
Output:
6 Taita-Taveta County Mwatate true

func (*Data) CountyVoters added in v0.2.0

func (d *Data) CountyVoters(code int) (VoterTotal, bool)
Example
v, _ := Default().CountyVoters(47)
fmt.Println(v.RegisteredVoters2022, v.Wards, v.Complete)
Output:
2415310 85 true

func (*Data) LoadCountyShapes added in v0.2.0

func (d *Data) LoadCountyShapes(r io.Reader) error

func (*Data) LoadWardShapes added in v0.2.0

func (d *Data) LoadWardShapes(r io.Reader) error

func (*Data) NationalVoters added in v0.2.0

func (d *Data) NationalVoters() VoterTotal

func (*Data) NeighbouringCounties added in v0.2.0

func (d *Data) NeighbouringCounties(code int) []County
Example
for _, c := range Default().NeighbouringCounties(47) {
	fmt.Println(c.Name)
}
Output:
Machakos County
Kiambu County
Kajiado County

func (*Data) ParseAddress added in v0.2.0

func (d *Data) ParseAddress(s string) (Address, error)
Example
a, err := Default().ParseAddress("P.O. Box 30100, 00100 GPO Nairobi")
fmt.Println(a.Box, a.PostalCode, a.PostOffice.Name, err)
fmt.Println(a)
Output:
30100 00100 Nairobi Gpo <nil>
P.O. Box 30100-00100 Nairobi

func (*Data) PostOffices

func (d *Data) PostOffices(postalCode string) []PostOffice

func (*Data) PostOfficesInCounty

func (d *Data) PostOfficesInCounty(countyCode int) []PostOffice

func (*Data) PostOfficesNear added in v0.2.0

func (d *Data) PostOfficesNear(lat, lng float64, n int) []NearbyPostOffice
Example
for _, p := range Default().PostOfficesNear(-0.2833, 36.0667, 2) {
	fmt.Printf("%s %s %.1f km\n", p.Code, p.Name, p.DistanceKm)
}
Output:
20100 Nakuru 2.7 km
20112 Lanet 8.2 km

func (*Data) Search

func (d *Data) Search(query string, limit int) []Match
Example
for _, m := range Default().Search("makadara", 3) {
	fmt.Printf("%s %s\n", m.Kind, m.Name)
}
Output:
constituency Makadara
post office Makadara
ward MJI WA KALE/MAKADARA

func (*Data) WardAt added in v0.2.0

func (d *Data) WardAt(lat, lng float64) (Ward, bool)
Example
w, ok := Default().WardAt(-1.2884, 36.8233)
fmt.Println(w.Code, w.Name, w.Constituency, w.CountyName, ok)
Output:
1439 NAIROBI CENTRAL Starehe Nairobi County true

func (*Data) WardBoundary added in v0.2.0

func (d *Data) WardBoundary(code string) (Feature[Ward], bool)

func (*Data) WardByCode

func (d *Data) WardByCode(code string) (Ward, bool)

func (*Data) WardsInConstituency

func (d *Data) WardsInConstituency(name string) []Ward

func (*Data) WardsInCounty

func (d *Data) WardsInCounty(countyCode int) []Ward

type Feature added in v0.2.0

type Feature[P any] struct {
	Type       string   `json:"type"`
	Geometry   Geometry `json:"geometry"`
	Properties P        `json:"properties"`
}

type Geometry added in v0.2.0

type Geometry struct {
	Type        string           `json:"type"`
	Coordinates [][][][2]float64 `json:"coordinates"`
}

type Kind

type Kind int
const (
	KindCounty Kind = iota
	KindConstituency
	KindWard
	KindPostOffice
)

func (Kind) String

func (k Kind) String() string

type LatLng

type LatLng struct {
	Lat float64 `json:"lat"`
	Lng float64 `json:"lng"`
}

type Match

type Match struct {
	Kind       Kind
	Name       string
	Code       string
	CountyCode int
	Score      float64
}

type NearbyPostOffice added in v0.2.0

type NearbyPostOffice struct {
	PostOffice
	DistanceKm float64 `json:"distance_km"`
}

type PostOffice

type PostOffice struct {
	Code         string  `json:"postal_code"`
	Name         string  `json:"name"`
	CountyCode   int     `json:"county_code"`
	CountySource string  `json:"county_source"`
	Location     *LatLng `json:"location"`
}

type VoterTotal added in v0.2.0

type VoterTotal struct {
	RegisteredVoters2022 int  `json:"registered_voters_2022"`
	Wards                int  `json:"wards"`
	Complete             bool `json:"complete"`
}

type Ward

type Ward struct {
	Code         string `json:"ward_code"`
	Name         string `json:"name"`
	Constituency string `json:"constituency_name"`
	CountyCode   int    `json:"county_code"`
	CountyName   string `json:"county_name"`

	RegisteredVoters2022 *int `json:"registered_voters_2022"`

	Centroid *LatLng `json:"centroid"`
}

Directories

Path Synopsis
cmd
kenyageo command
kenyageo-server command
internal
gendata command

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL