maxminddb-golang: reading MaxMind DB files with bounded decoding
MaxMind DB Reader for Go
At a glance
- What is it?
- maxminddb-golang is a Go reader for the MaxMind DB binary format, and its second major version added netip support, integrity verification and, unusually, hard limits on how much a hostile database file can make the decoder allocate. That last part is what separates it from a plain format library, and it is the part to read before you point it at a file you did not create.
- Who is it for?
- Adopt maxminddb-golang if you need to read GeoLite2 or GeoIP2 databases in Go and you want the safety properties documented rather than assumed, because the bounded decoding and the Verify call are things you would otherwise have to design yourself. Do not adopt it if you want a batteries-included geolocation API, because the README points you at the separate geoip2-golang project for that, and note this is not an official MaxMind API.
- Can I use it commercially?
- Yes. ISC is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 4 days ago.
- What is it written in?
- Mainly Go, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
What this library is, and what it deliberately is not
maxminddb-golang is a Go reader for the MaxMind DB format, the on-disk format used by the GeoLite2 and GeoIP2 geolocation databases. The repository is a single module, github.com/oschwald/maxminddb-golang/v2, and it is written in Go with a go.mod directive of go 1.26.0.
Two boundaries are stated in the README itself, and both matter for how you choose it.
The first is that this is a format reader, not a geolocation API. Although it can be used to read GeoLite2 and GeoIP2 databases, the geoip2 project provides a higher-level API for doing so. So if you want a country and city for an IP address with sensible struct types, that is a different repository, and this one is the layer underneath it.
The second is that this is not an official MaxMind API. It is a third-party implementation of a published file format, maintained by the author of the other MaxMind reader libraries, with an ISC licence.
That framing sets expectations correctly. This is a low-level library that decodes a binary tree structure into Go values, and it is at its best when you care about exactly how that decoding behaves, because a large part of the version 2 release is about making that behaviour bounded and predictable.
Installation is a single go get, and the module path carries the major version suffix, so the import path is github.com/oschwald/maxminddb-golang/v2.
What changed in version 2, and what the netip switch means for your code
Version 2 is described as including significant improvements, and the list is worth reading because two items are breaking changes for existing users.
The modern API item is the one that touches every call site. It uses netip.Addr instead of net.IP for better performance. The netip type is a value type, comparable and cheap to copy, whereas net.IP is a slice, and using it as a map key or passing it around involves allocation and comparison of byte slices. For a service doing a lookup per request, that difference is not academic.
The migration guide for existing v1 code lives in MIGRATION.md in the repository, which is where you would look before upgrading.
The other version 2 additions are additive. Custom unmarshaling lets you implement CursorUnmarshaler for reflection-free custom decoding. Network iteration lets you iterate over all networks in a database with Networks() and NetworksWithin(). Enhanced performance comes from optimized data structures and decoding paths. Better error handling means more detailed error types and improved debugging. Integrity checks give you Reader.Verify() and metadata helpers such as Metadata.BuildTime().
The release history shows this was a substantial rewrite rather than a small increment. Version 2.8.0 was published on 2026-10-11, 2.7.0 on 2026-09-29 and 2.6.0 on 2026-09-07, and the last push to the repository was on 2026-10-19. Three releases in five weeks in the run-up to a minor version suggests the interface was still settling.
That cadence also tells you something about the compatibility promise. The v2 line has already had three minor releases, so the API is still moving, which is worth weighing if you are putting this in front of production traffic.
Verify before you decode, and then keep the file still
The untrusted database section is the most important thing in this README, and it is a short section with a lot of specific rules in it.
Call Reader.Verify once immediately after opening an untrusted database and before performing lookups or decoding records:
if err := db.Verify(); err != nil {
log.Fatal(err)
}The ordering matters. Verify is not something you call at the end as a sanity check, it is a precondition for using the reader at all.
Then there is the immutability rule, and it is stated in terms of what would break. Verification applies to the database contents at the time of the call, so you must keep those contents immutable for the Reader's lifetime. Do not modify a slice passed to OpenBytes, and do not rewrite or truncate a memory-mapped file in place. If you publish an updated database, you open and verify a new Reader.
That last sentence is a real design constraint, not pedantry. A memory-mapped file is a common way to load a large database, and it is tempting to update the file in place to avoid a reload. If you do that, the tree the Reader is walking can change underneath it, and the verification you did at open time no longer describes what is on disk.
The repository supports both access paths, which is why the rule mentions both. The top level has mmap_unix.go, mmap_windows.go and mmap_stub.go, so the same code memory-maps where it can and falls back where it cannot, and OpenBytes is the path for a database already in memory.
The verifier has its own files, verifier.go and verifier_test.go, and the README notes that Reader.Verify validates the complete data section and the original metadata graph, including unknown metadata fields, before those APIs are used with untrusted input.
Bounded decoding, which is the part that is rare to see documented
Most format readers assume the file is well formed. This one puts budgets on what a decoder can be made to do, and the numbers are specific.
Reflection decoding limits each operation to 32,768 declared container child slots, and map keys and values each consume one slot. Maps and slices reserve their children before allocation or traversal, which is the important part: the limit is applied before the allocation happens, so a malicious file cannot make the decoder allocate a billion-element slice and then fail.
There is a second, separate budget for payloads. Materialized string and byte payloads have an operation-wide bound, where every delivered payload byte draws from a shared 2 MiB allowance. Materialized map keys, and keys inspected by DecodePath, draw their full size from the same payload allowance.
The rules around when the limits apply are stated with care, and they are the sort of detail that decides whether the library is safe to point at a hostile file.
Decoding into any activates these limits even when the root is a scalar. A standalone scalar decoded into a directly typed destination, or into a named empty-interface type, remains unbudgeted because it cannot amplify. A non-empty DecodePath shares one set of limits between path navigation and the selected value. Skipping an unknown field still charges any inline containers, but does not follow pointer targets and does not charge payload that is not materialized.
And then the escape hatch is stated explicitly: custom unmarshalers and low-level cursor traversal control and must bound their own work. The library is telling you that the guarantee ends where its own code stops being in charge, which is more honest than a blanket claim.
The maxsize tag, and what it protects against
The struct tag is where the budgets meet your own types, and the example shows the shape.
type City struct {
Country struct {
ISOCode string `maxminddb:"iso_code"`
Names struct {
English string `maxminddb:"en"`
German string `maxminddb:"de"`
} `maxminddb:"names"`
} `maxminddb:"country"`
Subdivisions []struct {
ISOCode string `maxminddb:"iso_code"`
} `maxminddb:"subdivisions,maxsize:32"`
}The maxsize:N option rejects a matching MMDB map or array with more than N entries, or a matching string or byte value with more than N bytes. An MMDB array decoded into a byte slice is covered as well. The check happens before the matching field is allocated or mutated, so a rejected field leaves your struct untouched rather than half written.
The grammar follows encoding/json/v2, using the comma and colon convention, and the README gives the escaping rule for the awkward case: because a comma delimits options, a literal field name containing a comma has to be quoted with the same grammar, for example maxminddb:"'city,name'".
There is one more subtlety in the interaction with custom unmarshalers. For a supported custom field type, maxsize checks every size-bearing MMDB kind, map, array, string and bytes, before invoking the unmarshaler, because the encodings a callback accepts cannot be inferred from its Go type. That is a careful piece of design, since the alternative is that a custom unmarshaler silently bypasses the limit.
The result is that you can express a per-field size expectation in the type itself, which is the right place for it because the type is the thing that knows what a valid record looks like.
Generating decoders instead of reflecting
For application-owned structs, maxminddb-gen can generate an UnmarshalMaxMindDBCursor method that avoids reflection. The generator is versioned with the module and remains optional, and types with neither generated nor handwritten custom unmarshaling methods continue to use reflection.
The setup is the modern Go tool directive. The tool is added to the consuming module's go.mod:
tool github.com/oschwald/maxminddb-golang/v2/maxminddb-genand a generation directive goes in the package that owns the target types:
//go:generate go tool maxminddb-gen $GOFILEThe generator discovers the exported structs declared in the directive's source file. For models.go it writes models_maxminddb.go, and recognized build suffixes and source build constraints are preserved. Constrained inputs must match the generation environment, and multiple inputs share the intersection of their constraints. There is an -output flag to override the default, and the generated file is meant to be checked into source control after running go generate ./...
For new handwritten decoders, the interface is mmdbdata.CursorUnmarshaler. Cursor reads return an opaque successor positioned after the decoded value, which allows nested custom decoding to continue without rescanning it.
One deprecation to note. The older UnmarshalMaxMindDB(*mmdbdata.Decoder) error callback is deprecated. It remains supported throughout v2 but is planned for removal in v3, and there is an issue tracking that. When a type implements both callbacks, UnmarshalMaxMindDBCursor takes precedence.
So the migration path is gradual: write new decoders against the cursor interface, let the generator handle the mechanical ones, and treat the old callback as something to retire before v3.
Editorial conclusion
Adopt maxminddb-golang if you need to read GeoLite2 or GeoIP2 databases in Go and you want the safety properties documented rather than assumed, because the bounded decoding and the Verify call are things you would otherwise have to design yourself. Do not adopt it if you want a batteries-included geolocation API, because the README points you at the separate geoip2-golang project for that, and note this is not an official MaxMind API. Before you read a file you did not create, call Reader.Verify right after opening it, and keep the file immutable for the life of the Reader, since verification applies to the contents at the time of the call.
Frequently asked questions
How do I install and open a MaxMind DB file in Go?
Run go get github.com/oschwald/maxminddb-golang/v2, then call maxminddb.Open with the path to the .mmdb file, defer the Close, and use db.Lookup(ip).Decode(&record) with a netip.Addr parsed from a string.
Do I need to call Verify before reading an untrusted database?
Yes. Call Reader.Verify once immediately after opening an untrusted database and before performing lookups or decoding records. Verification applies to the contents at the time of the call, so keep them immutable for the Reader's lifetime and open and verify a new Reader when you publish an updated database.
What limits apply when decoding a MaxMind DB record?
Reflection decoding limits each operation to 32,768 declared container child slots, with map keys and values each consuming one slot, and materialized payloads draw from a shared 2 MiB allowance. Decoding into any activates the limits even for a scalar root, while custom unmarshalers and low-level cursor traversal must bound their own work.
What is the maxsize tag option in maxminddb-golang?
maxsize:N rejects a matching MMDB map or array with more than N entries, or a matching string or byte value with more than N bytes, and the check happens before the field is allocated or mutated. It is supported by both reflection decoding and maxminddb-gen, and uses the encoding/json/v2 comma and colon grammar.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/oschwald-maxminddb-golang)Was this useful?