# Reading MaxMind GeoIP2 databases from Go with geoip2-golang

> geoip2-golang wraps the MaxMind .mmdb reader with typed City, Country and ASN records, a HasData() check and a Names struct per language. It suits services that read the whole record, and it gives ground to a direct maxminddb Lookup when you read two fields.

**oschwald/geoip2-golang** — Unofficial MaxMind GeoIP2 Reader for Go

- Repository: https://github.com/oschwald/geoip2-golang
- Stars: 2,341 · Forks: 220
- Language: Go
- License: ISC
- Published: 2026-10-13 · Updated: 2026-10-13 · Language: en
- Canonical page: https://hysenlabs.com/projects/oschwald-geoip2-golang

## Typed schema structs on top of the maxminddb reader

geoip2-golang reads MaxMind GeoIP2 and GeoLite2 database files in the .mmdb format from Go, and its own one-line description calls it an Unofficial MaxMind GeoIP2 Reader for Go. Decoding is not done here: go.mod requires github.com/oschwald/maxminddb-golang/v2 v2.6.0, and every example opens a file with geoip2.Open and then reads it through db.City, db.Country or db.ASN. What sits on top is a set of structs shaped like the MaxMind schema, so a city record exposes City, Continent, Country, Location, Postal, RegisteredCountry, Subdivisions and Traits as typed fields rather than as map keys.

That shape is the reason to choose it. A Go service tagging log lines, screening signups or attaching a country to an order reaches record.Country.ISOCode and record.Location.TimeZone without writing decode structs of its own. The price is a hard coupling to MaxMind's file format and no bundled data, so every deployment supplies its own database file.

Install it as a module dependency, which brings the maxminddb reader along with it:

```bash
go get github.com/oschwald/geoip2-golang/v2
```

## Open a file, then tell a missing record from a failed lookup

Every result struct in v2 carries a HasData() method, and the examples use it to split two cases that are otherwise easy to confuse: a lookup error, handled with log.Fatal(err), and a record that carries nothing, reported as `No data found for this IP` before the function returns.

Coordinates are a second boundary of the same kind. Location.Latitude and Location.Longitude are pointers, and the examples dereference them only inside if record.Location.HasCoordinates(). Skip that check and a record with no coordinates takes the process down with a nil dereference rather than returning an error.

The first lookup follows the shape below. Addresses are built with netip.ParseAddr, and the README comment on that line says to use it when the string may be invalid and to check the error.

```go
db, err := geoip2.Open("GeoIP2-City.mmdb")
if err != nil {
	log.Fatal(err)
}
defer db.Close()
ip, err := netip.ParseAddr("81.2.69.142")
if err != nil {
	log.Fatal(err)
}
record, err := db.City(ip)
if err != nil {
	log.Fatal(err)
}
if !record.HasData() {
	fmt.Println("No data found for this IP")
	return
}
```

## A Names struct freezes the language set at compile time

v2 replaced the map[string]string that held localised names with a typed Names struct, and the README ties that change to performance. What it means in code is that a language becomes a field name instead of a lookup key: record.City.Names.BrazilianPortuguese, record.Country.Names.Russian and the subdivision's Names.English all appear in the opening example.

Printed output for 81.2.69.142 shows how little structure the caller has to remember. The Brazilian Portuguese city name comes back as Londres, the Russian country name as Великобритания, the ISO code as GB.

```go
fmt.Printf("Portuguese (BR) city name: %v\n", record.City.Names.BrazilianPortuguese)
if len(record.Subdivisions) > 0 {
	fmt.Printf("English subdivision name: %v\n", record.Subdivisions[0].Names.English)
}
fmt.Printf("Russian country name: %v\n", record.Country.Names.Russian)
fmt.Printf("ISO country code: %v\n", record.Country.ISOCode)
fmt.Printf("Time zone: %v\n", record.Location.TimeZone)
```

A fixed struct cuts both ways. Once the language set is frozen into fields, a record cannot be asked for an arbitrary language, and no way to iterate over every name a record holds is given anywhere in the README. If a database ships a translation this struct does not name, the typed field is the only route documented to it.

## Generated decoders, a pinned tool, and a reflection benchmark left behind

Model decoding is not done by hand. Decoding is not hand written either: the structs in models.go are decoded by code generated with maxminddb-gen from maxminddb-golang, and that step is described as invisible to library users while improving lookup performance.

The generator is pinned rather than merely suggested. It is declared in go.mod as a tool, the output file models_maxminddb.go is meant to be committed, and the instruction for changing models.go is to run go generate ./... and commit the result. CI checks that generated files are current, so a stale models_maxminddb.go stops the build instead of drifting quietly.

A detail that only a close reading of the top level file list catches sits beside that text: the tree still carries reflection_benchmark_test.go next to generated_test.go, so a reflection path and the generated path are both still measured here, even though the shipped decoder is the generated one. Two lint configurations sit in that same tree, .golangci.yml and .precious.toml, plus a .githooks directory and a .gitmodules file.

## The City example indexes Subdivisions[0] with no guard

HasData() answers whether a record exists. It says nothing about the slices inside that record, and the City database example shows the gap:

```go
fmt.Printf("Subdivision: %v\n", record.Subdivisions[0].Names.English)
```

No length check wraps that index, while the earlier example wraps the same access in if len(record.Subdivisions) > 0. A city record with no subdivision row panics on that line, and the panic comes from an index rather than from a returned error, so no caller error handling catches it. The same example also reads record.Postal.Code and record.Continent.Names.English without asking whether either is populated.

The Country example handles the equivalent case differently, guarding with record.RegisteredCountry.Names.English != "" before it prints. That is the pattern worth copying, since the reader reports what MaxMind published for that network and nothing more. On file handling, both examples defer db.Close() on the reader they open, and no reload path for a newer .mmdb file is given anywhere in the README.

## The documented alternative sits inside this README

For a service that reads two or three fields, the advice is to skip this wrapper and call maxminddb's Lookup directly with a result struct containing only the required fields. The README points at example_test.go in the maxminddb repository as the shape to copy, and calls that path superior for narrow reads.

Decode cost is what that choice turns on. A direct Lookup builds only the struct you declared. geoip2-golang decodes every field of the database record, generates decoders for the whole of models.go, and hands you db.City or db.Country with the schema already typed. That buys HasData(), HasCoordinates(), the Names structs and the per-database record types, and it spends allocations on fields you never read.

One scope note matters when those figures get quoted. The 56% fewer allocations and 34% less memory usage are measured against v1 of this library, not against a hand written struct on maxminddb. Choosing maxminddb directly also drops no dependency from the module graph, since maxminddb-golang/v2 v2.6.0 is already required here; it drops the wrapper.

## A Go 1.26 floor and four edits for the v1 upgrade

Requirements name Go 1.26 or later, and go.mod declares go 1.26.0. That is the first thing to check, because a service pinned to an older toolchain cannot take this package without moving first, and the v1 to v2 move has its own work on top.

Four changes define that move. Lookups take a netip.Addr instead of a net.IP, localised names moved from map[string]string to the typed Names struct, Network and IPAddress were added to result structs and are reached in the examples through record.Traits.Network and record.Traits.IPAddress, and HasData() arrived alongside them. MIGRATION.md holds the step by step guidance. Three non-test Go files sit at the top level, reader.go, models.go and models_maxminddb.go, so a typical upgrade edits two of them and the call sites around them.

go.mod also lists five indirect modules, go.yaml.in/yaml/v3 v3.0.5, golang.org/x/mod v0.39.0, golang.org/x/sync v0.22.0, golang.org/x/sys v0.47.0 and golang.org/x/tools v0.49.0, next to the test dependency on testify v1.12.1. The last push to the repository was on 2026-09-28.

## The mmdb files come from MaxMind, never from the module

No data ships with the module. GeoLite2 files are free from MaxMind with registration required, GeoIP2 files are a commercial purchase, and the same code path reads either, so that choice is made at download time rather than in code. Each example opens GeoIP2-City.mmdb, GeoIP2-Country.mmdb and GeoLite2-ASN.mmdb by relative path.

Record shapes differ by database, which matters when code moves between them. The Country example is the one that prints record.Country.IsInEuropeanUnion and the registered country, while the City example reaches for the postal code, the time zone and the subdivision slice. The ASN example is the only one that calls db.ASN, on the address 1.128.0.0, and the City example reads 128.101.101.101.

The ISC licence covers the Go code only. The database files arrive from MaxMind under their own terms, so bundling a database inside a distributed product is a separate question from importing this package, and nothing here resolves it.

## Conclusion

geoip2-golang earns a slot in Go services that want typed MaxMind records without writing decode structs, and it costs little next to the maxminddb-golang dependency the module already requires. Pass on it when each request needs two fields, because the README points at Lookup with a narrow struct for that case, and pass on it if your build cannot move to Go 1.26. Before adopting, confirm that every language your product prints exists as a field on the Names struct, and copy the Country example's guard rather than the City example's bare record.Subdivisions[0].

## FAQ

### How do I install geoip2-golang and which Go version does it require?

Add it with go get github.com/oschwald/geoip2-golang/v2. The requirements list Go 1.26 or later, and go.mod declares go 1.26.0.

### Does geoip2-golang include the GeoIP2 database files, or do I have to download them?

The module contains no data. GeoLite2 files are free from MaxMind with registration required, GeoIP2 files are a commercial purchase, and the examples open GeoIP2-City.mmdb, GeoIP2-Country.mmdb and GeoLite2-ASN.mmdb from a local path.

### What changed between v1 and v2 of geoip2-golang?

Lookups moved from net.IP to netip.Addr, map[string]string names were replaced by a typed Names struct, Network and IPAddress fields were added to result structs, and HasData() was introduced. MIGRATION.md holds the step by step guidance.

### Can I read a MaxMind database in Go without using the geoip2 wrapper?

Yes, the README says to call maxminddb's Lookup directly with a result struct that contains only the fields you need, and describes that as the better path when you only need several fields. example_test.go in the maxminddb repository is given as the example shape.

### How does geoip2-golang tell me whether an address has any data at all?

Call HasData() on the returned record before reading any field; the examples print No data found for this IP and return when it is false. For coordinates, ask Location.HasCoordinates() first, since Latitude and Longitude are pointers.

### Do I have to run the code generator when I change geoip2-golang models?

When models.go changes, run go generate ./... and commit the updated models_maxminddb.go. The generator is declared as a tool in go.mod, its version is pinned there, and CI checks that the generated files are current.

## Sources

- [Issues](https://github.com/oschwald/geoip2-golang/issues)
- [License: ISC](https://github.com/oschwald/geoip2-golang/blob/main/LICENSE)
- [oschwald/geoip2-golang on GitHub](https://github.com/oschwald/geoip2-golang)
- [README](https://github.com/oschwald/geoip2-golang/blob/main/README.md)
- [Releases](https://github.com/oschwald/geoip2-golang/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/oschwald-geoip2-golang
