How to Migrate from MaxMind GeoIP2 to IP Geo API in 2026: A Step-by-Step Drop-In Guide

7-minute read · 2026 code samples · honest rollback plan

This is the practical companion to the MaxMind alternative comparison → and the head-on review of MaxMind GeoIP2 vs IP Geo API →. Those two pages tell you whether to switch. This page tells you how — including the four steps no other migration guide is honest about.

TL;DR — most teams complete a .mmdb → REST migration in one engineering day. The hard work is not the code; it’s the field map, the cache layer, and a rollback path you actually trust.

Who this guide is for

You already use MaxMind GeoIP2 (paid) or GeoLite2 (free) via libmaxminddb or one of its language bindings, you’ve decided that the database-sync workflow costs more than it saves, and you want a REST API replacement that:

If those three boxes are unchecked — pause and read the vs comparison → first. The tradeoffs are real, especially around hot-path latency and air-gapped environments.

The 7-step migration checklist

  1. Inventory every call site that opens a Reader / queries an .mmdb.
  2. Map your fields to the GeoIP2-compatibility response (?format=geoip2).
  3. Add a feature flag so you can switch any call site between MMDB and API.
  4. Wire a 60-second cache in front of the API client (in-memory or Redis).
  5. Deploy in shadow mode — read both, log differences, serve MMDB responses.
  6. Cut over gradually — 10% → 50% → 100% of traffic over 48 hours.
  7. Decommission the sync pipeline — delete the cron, remove the licence, archive the readers.

The rest of this post walks each step with copy-paste code.

Step 1 — Inventory call sites

Run this in the repo root before touching anything:

git grep -nE "GeoIP2|libmaxminddb|maxminddb|\.mmdb" -- ':!*.lock' ':!*.md'

Most teams find 3-12 call sites. Make a list. Note for each: language, library version, field paths consumed, and whether the result is cached.

Step 2 — Map the fields

IP Geo API ships a ?format=geoip2 compatibility shim that returns the most common GeoIP2 City fields under the same nested paths. The mapping for the fields ~95% of integrations rely on:

Your old code GeoIP2 path IP Geo API ?format=geoip2 Native ?format=ipgeo
Country code country.iso_code country.iso_code country
Country name country.names.en country.names.en country_name
City city.names.en city.names.en city
Subdivision subdivisions[0].iso_code subdivisions[0].iso_code region
Postal postal.code postal.code postal
Lat/lon location.latitude / .longitude location.latitude / .longitude lat / lon
Time zone location.time_zone location.time_zone timezone
ASN traits.autonomous_system_number traits.autonomous_system_number asn.number
ASN org traits.autonomous_system_organization traits.autonomous_system_organization asn.org
VPN / Tor / proxy (Anonymous IP DB, separate licence) traits.is_anonymous / is_tor_exit_node / is_anonymous_proxy threat.{vpn,tor,proxy}

Fields the shim does not cover (documented gaps): registered_country distinction, ISP-only fields (e.g. traits.isp), confidence scores, and historical / point-in-time queries. If your code reads any of those, list them as blockers and decide per call site whether to drop the dependency or keep MaxMind for that path only (hybrid pattern — see the comparison page →).

Step 3 — Feature flag, then drop-in client

Python (was geoip2.database.Reader)

# before
import geoip2.database
reader = geoip2.database.Reader("/var/lib/geoip/GeoLite2-City.mmdb")
res = reader.city(ip)
country = res.country.iso_code

# after — drop-in via the geoip2-compatibility shim
import os, requests
from functools import lru_cache

API_KEY = os.environ["IPGEO_API_KEY"]
USE_IPGEO = os.environ.get("USE_IPGEO_API", "0") == "1"   # feature flag

@lru_cache(maxsize=10_000)
def _lookup(ip: str) -> dict:
    r = requests.get(
        f"https://api.ipgeo.10b.app/v1/{ip}",
        headers={"Authorization": f"Bearer {API_KEY}"},
        params={"format": "geoip2"},
        timeout=2.0,
    )
    r.raise_for_status()
    return r.json()

def lookup_country(ip: str) -> str:
    if USE_IPGEO:
        return _lookup(ip)["country"]["iso_code"]
    return reader.city(ip).country.iso_code   # legacy path

The lru_cache decorator gives you a free in-process cache; replace with Redis SETEX 60 for multi-pod deployments.

Node / TypeScript (was maxmind)

// before
import maxmind from "maxmind";
const lookup = await maxmind.open<CityResponse>("/var/lib/geoip/GeoLite2-City.mmdb");
const res = lookup.get(ip);

// after — drop-in
const cache = new Map<string, any>();
export async function geoLookup(ip: string) {
  if (process.env.USE_IPGEO_API !== "1") return lookup.get(ip);
  if (cache.has(ip)) return cache.get(ip);
  const r = await fetch(`https://api.ipgeo.10b.app/v1/${ip}?format=geoip2`, {
    headers: { Authorization: `Bearer ${process.env.IPGEO_API_KEY!}` },
  });
  if (!r.ok) throw new Error(`ipgeo ${r.status}`);
  const j = await r.json();
  cache.set(ip, j);
  setTimeout(() => cache.delete(ip), 60_000);   // 60-s TTL
  return j;
}

Go (was github.com/oschwald/geoip2-golang)

// after — drop-in via the geoip2-compatibility shim
url := fmt.Sprintf("https://api.ipgeo.10b.app/v1/%s?format=geoip2", ip)
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("IPGEO_API_KEY"))
resp, err := httpClient.Do(req)
// ... unmarshal into your existing geoip2.City struct

Step 4 — Cache layer (the step everyone skips)

A naive 1-call-per-request integration will burn through paid-tier quotas in week one. Most production traffic is dominated by 1-5% of IPs (your bot crawler, your monitoring, your power users). A 60-second in-memory cache typically deflects 70-90% of calls at zero cost.

If you want strict cache-miss bounds, add a per-host concurrency limiter so only one in-flight call per IP is ever issued.

Step 5 — Shadow mode (the step that builds trust)

Before flipping any user-facing path: run both readers and compare.

def lookup_country(ip: str) -> str:
    legacy = reader.city(ip).country.iso_code
    if SHADOW_MODE:
        try:
            new = _lookup(ip)["country"]["iso_code"]
            if new != legacy:
                logger.warning("geoip-shadow-mismatch", extra={"ip": ip, "legacy": legacy, "new": new})
        except Exception as e:
            logger.error("geoip-shadow-error", extra={"ip": ip, "error": str(e)})
    return legacy

Run shadow mode for 24-48 hours. The mismatch rate on country-level data is typically <0.5% (mostly recent IP-block reassignments where one source is fresher). City-level is 1-3%. ASN and threat flags are not directly comparable because MaxMind’s free GeoLite2 lacks threat data.

Step 6 — Gradual cutover

Once shadow logs are clean, flip a percentage of traffic via your feature-flag system (LaunchDarkly, Unleash, or a hashed-IP rollout):

import hashlib

def use_ipgeo(ip: str, percent: int) -> bool:
    h = int(hashlib.md5(ip.encode()).hexdigest(), 16)
    return (h % 100) < percent

Recommended schedule: 10% → 50% → 100% over 48 hours. Watch error rate, p99 latency, and downstream conversion / fraud-rate dashboards for any regression.

Step 7 — Decommission

After 7 days at 100% with no rollback events, you can:

That last bullet is often the surprise win: container builds get faster, cold starts shrink, CI cache hit-rates improve.

Rollback plan

Keep the legacy MaxMind reader path behind the same feature flag for a minimum of 30 days. If you see a regression at any percentage, you can flip back with one config change and zero deploy. After 30 days of stability, prune the dead code in a separate PR.

The 7 gotchas teams hit in week one

  1. No cache layer. Quota burn in 48 h. Add the cache before flipping the flag.
  2. Outbound HTTPS blocked. Production VPC egress rules deny api.ipgeo.10b.app. Get firewall change scheduled before cutover.
  3. Timezone parsing differences. GeoIP2 returns Europe/Amsterdam; ensure your parser doesn’t fail on rare nulls.
  4. registered_country consumers. A handful of fraud rules read this distinct from country; map both or document the gap.
  5. Bot traffic spike. Your scrapers / synthetic monitoring suddenly count against paid quota. Allowlist them client-side or skip the lookup entirely.
  6. CI / test environments. Shared test IPs (127.0.0.1, ::1) return 422 from the API; mock them in test or short-circuit before the call.
  7. GDPR DPIA refresh. Switching processor classes (US-jurisdictional MaxMind LLC → EU-only IP Geo API) usually triggers a one-page Article 30 update. Boring, but should be on the cutover checklist.

What you’ll see in week two

Pairing pages

FAQ

How long does a real migration take? For a single-stack web app with 3-6 call sites and a working CI: one engineering day end-to-end. Multi-stack monorepos or services with 12+ call sites: 2-4 days, mostly in shadow-mode tuning.

Will my MMDB-shaped tests still pass? Yes — if you’ve used dependency injection on the reader. The compatibility shim returns the same nested shape for the supported field set. For fields outside the shim, mock the new client path instead.

What about GeoLite2 (free) vs paid GeoIP2 callers? Same migration. The free tier of IP Geo API (1.000 req/day, no attribution required) is a cleaner replacement than GeoLite2 for low-volume internal tools.

What’s the rollback story if something goes wrong? The feature flag gives you a 1-second flip back to MaxMind. Keep the licence and the sync cron alive for at least 30 days post-cutover. Most teams keep them for 90 days for audit-trail reasons.

Can I migrate one service at a time? Yes — and it’s the recommended approach. Each call site is independent. Migrate the lowest-risk one first (often a dashboard analytics path), measure for a week, then move to the next. There is no all-or-nothing requirement.

Related migration & comparison reading

Industry deep-dives


Last reviewed 2026-05-09 · IP Geo API team · Comments / corrections: hello@ipgeo.10b.app

Pairs with the full MaxMind alternative comparison page and the head-on IP Geo API vs MaxMind GeoIP2 review.


Get early access — 50% off for 12 months

First 100 signups lock in 50% off any paid plan for the first year. No credit card required — we’ll email you at launch.