Skip to content

About

Pure Rust MaxMind DB v2 (MMDB IPv4/IPv6) reader and writer with zero-copy borrowed decoding https://0x00f6.github.io/libmaxminddb-rs

Topics

Resources

Contributing

Stars

4 stars

Watchers

0 watching

Forks

Latest commit

Β 

History

41 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Ferris MaxMind

πŸ¦€ libmaxminddb-rs

crates.io API documentation MIT OR Apache-2.0 Rust 1.98.1+

185 workspace tests passing 97.05% line coverage

An independent Rust implementation of the MaxMind DB (MMDB) v2 format. It reads IPv4/IPv6 databases with borrowed decoding and writes deterministic MMDB files.


πŸ“‹ Table of Contents


πŸš€ Features

  • Full MMDB v2 Specification β€” Independent, pure-Rust implementation of the MaxMind DB v2 format for both IPv4 and IPv6 network lookups, without external C dependencies or wrapper overhead.
  • Zero-Copy Deserialization β€” Derive-based deserialization (#[derive(MmdbDecode)]) that borrows string (&'a str) and binary (&'a [u8]) slices directly from the database buffer without heap allocations.
  • Flexible Lookup APIs:
    • lookup_borrowed: Strongly typed, single-pass zero-copy decoding into user structs.
    • lookup_borrowed_opt: Borrowed decoding with an Option result for miss-heavy workloads.
    • lookup_borrowed_map: Borrowed decoding with a small callback result and None on a miss.
    • lookup_value: Generic dynamic inspection via borrowed ValueRef trees.
    • lookup_value_with_prefix: Longest-prefix matching returning both data and matched CIDR prefix length (subnet mask).
    • lookup: Owned deserialization through serde.
    • lookup_many: Order-preserving batch lookup with adaptive multithreading for bulk IP resolution.
    • lookup_exists: Check whether an address matches a record without decoding it.
  • Database Creation & Writer β€” High-performance in-memory trie builder producing standard-compliant MMDB v2 binary files with automatic payload deduplication and optimal pointer widths (24, 28, or 32 bits).
  • Multi-Source Dataset Merging β€” Built-in conflict resolution strategies for overlapping CIDR blocks (Replace, Append, AppendUnique, and recursive DeepMerge).
  • Ergonomic Derive Macros β€” #[derive(MmdbDecode, MmdbEncode, MmdbRecord)] with #[mmdb(network)] attribute support for one-object database insertion and zero-boilerplate struct mapping.
  • Flexible Storage Backends β€” Load databases from owned memory buffers (Reader::open), borrowed byte slices (Reader::from_bytes), or memory-mapped files (Reader::open_mmap).

⚑ Architecture & Optimizations

1. Zero-Copy & Allocation Avoidance

  • Lifetime-Bound Borrowing: lookup_borrowed projects wire bytes straight into derived structs without intermediate ValueRef trees; string and byte fields borrow the reader buffer directly, avoiding String and Vec<u8> allocations.
  • Compact Miss Path: Tree traversal represents misses without constructing a decoded record.
  • Arena-Based Trie Builder: The writer stores trie nodes contiguously in a flat Vec<TrieNode> arena using u32 index references, eliminating individual heap-allocated pointer indirections (Box).
  • Lower Writer Peak Memory: Replacing an existing prefix keeps the new shared Arc<Value> without cloning its payload, and finalization releases trie nodes before allocating the output buffer.

2. Search Tree & Traversal Optimizations

  • Specialized Unaligned Node Traversal: Unaligned 64-bit word loads (load!) read each node in a single instruction, with dedicated branch-free decoding loops tailored for 24-bit, 28-bit, and 32-bit pointer layouts.
  • Cache-Line Aligned Fast Tree: Opening a reader prepares a native-endian search tree aligned to 64-byte boundaries (AlignedNodes), packing 8 nodes per CPU cache line. Radix and byte-stride tables are built at the same time, so the first lookup does not construct an index. This trades open time and reader memory for lower steady-state lookup latency.
  • Radix Accelerator Tables: Root-level lookup tables index the first 8, 16, or 20 bits of IPv4 and IPv6 prefixes, reducing dependent pointer-chasing iterations.
  • CPU Cache Prefetching: Targeted _mm_prefetch instructions warm CPU cache lines ahead of sequential and multi-step tree traversals on supported architectures.

3. Vectorization & SIMD

  • Hardware-Accelerated ASCII Probing: Vectorized SIMD scanners (AVX2 / SSE2 on x86_64, NEON on AArch64) validate ASCII strings in bulk before UTF-8 decoding.
  • Out-of-Line Non-ASCII Fallback: Payloads with non-ASCII bytes divert to an out-of-line #[cold] validator, keeping standard library UTF-8 validation routines out of the hot instruction stream.

4. Concurrency & Throughput

  • Shared Reader: Prepared tree indexes are initialized once and shared across lookup threads.
  • Adaptive Batch Parallelism: lookup_many dynamically selects sequential execution for smaller workloads and chunked parallel processing via std::thread::scope for large batches ($\ge 4,096$ IPs).

5. Memory-Mapped File Access & Safety

  • Direct Kernel Paging (open_mmap): Memory-maps database files to leverage OS page caches, reducing startup overhead and memory footprint across shared processes.
  • Branch-Guarded Bounds Hardening: Unchecked slice accesses on hot paths are guarded by cheap, inlined range checks (in_range, cursor limits) and audited unsafe blocks with explicit safety invariants.

πŸ“¦ Installation & Cargo Features

Add libmaxminddb-rs to your Cargo.toml:

[dependencies]
libmaxminddb-rs = "0.5.0"

βš™οΈ Cargo Features

Feature Default Status Description
πŸ” reader Yes βœ… Search tree traversal, MMDB v2 decoding, mmap file support
✍️ writer Yes βœ… In-memory trie builder, binary serialization, deep merge
🧬 derive Yes βœ… Procedural derive macros: #[derive(MmdbDecode, MmdbEncode, MmdbRecord)]
⚑ simd Yes βœ… Vectorized SSE2/AVX2 (x86_64) and NEON (AArch64) ASCII validation with runtime detection

The fast tree is built into the reader and is always prepared during Reader::open, Reader::open_mmap, Reader::from_vec, or Reader::from_bytes for every valid MMDB record size. It is no longer a Cargo feature. Common 24/28/32-bit records use aligned u32 nodes and acceleration tables; 36–64-bit records use decoded u64 children (16 bytes per node). Preparation errors are returned when opening the database.


πŸš€ Quickstart Guide

πŸ” 1. Reading & Zero-Copy Lookups

This complete example builds a small database, borrows a typed record from it, and handles an absent address. &str fields refer to the reader's MMDB bytes; the record cannot outlive the reader.

use libmaxminddb_rs::{Error, MetadataBuilder, MmdbDecode, MmdbEncode, Reader, Writer};
use std::net::IpAddr;

#[derive(MmdbDecode, MmdbEncode)]
struct NetworkRecord<'a> {
    asn: u32,
    org: &'a str,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let metadata = MetadataBuilder::new().ip_version(4).build()?;
    let mut writer = Writer::with_metadata(metadata);
    writer.insert_encoded(
        "198.51.100.0/24".parse()?,
        &NetworkRecord { asn: 64512, org: "Example Network" },
    )?;
    let bytes = writer.finish()?;
    let reader = Reader::from_bytes(&bytes)?;

    let ip: IpAddr = "198.51.100.7".parse()?;
    let record: NetworkRecord<'_> = reader.lookup_borrowed(ip)?;
    assert_eq!(record.asn, 64512);
    assert_eq!(record.org, "Example Network");

    // Project a large decoded record to a small return value on a hit.
    let asn = reader.lookup_borrowed_map(ip, |r: NetworkRecord<'_>| r.asn)?;
    assert_eq!(asn, Some(64512));

    let missing: IpAddr = "203.0.113.7".parse()?;
    assert!(matches!(reader.lookup_borrowed::<NetworkRecord<'_>>(missing), Err(Error::NotFound)));
    Ok(())
}

For a zero-copy walkthrough that can be run with Cargo, see examples/zero_copy_lookup.rs.

Scan all stored network ranges

Reader::visit_records exposes a complete, checked network/value scan with the reader feature alone. Reader::visit_borrowed_records decodes directly into MmdbDecode types and avoids generic map/array containers for borrowed scalar fields. Deriving those types additionally requires derive; handwritten trait implementations do not.

Build a small synthetic FireHOL-style database, deep-merge records for three IPv4/IPv6 addresses, and scan its unique files/categories with assertions (requires reader, writer and derive):

use libmaxminddb_rs::{MergeStrategy, MetadataBuilder, MmdbDecode, Reader, Writer};
use serde_json::json;
use std::collections::BTreeSet;
use std::sync::Mutex;

#[derive(MmdbDecode)]
struct Record<'a> {
    files: Vec<&'a str>,
    categories: Vec<&'a str>,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let metadata = MetadataBuilder::new().ip_version(6).build()?;
    let mut writer = Writer::with_metadata(metadata).merge_strategy(MergeStrategy::DeepMerge);
    let networks = [
        "192.0.2.1/32".parse()?,
        "198.51.100.42/32".parse()?,
        "2001:db8::1/128".parse()?,
    ];

    for network in networks {
        writer.insert(
            network,
            &json!({"files": ["base.ipset"], "categories": ["other"]}),
        )?;
        writer.insert(
            network,
            &json!({"files": ["malware.ipset"], "categories": ["malware", "other"]}),
        )?;
    }

    let bytes = writer.finish()?;
    let reader = Reader::from_bytes(&bytes)?;
    let mut seen = BTreeSet::new();
    let mut files = BTreeSet::new();
    let mut categories = BTreeSet::new();

    reader.visit_borrowed_records(|network, record: Record<'_>| {
        assert_eq!(record.files, ["base.ipset", "malware.ipset"]);
        assert_eq!(record.categories, ["other", "malware", "other"]);
        assert!(seen.insert(network));
        files.extend(record.files);
        categories.extend(record.categories);
        Ok(())
    })?;

    assert_eq!(seen, BTreeSet::from(networks));
    assert_eq!(files, BTreeSet::from(["base.ipset", "malware.ipset"]));
    assert_eq!(categories, BTreeSet::from(["malware", "other"]));
    // Parallel callbacks use Fn + Sync; borrowed fields can be retained.
    // This tiny demo uses the adaptive sequential fallback. On larger trees,
    // decoding runs concurrently and the callback order is unspecified.
    let parallel_files = Mutex::new(BTreeSet::new());
    reader.visit_borrowed_records_parallel(|_network, record: Record<'_>| {
        parallel_files.lock().unwrap().extend(record.files);
        Ok(())
    })?;
    assert_eq!(parallel_files.into_inner().unwrap(), files);
    println!("Files: {files:?}");
    println!("Categories: {categories:?}");
    Ok(())
}

Run the complete example with cargo run --example unique_fields_scan_records. It builds and scans its own in-memory database. Strings borrow the reader; containers and database construction allocate.

visit_borrowed_records_parallel uses scoped threads from 24,000 tree nodes; smaller trees or one CPU use a sequential scan. Set a worker limit and bypass the size heuristic with visit_borrowed_records_parallel_with_workers(NonZeroUsize, visitor). Callbacks require Fn + Sync and run in unspecified order. Errors cancel cooperatively: in-flight callbacks may finish before all workers are joined.

Records stream without collection; strings/bytes stay borrowed, and T needs neither Send nor Sync. Setup allocates, but borrowed scalar decoding has no per-record allocations. Prefer worker-local accumulation for throughput.

Two synthetic IPv4/IPv6 benchmark passes on an 8-CPU Xeon VM measured 3.26–3.41x faster scans at 100k ranges and 4.14–4.26x at 1M ranges, including thread setup. Gains depend on the schema and callback. See scan details for requirements, safety limits, full results and the small sequential generic-IPv6 tradeoff.

Lookup API Reference

The GeoLite2 examples use shared record types and a streaming download helper from examples/common/mod.rs. Each example downloads its database once into target/database and reuses it on later runs.

GeoLite2 Country β€” full example

Source: examples/geolite2_country.rs
Run with: cargo run --example geolite2_country

use anyhow::Result;
mod common;
use common::download_file;
use libmaxminddb_rs::{MmdbDecode, Reader};
use std::net::IpAddr;
use std::path::PathBuf;
use tokio::fs;

#[derive(Debug, MmdbDecode)]
pub struct GeoLite2Country<'a> {
    pub continent: Option<Continent<'a>>,
    pub country: Option<Country<'a>>,
    pub location: Option<Location<'a>>,
    pub registered_country: Option<Country<'a>>,
}

#[derive(Debug, MmdbDecode)]
pub struct Continent<'a> {
    pub code: Option<&'a str>,
    pub geoname_id: Option<u64>,
    pub names: Option<Names<'a>>,
}

#[derive(Debug, MmdbDecode)]
pub struct Country<'a> {
    pub geoname_id: Option<u64>,
    pub iso_code: Option<&'a str>,
    pub names: Option<Names<'a>>,
}

#[derive(Debug, MmdbDecode)]
pub struct Names<'a> {
    pub de: Option<&'a str>,
    pub en: Option<&'a str>,
    pub es: Option<&'a str>,
    pub fr: Option<&'a str>,
    pub ja: Option<&'a str>,
    #[serde(rename = "pt-BR")]
    pub pt_br: Option<&'a str>,
    pub ru: Option<&'a str>,
    #[serde(rename = "zh-CN")]
    pub zh_cn: Option<&'a str>,
}

#[derive(Debug, MmdbDecode)]
pub struct Location<'a> {
    pub accuracy_radius: Option<u64>,
    pub latitude: Option<f64>,
    pub longitude: Option<f64>,
    pub time_zone: Option<&'a str>,
}

#[tokio::main]
async fn main() -> Result<()> {
    // Keep the downloaded MMDB in this example package's target directory.
    let database_file = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("target")
        .join("database")
        .join("GeoLite2-Country.mmdb");

    // download_file creates target/database automatically when it is missing.
    // Reuse an existing database file to avoid downloading it on every run.
    if !fs::try_exists(&database_file).await? {
        download_file(
            "https://github.com/P3TERX/GeoLite.mmdb/releases/download/2026.09.28/GeoLite2-Country.mmdb",
            &database_file,
        )
        .await?;
    }

    // Open the downloaded MaxMind DB with the library reader.
    let reader = Reader::open(&database_file)?;

    // Parse the query address once before looking it up.
    let ip: IpAddr = "8.8.8.8".parse()?;

    // Borrowed strings remain tied to the reader's MMDB bytes where possible.
    let record: GeoLite2Country<'_> = reader.lookup_borrowed(ip)?;
    println!("{record:#?}");

    Ok(())
}
GeoLite2 City β€” full example

Source: examples/geolite2_city.rs
Run with: cargo run --example geolite2_city

use anyhow::Result;
mod common;
use common::download_file;
use libmaxminddb_rs::{MmdbDecode, Reader};
use std::net::IpAddr;
use std::path::PathBuf;
use tokio::fs;

#[derive(Debug, MmdbDecode)]
pub struct GeoLite2City<'a> {
    pub continent: Option<Continent<'a>>,
    pub country: Option<Country<'a>>,
    pub city: Option<City<'a>>,
    pub location: Option<Location<'a>>,
    pub postal: Option<Postal<'a>>,
    pub registered_country: Option<Country<'a>>,
    pub subdivisions: Option<Vec<Subdivision<'a>>>,
    pub traits: Option<Traits>,
}

#[derive(Debug, MmdbDecode)]
pub struct Continent<'a> {
    pub code: Option<&'a str>,
    pub geoname_id: Option<u64>,
    pub names: Option<Names<'a>>,
}

#[derive(Debug, MmdbDecode)]
pub struct Country<'a> {
    pub geoname_id: Option<u64>,
    pub iso_code: Option<&'a str>,
    pub names: Option<Names<'a>>,
}

#[derive(Debug, MmdbDecode)]
pub struct City<'a> {
    pub geoname_id: Option<u64>,
    pub names: Option<Names<'a>>,
}

#[derive(Debug, MmdbDecode)]
pub struct Names<'a> {
    pub de: Option<&'a str>,
    pub en: Option<&'a str>,
    pub es: Option<&'a str>,
    pub fr: Option<&'a str>,
    pub ja: Option<&'a str>,
    #[serde(rename = "pt-BR")]
    pub pt_br: Option<&'a str>,
    pub ru: Option<&'a str>,
    #[serde(rename = "zh-CN")]
    pub zh_cn: Option<&'a str>,
}

#[derive(Debug, MmdbDecode)]
pub struct Location<'a> {
    pub accuracy_radius: Option<u64>,
    pub latitude: Option<f64>,
    pub longitude: Option<f64>,
    pub time_zone: Option<&'a str>,
}

#[derive(Debug, MmdbDecode)]
pub struct Postal<'a> {
    pub code: Option<&'a str>,
}

#[derive(Debug, MmdbDecode)]
pub struct Subdivision<'a> {
    pub geoname_id: Option<u64>,
    pub iso_code: Option<&'a str>,
    pub names: Option<Names<'a>>,
}

#[derive(Debug, MmdbDecode)]
pub struct Traits {
    pub is_anonymous_proxy: Option<bool>,
    pub is_satellite_provider: Option<bool>,
    pub is_anycast: Option<bool>,
}

#[tokio::main]
async fn main() -> Result<()> {
    // Store the GeoLite2 City database under this package's target directory.
    let database_file = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("target")
        .join("database")
        .join("GeoLite2-City.mmdb");

    // The shared downloader creates target/database and streams the file to disk.
    // Keep an existing database so later runs do not download it again.
    if !fs::try_exists(&database_file).await? {
        download_file(
            "https://github.com/P3TERX/GeoLite.mmdb/releases/download/2026.09.28/GeoLite2-City.mmdb",
            &database_file,
        )
        .await?;
    }

    // Open the MMDB reader, parse the address, and borrow record strings.
    let reader = Reader::open(&database_file)?;
    let ip: IpAddr = "8.8.8.8".parse()?;
    let record: GeoLite2City<'_> = reader.lookup_borrowed(ip)?;

    println!("{record:#?}");
    Ok(())
}
GeoLite2 ASN β€” full example

Source: examples/geolite2_asn.rs
Run with: cargo run --example geolite2_asn

use anyhow::Result;
mod common;
use common::download_file;
use libmaxminddb_rs::{MmdbDecode, Reader};
use std::net::IpAddr;
use std::path::PathBuf;
use tokio::fs;

#[derive(Debug, MmdbDecode)]
pub struct GeoLite2Asn<'a> {
    pub autonomous_system_number: u64,
    pub autonomous_system_organization: &'a str,
}

#[tokio::main]
async fn main() -> Result<()> {
    // Use a database-specific filename so another GeoLite2 database cannot be reused.
    let database_file = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("target")
        .join("database")
        .join("GeoLite2-ASN.mmdb");

    // Download once; the shared helper creates target/database and streams to disk.
    if !fs::try_exists(&database_file).await? {
        download_file(
            "https://github.com/P3TERX/GeoLite.mmdb/releases/download/2026.09.28/GeoLite2-ASN.mmdb",
            &database_file,
        )
        .await?;
    }

    let reader = Reader::open(&database_file)?;
    let ip: IpAddr = "8.8.8.8".parse()?;

    // The organization string borrows from the MMDB data for this lookup.
    let record: GeoLite2Asn<'_> = reader.lookup_borrowed(ip)?;
    println!("{record:#?}");
    Ok(())
}

✍️ 2. Database Creation & Serialization

The writer constructs standard-compliant MMDB v2 binary databases in memory with automatic payload deduplication and optimal pointer sizing (24, 28, or 32 bits).

The #[derive(MmdbEncode, MmdbRecord)] derive macros provide a seamless one-object insertion API: the #[mmdb(network)] attribute marks the CIDR subnet key and automatically omits it from the serialized payload data.

(Run this complete example with cargo run --example custom_record)

use std::error::Error;
use libmaxminddb_rs::{IpNetwork, MetadataBuilder, MmdbEncode, MmdbRecord, Writer};

/// Custom record deriving both MmdbEncode and MmdbRecord.
/// The #[mmdb(network)] field designates the subnet without storing it in the payload.
#[derive(Debug, MmdbEncode, MmdbRecord)]
struct SecurityEntry<'a> {
    #[mmdb(network)]
    network: IpNetwork,
    country: &'a str,
    threat_score: u32,
    is_tor_exit: bool,
}

fn main() -> Result<(), Box<dyn Error>> {
    // 1. Configure database metadata (database type, IP version, languages, descriptions)
    let metadata = MetadataBuilder::new()
        .database_type("Security-Intelligence")
        .ip_version(4)
        .description("en", "IP Threat Intelligence Feed")
        .build()?;

    let mut writer = Writer::with_metadata(metadata);

    // 2. Insert records using the ergonomic one-object insertion API
    writer.insert_entry(&SecurityEntry {
        network: "203.0.113.0/24".parse()?,
        country: "FR",
        threat_score: 85,
        is_tor_exit: false,
    })?;

    writer.insert_entry(&SecurityEntry {
        network: "198.51.100.128/25".parse()?,
        country: "US",
        threat_score: 95,
        is_tor_exit: true,
    })?;

    // 3. Finalize into a contiguous in-memory byte buffer
    let mmdb_bytes: Vec<u8> = writer.finish()?;
    println!("Database serialized successfully: {} bytes", mmdb_bytes.len());

    // Alternatively, serialize directly to a file on disk:
    // writer.write_to_file("threat-intelligence.mmdb")?;

    Ok(())
}

πŸ”€ 3. Merging Databases (Deep Merge)

A common challenge in network telemetry is combining multi-source intelligence on identical or overlapping subnets β€” such as augmenting a baseline IP geolocation database with an external real-time threat intelligence feed.

By default, MMDB writers use MergeStrategy::Replace, where inserting an existing subnet completely wipes out the previous record, destroying any previously associated geographic coordinates or ISP metadata.

Configuring the writer with MergeStrategy::DeepMerge enables recursive hierarchical merging:

  • Nested Maps: Recursively merged key-by-key. Keys present only in the base record are preserved (country, city, details.timezone). New keys are seamlessly added (details.datacenter, security). Conflicting nested scalar values are updated with the latest value (details.accuracy_radius 50 $\to$ 10).
  • Arrays: Concatenated in order (["residential", "broadband"] $+$ ["vpn_exit_node"]).
  • Scalars: Conflicting scalar values are replaced by the newest record.

(Run this complete example with cargo run --example deep_merge)

use std::error::Error;
use std::net::IpAddr;
use libmaxminddb_rs::{MergeStrategy, MergeStrategy, MetadataBuilder, Reader, Writer};

fn main() -> Result<(), Box<dyn Error>> {
    // 1. Initialize Writer configured with recursive DeepMerge strategy
    let metadata = MetadataBuilder::new()
        .database_type("Enriched-GeoIP-Threat")
        .ip_version(4)
        .build()?;

    let mut writer = Writer::with_metadata(metadata)
        .merge_strategy(MergeStrategy::DeepMerge);

    let target_subnet = "203.0.113.0/24".parse()?;

    // 2. Base geolocation feed: general geographic coordinates and ISP tags
    let base_geo_record = serde_json::json!({
        "country": "FR",
        "city": "Paris",
        "details": {
            "timezone": "Europe/Paris",
            "accuracy_radius": 50
        },
        "network_tags": ["residential", "broadband"]
    });
    writer.insert(target_subnet, &base_geo_record)?;

    // 3. Threat intelligence feed: enriches the same subnet with security telemetry
    let threat_intel_record = serde_json::json!({
        "details": {
            "accuracy_radius": 10,       // overrides conflicting scalar with higher precision
            "datacenter": "PAR-01"       // adds a new nested key into "details"
        },
        "network_tags": ["vpn_exit_node"], // appends new element to the existing array
        "security": {                    // adds an entirely new top-level nested map
            "is_proxy": true,
            "threat_score": 85
        }
    });
    writer.insert(target_subnet, &threat_intel_record)?;

    // 4. Finalize database and query with zero-copy Reader
    let mmdb_bytes = writer.finish()?;
    let reader = Reader::from_bytes(&mmdb_bytes)?;

    let target_ip: IpAddr = "203.0.113.42".parse()?;
    let (merged_value, prefix_len) = reader.lookup_value_with_prefix(target_ip)?;

    println!("Matched subnet prefix: /{prefix_len}");
    println!("{}", serde_json::to_string_pretty(&merged_value.to_json())?);

    Ok(())
}

πŸ” Verified Output

Executing the lookup yields the fully unified document combining both datasets:

{
  "city": "Paris",
  "country": "FR",
  "details": {
    "accuracy_radius": 10,
    "datacenter": "PAR-01",
    "timezone": "Europe/Paris"
  },
  "network_tags": [
    "residential",
    "broadband",
    "vpn_exit_node"
  ],
  "security": {
    "is_proxy": true,
    "threat_score": 85
  }
}

πŸ“‹ Merge Strategy Comparison

Strategy Behavior on Subnet Collision Real-World Use Case
Replace (default) Overwrites the entire subnet record with the new value Replacing expired telemetry or full database rewrites
Append Concatenates arrays; replaces conflicting non-array values Appending audit logs, incident tickets, or historical IPs
AppendUnique Appends new array items only if not already present; replaces non-arrays Merging distinct category labels or tag sets without duplicates
DeepMerge Recursively traverses maps, concatenates arrays, and replaces conflicting scalar leaves Multi-source enrichment (e.g. GeoIP + ASN + Threat Intelligence)

πŸ”„ 4. Hot In-Memory Database Updates

Editor::from_reader shares an existing reader and stages updates or deletions. finish() returns rebuilt MMDB bytes; write_to_file() writes them to disk. With reader and writer enabled, ReloadableReader::commit publishes the rebuilt database while other threads continue reading.

  • ⚑ Fast reads: ArcSwap avoids a global Mutex/RwLock on the lookup path. Hold one load() guard per query or batch; borrowed fields cannot outlive it.
  • 🧩 Typed updates: pass an owned Value or a reference to a custom MmdbEncode struct, plus an explicit MergeStrategy.
  • πŸ”€ Merge behavior: Replace replaces values; DeepMerge merges maps, appends arrays and replaces scalars. Append/AppendUnique append array items. Updates run in order on exact prefixes exported from the MMDB tree, without merging inherited parent values or more-specific children.
  • πŸ›‘οΈ Safe publication: commit returns false for stale editors; retry with a fresh snapshot. Rebuild errors leave the active reader unchanged.
  • πŸ—‘οΈ Memory: old owned buffers are released after the last guard, snapshot or editor drops. Rebuilding temporarily holds both generations; RSS may not decrease immediately. Publication does not persist a file. Never overwrite or truncate a mapped file while a reader still uses it.

This complete example updates a custom record, preserves its country field, and verifies that the old database is destroyed after commit:

use std::sync::Arc;

use libmaxminddb_rs::{
    Editor, MergeStrategy, MetadataBuilder, MmdbDecode, MmdbEncode, Reader, ReloadableReader,
    Writer,
};

#[derive(Debug, MmdbEncode, MmdbDecode)]
struct Record<'a> {
    country: &'a str,
    score: u32,
}

#[derive(MmdbEncode)]
struct Patch {
    score: u32,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let network = "198.51.100.0/24".parse()?;
    let ip = "198.51.100.7".parse()?;
    let metadata = MetadataBuilder::new().ip_version(4).build()?;
    let mut writer = Writer::with_metadata(metadata);
    writer.insert_encoded(
        network,
        &Record {
            country: "FR",
            score: 1,
        },
    )?;

    // Transfer ownership without retaining an extra Arc to the old database.
    let database = ReloadableReader::new(Reader::from_vec(writer.finish()?)?);
    {
        let guard = database.load();
        let record: Record<'_> = guard.lookup_borrowed(ip)?;
        println!("Before update: {record:?}");
    } // Borrowed fields and their guard are dropped before publication.

    let mut editor = Editor::from_reader(database.snapshot());
    // Weak observes destruction without keeping the old Reader alive.
    let old_lifetime = Arc::downgrade(editor.source_reader());
    editor.update_value(network, &Patch { score: 42 }, MergeStrategy::DeepMerge)?;

    // Rebuild, then publish atomically. A stale editor would return false.
    assert!(database.commit(editor)?, "Unexpected publication conflict");
    assert!(old_lifetime.upgrade().is_none());
    drop(old_lifetime);
    println!("Committed: the old Reader and its owned buffers were released.");

    let guard = database.load();
    let record: Record<'_> = guard.lookup_borrowed(ip)?;
    assert_eq!((record.country, record.score), ("FR", 42));
    println!("After DeepMerge: {record:?}");
    Ok(())
}

Run cargo run --example editor_merge for this example or cargo run --example concurrent_editor for concurrent readers and a writer.

πŸ“Š Benchmarks

Reproducible cross-library benchmark suite comparing libmaxminddb-rs against industry standard implementations in Rust, C, and Go.

βš™οΈ Evaluated Libraries & Reproducibility Specification

Library Name Language Role Evaluated Version Compiler & Build Flags Upstream Repository
πŸ¦€ libmaxminddb-rs Rust Reader & Writer 0.1.0 rustc 1.90.0 (opt-level=3, native) Current Repository
πŸ›οΈ libmaxminddb C Reader 1.14.1 cc (-O3 -march=native) maxmind/libmaxminddb
πŸ“¦ maxminddb-rust Rust Reader 0.32.0 rustc 1.90.0 (release) maxminddb-rust
πŸš€ geoip2-rs Rust Reader 0.1.8 rustc 1.90.0 (release) geoip2-rs
🐹 maxminddb-golang Go Reader v2.6.0 go go1.23.1 linux/amd64 (-ldflags="-s -w" -trimpath) oschwald/maxminddb-golang
✍️ mmdbwriter Go Writer v1.2.0 go go1.23.1 linux/amd64 (-ldflags="-s -w" -trimpath) maxmind/mmdbwriter

Environment: Linux x86_64 Β· AMD Ryzen 7 PRO 7840U w/ Radeon 780M Graphics Β· rustc 1.90.0 Β· Deterministic SplitMix64 datasets with pre-allocated memory.

Execute all benchmarks and regenerate reports with a single command:

make bench-compare

To run the same comparative suite with pinned Rust and Go toolchains in Docker, use make bench-compare-docker (Docker with Compose required). It selects the system default Docker context, even if Docker Desktop is the current CLI context; set BENCH_DOCKER_CONTEXT=name to choose another daemon. The command builds the image, runs make bench-compare in a temporary container, and prints the host paths of the generated HTML report, SVG charts, JSON/CSV results, and updated README.md when it finishes. Docker results and build caches remain under target/docker-bench/. Compare measurements only from compatible host CPUs and Docker resource limits.

Both benchmark commands generate an interactive HTML report at benchmark-report/index.html with all measured results, charts, and sortable tables. Open this local file after the run.

πŸ“Š Full HTML report preview (static image) Full benchmark report preview with every section, chart, and table

Database-size and writer benchmarks use 1K, 10K, 100K, 500K, 1M, 1.5M, 2M, 5M entries, with the same fixed seed, query workload and batch settings at every size.

Reader RSS is also compared at these eight sizes for the four Rust/C libraries, using identical databases and queries, mmap, and three isolated processes per point. The generated HTML report's Memory section shows RSS after open and the lookup peak, with exact hover values and explicit unavailable measurements. See the memory protocol for reproduction and interpretation.

🏁 Reader Performance Summary β€” p99 Tail Latency & Peak Throughput

Measured on AMD Ryzen 7 PRO 7840U w/ Radeon 780M Graphics under Linux:

Scenario Metric πŸ¦€ libmaxminddb-rs πŸ›οΈ libmaxminddb (C) πŸ“¦ maxminddb-rust πŸš€ geoip2-rs 🐹 maxminddb-golang ✍️ mmdbwriter (Go)
IPv4 Random Lookup (1M) p99 Tail Latency πŸ† 471.0 ns 541.0 ns 702.0 ns 561.0 ns 662.0 ns β€”
IPv4 Random Throughput (1M) Peak Throughput πŸ† 44.68 M ops/s 21.29 M ops/s 13.79 M ops/s 21.93 M ops/s 12.81 M ops/s β€”
IPv6 Random Lookup (1M) p99 Tail Latency 191.0 ns πŸ† 60.0 ns 341.0 ns 351.0 ns 81.0 ns β€”
IPv6 Random Throughput (1M) Peak Throughput πŸ† 116.69 M ops/s 69.31 M ops/s 36.08 M ops/s 48.65 M ops/s 29.65 M ops/s β€”
16-Thread Concurrent IPv4 Concurrent Throughput πŸ† 173.71 M ops/s 106.94 M ops/s 89.21 M ops/s 124.05 M ops/s 66.79 M ops/s β€”
Database Open (mmap) Median Latency 97.80 Β΅s 34.45 Β΅s 11.99 Β΅s πŸ† 10.59 Β΅s 13.16 Β΅s β€”
Writer Generation (5M) Insert Throughput πŸ† 1.05 M ops/s β€” β€” β€” β€” 406.9 K ops/s
Writer Total Time (5M) Total Duration πŸ† 4.76 s β€” β€” β€” β€” 12.29 s
Writer Peak RSS (5M) Peak Memory (RSS) 569.72 MiB β€” β€” β€” β€” πŸ† 150.54 MiB

πŸ“ˆ Visual Benchmark Charts

Candlestick Percentile Rank β€” IPv4 Lookups
Candlestick Percentile Rank β€” IPv4 Lookups

Candlestick Percentile Rank β€” IPv6 Lookups
Candlestick Percentile Rank β€” IPv6 Lookups

IPv4 Throughput β€” Random Lookups (1M)
IPv4 Throughput β€” Random Lookups (1M)

IPv6 Throughput β€” Random Lookups (1M)
IPv6 Throughput β€” Random Lookups (1M)

IPv4 Throughput β€” Absent Keys (1M)
IPv4 Throughput β€” Absent Keys (1M)

IPv6 Throughput β€” Absent Keys (1M)
IPv6 Throughput β€” Absent Keys (1M)

Peak Concurrent Throughput β€” 16 Threads
Peak Concurrent Throughput β€” 16 Threads

Peak Concurrent Throughput β€” 16 Threads (IPv6)
Peak Concurrent Throughput β€” 16 Threads (IPv6)

IPv4 Random Lookup β€” Worker Scaling by API
IPv4 Random Lookup β€” Worker Scaling by API

IPv6 Random Lookup β€” Worker Scaling by API
IPv6 Random Lookup β€” Worker Scaling by API

p99 Tail Latency vs Database Size
p99 Tail Latency vs Database Size

Peak RSS During Lookups (mmap)
Peak RSS During Lookups (mmap)

πŸ”₯ FlameGraph

Profile all public lookup methods on IPv4 and IPv6 and regenerate the SVG with:

make flamegraph

The command writes the lookup-only profile to target/flamegraph/lookup-flamegraph.svg and updates the image below. The graph covers lookup_borrowed, lookup_borrowed_opt, lookup_borrowed_map, lookup_value, lookup_value_with_prefix, lookup, lookup_many, and lookup_exists.

Flame graph of all IPv4 and IPv6 reader lookup methods

Documentation and compatibility

The Rust API documentation describes the reader, writer, value types, and derive macros. Runnable usage examples are in examples/. The crate supports MMDB v2 files, IPv4 and IPv6, and 24-, 28-, or 32-bit tree records. Its minimum supported Rust version is 1.98.1 (edition 2024). Reader::open_mmap is unsafe because callers must keep the mapped file unchanged while the reader exists.

For implementation details and performance protocols, see docs/ and AGENTS.md. The derive proc-macro crate is a separate package and must be published before the main crate.

Development and tests

Run make or make help to see every available rule, grouped by task.

cargo build --all-features
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo doc --no-deps --all-features
make bench-performance
make bench-writer
make bench-micro

make publish-check checks formatting, linting, tests, documentation, and both package archives. make release X.Y.Z synchronizes version references; after those edits are committed, rerun it to push main, publish the proc-macro crate and main crate to crates.io, then create the Git tag and GitHub Release. Run the release command only when you intend to publish. The contribution guide explains the development workflow.

Code Coverage & Tests

Run the complete test suite with the Makefile target. It includes the workspace (including derive), documentation tests, feature-isolated builds, and the benchmark tooling crates:

make tests

Install cargo-llvm-cov, then generate the HTML report and per-file summary with:

make coverage

Open target/coverage/html/index.html in a browser. Both Makefile targets refresh this section and the test and code coverage badges; make tests runs coverage after its other tests. Documentation tests are run separately and are not included in these coverage figures because instrumenting them requires a nightly Rust toolchain.

The latest local coverage run measured 97.05% overall line coverage and 95.70% region coverage.

Code coverage by file (line, region, and function coverage)
File Lines Regions Functions
derive/src/lib.rs 97.01% (292/301) 96.47% (465/482) 100.00% (41/41)
src/decoder/ascii.rs 97.35% (147/151) 98.35% (298/303) 100.00% (12/12)
src/decoder/mod.rs 96.86% (524/541) 95.11% (895/941) 96.55% (28/29)
src/decoder/raw.rs 98.48% (519/527) 95.51% (999/1046) 94.00% (47/50)
src/editor.rs 85.87% (158/184) 88.14% (327/371) 76.19% (16/21)
src/encoder.rs 100.00% (113/113) 94.30% (215/228) 100.00% (8/8)
src/metadata.rs 97.02% (228/235) 96.59% (340/352) 100.00% (26/26)
src/reader/marker.rs 96.79% (211/218) 97.00% (420/433) 100.00% (18/18)
src/reader/mod.rs 97.25% (919/945) 95.25% (1644/1726) 96.47% (82/85)
src/reader/scan.rs 94.46% (392/415) 92.01% (576/626) 96.97% (32/33)
src/reader/tree.rs 97.56% (840/861) 97.07% (1589/1637) 100.00% (54/54)
src/reloadable.rs 92.86% (39/42) 87.67% (64/73) 90.00% (9/10)
src/traits.rs 100.00% (360/360) 97.85% (636/650) 100.00% (61/61)
src/value.rs 98.67% (593/601) 97.78% (750/767) 98.15% (106/108)
src/writer/mod.rs 96.71% (883/913) 95.87% (1441/1503) 95.31% (61/64)
Total 97.05% (6218/6407) 95.70% (10659/11138) 96.94% (601/620)

Contributing

Issues and pull requests are welcome. Please read CONTRIBUTING.md before changing public APIs, parser invariants, or hot paths. User-visible changes should be recorded in CHANGELOG.md.

License

Licensed under either MIT or Apache-2.0, at your option.

More examples

Explore the examples directory: quickstart, concurrent updates, DeepMerge and memory reclamation, and custom database writing. Run one with cargo run --example quickstart.

See the API documentation for all methods. Minimum Rust version: 1.98.1. License: MIT or Apache-2.0.

About

Pure Rust MaxMind DB v2 (MMDB IPv4/IPv6) reader and writer with zero-copy borrowed decoding https://0x00f6.github.io/libmaxminddb-rs

Topics

Resources

Contributing

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages