Skip to content
InternetData

What is an MMDB file? How to read, build and diff one

An MMDB file is a search tree over IP addresses with each record stored once. Read, build, verify and diff one with the free mmdb CLI, timed on IP Country.

Published 5 min read

On this page
  1. What's inside an MMDB file
  2. Install mmdb
  3. Read one address, or a million
  4. Build one from a CSV
  5. Verify it, then diff two days
  6. Lookups from code

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

bash
# 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 mmdb

The 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:

BuildSubnet changesRecords changedOf which, a new country
2026-10-2918,3281,563264
2026-10-3025,9105,561677
2026-10-3125,2632,905343
2026-11-0176,3284,571287
2026-11-02163,5497,961398
2026-11-0314,4911,359219
2026-11-0410,0741,331209

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:

bash
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"; fi

How 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:

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.

Written by

  • Uman Shahzad

    Founder & CEO

    IP intelligence data expert for over 8 years, former lead engineer at IPinfo, Uman has pioneered many systems and practices used by IP data companies to operate at scale, provide high-quality data and offer the best developer experiences.

Get new posts by email

Or follow along in a feed reader.

RSS feed

Put this data to work.

Everything these posts report on comes from the databases we license, and you can test them first.

00 Β· Evaluate

Test the real build first.

An evaluation license puts a full current build in your hands, with no sampling, so you can measure it against your own traffic before anyone talks about terms.

01 Β· License

Then license what you use.

Standard, for detection and decisions inside your own product and operations.
Redistribution, when the data ships onward inside what you sell, priced by scope rather than by seat.
Annual terms, a named contact, and monthly, quarterly or yearly payment.