On this page
An MMDB file is the format most IP databases ship in for lookups on your own servers. MaxMind designed it for its GeoIP2 databases and published the specification, and there's a reader for every common language. Every InternetData database keyed by IP comes as an MMDB beside the CSV.
This post explains what's inside one, then reads, builds, verifies and diffs one with mmdb, our free, open-source command-line tool, which works with any MMDB file, whoever built it. The example is IP Country, an Open database (what it holds), as built on 2026-11-03 and 2026-11-04. Every command ran on 2026-11-04 with mmdb 1.9.0 on a Linux server, and each build and lookup time is the median of three runs.
What's inside an MMDB file
An MMDB file has three parts:
- A search tree: a binary tree over the bits of an IP address. A lookup starts at the root and takes the left or right branch for each bit, at most 32 steps for IPv4 and 128 for IPv6, until it reaches a pointer into the data section.
- The data section, which holds the records. Each distinct record is stored once, however many networks point to it.
- Metadata: the IP version, the size of each pointer in the tree, a description and the build time.
That second part is why a country file is small. IP Country's CSV has 2,497,649 rows and is 301 MB unpacked, but only 409 different records, one for each country and timezone pair. As an MMDB it's a tree of 1.44 million nodes, 8.2 MB, plus 31 KB of records. A database whose rows mostly differ, with a company name or coordinates on each range, saves much less, and its records are most of the file.
A reader maps the file into memory and walks the tree. There's no server to run, and nothing to load before the first lookup.
Install mmdb
# macOS, or Homebrew on Linux
brew trust internetdata/tap
brew tap internetdata/tap
brew install mmdb
# Debian / Ubuntu
echo "deb [trusted=yes] https://apt.internetdata.io/mmdb/ /" | sudo tee /etc/apt/sources.list.d/mmdb.list
sudo apt update && sudo apt install mmdb
# Windows
choco install mmdbThe README covers Docker and go install, and the releases page has binaries for 23 platforms.
Read one address, or a million
Download IP Country's MMDB from its page, free with an account, and look up an address:
$ mmdb read 8.8.8.8 ip_country_v1.mmdb
{"continent":"NA","continent_name":"North America","country":"US","country_name":"United States","currency_code":"USD","currency_name":"Dollar","currency_symbol":"$","flag_emoji":"πΊπΈ","flag_unicode":"U+1F1FA U+1F1F8","ip":"8.8.8.8","timezone":"America/Los_Angeles"}
-f picks the output: json (the default), json-pretty, csv or tsv. Addresses can come as arguments, from a file or on stdin, one per line. A CIDR or a range stands for every address in it, so mmdb read 8.8.8.0/24 ip_country_v1.mmdb prints 256 lines.
$ mmdb read -f csv ips.txt ip_country_v1.mmdb > countries.csv
A million random IPv4 addresses took 2.2 s as CSV, using 16 MB of memory, and 5.5 s as JSON. 862,240 had a record. The other 137,760 were all in reserved space (multicast, 240.0.0.0/4, 0.0.0.0/8, private, loopback and link-local ranges, carrier-grade NAT and the benchmarking range 198.18.0.0/15), which IP Country leaves out. CSV output leaves an address with no record out, and JSON output prints err: couldn't get data for <ip> to stderr for it.
Build one from a CSV
The CSV download holds the same data, a range and its fields on each row:
$ gunzip ip_country_v1.csv.gz
$ head -n 3 ip_country_v1.csv
start_ip,end_ip,continent,continent_name,country,country_name,timezone,currency_code,currency_name,currency_symbol,flag_emoji,flag_unicode
1.0.0.0,1.0.0.255,OC,Oceania,AU,Australia,Australia/Brisbane,AUD,Dollar,A$,π¦πΊ,U+1F1E6 U+1F1FA
1.0.1.0,1.0.3.255,AS,Asia,CN,China,Asia/Shanghai,CNY,Yuan Renminbi,CNΒ₯,π¨π³,U+1F1E8 U+1F1F3
$ mmdb import --range-multicol --no-network --in ip_country_v1.csv --out ip_country.mmdb
writing to ip_country.mmdb (2497649 entries)
--range-multicol says the first two columns are each range's start and end. Without it, import reads the first column as a CIDR or a range. Every other column becomes a field named from the header, and every value is stored as a string. The build took 20 s and about 260 MB of memory, and wrote 8.7 MB. A CSV of your own, such as an allowlist or a map of your internal networks, builds the same way.
The network field is the one decision to make. Without --no-network, import stores each row's range in its record, so a lookup also returns the range the address fell in ("network":"8.8.8.0-8.8.8.255"). That makes every row's record different: 2.5 million records where there were 409, and a tree of 4.65 million nodes where there were 1.44 million, since neighboring rows can no longer share a branch. The file grows from 8.7 MB to 235 MB, and the build takes 36 s and 3.2 GB of memory. Leave it out unless your lookups need the range.
--dry-run sizes a build without writing anything:
$ mmdb import --dry-run --range-multicol --no-network --in ip_country_v1.csv --out ip_country.mmdb
dry run for ip_country.mmdb; nothing was written
input: 2.5M rows, 4.3M networks, 409 distinct records
tree: at most 4.7M nodes and 31 kB of records
fits: in 24-, 28- and 32-bit records; 24-bit at most 28% full
memory: about 327 MiB at the peak, while writing
It still reads the whole input, 25 s here. The fits line is the size of each pointer in the tree, and since 1.7.0 import writes the smallest that fits. Here that's 24 bits, where 32-bit pointers (--size 32) make the same file 11.6 MB.
Verify it, then diff two days
$ mmdb verify ip_country.mmdb
valid
verify checks that the tree and the records are well formed, in under a tenth of a second here, and exits with status 1 on a bad file, so a deploy script can stop on it.
A day's build of a database can move thousands of networks, and diff shows which. Download the MMDB on two days and compare them:
$ mmdb diff -s -r ip_country_20261103.mmdb ip_country_20261104.mmdb
** SUBNETS **
2.26.166.0/24 -> 2.26.166.0/23
2.26.167.0/24 -> 2.26.166.0/23
2.132.192.0/19 -> 2.132.192.0/21
2.132.192.0/19 -> 2.132.200.0/21
...
10074 subnet(s) modified.
** RECORDS **
2.27.2.0/24
-map[continent:NA continent_name:North America country:US country_name:United States currency_code:USD currency_name:Dollar currency_symbol:$ flag_emoji:πΊπΈ flag_unicode:U+1F1FA U+1F1F8 timezone:America/New_York]
+map[continent:EU continent_name:Europe country:DE country_name:Germany currency_code:EUR currency_name:Euro currency_symbol:β¬ flag_emoji:π©πͺ flag_unicode:U+1F1E9 U+1F1EA timezone:Europe/Berlin]
...
1331 record(s) modified.
-s lists each change to a network's boundaries once, as old -> new, so two /24s merged into a /23 are two lines and a /19 split into pieces is a line for each piece. -r lists the networks that kept their boundaries but got a new record, the old one marked - and the new +. A network whose boundaries changed is listed only under -s, so its record isn't compared. From 2026-11-03 to 2026-11-04 there were 10,074 subnet changes, and 1,331 networks changed record. Of those, 1,122 changed only their timezone and 209 changed country: 145 IPv4 networks holding 63,253 addresses, and 64 IPv6 ones. The diff took 3.0 s, and each in the table below 3 to 4 s.
A week of IP Country, each build against the day before:
| Build | Subnet changes | Records changed | Of which, a new country |
|---|---|---|---|
| 2026-10-29 | 18,328 | 1,563 | 264 |
| 2026-10-30 | 25,910 | 5,561 | 677 |
| 2026-10-31 | 25,263 | 2,905 | 343 |
| 2026-11-01 | 76,328 | 4,571 | 287 |
| 2026-11-02 | 163,549 | 7,961 | 398 |
| 2026-11-03 | 14,491 | 1,359 | 219 |
| 2026-11-04 | 10,074 | 1,331 | 209 |
The last line diff prints is a count, so a script can hold back a new file when the count is well outside its usual range:
n=$(mmdb diff -r yesterday.mmdb today.mmdb | tail -n 1 | cut -d' ' -f1)
if [ "$n" -gt 10000 ]; then echo "IP Country: $n records changed"; fiHow often to take a new build at all is a separate question, which How often should you update an IP database? measures.
Lookups from code
Most languages have a reader: maxminddb-golang for Go, which mmdb itself uses, maxminddb for Python, node-maxmind for Node.js, libmaxminddb for C and MaxMind-DB-Reader-java for Java. In Python:
import maxminddb
with maxminddb.open_database("ip_country_v1.mmdb") as db:
with open("ips.txt") as f:
for line in f:
ip = line.strip()
record = db.get(ip)
if record is not None:
print(ip, record["country"])With maxminddb 3.2.0 and its C extension, that looked up the same million addresses in 4.9 s and found the same 862,240.
Our CLI and client libraries download the files. The database API gives a script a link to each day's build, and the formats page lists what each database comes in.
IP Country is one of our eleven free IP databases: sign up to download it as MMDB or CSV. To license the rest, see pricing or talk to us.


