A growing share of web traffic arrives over IPv6, especially from mobile networks and large consumer ISPs. Looking up an IPv6 address works exactly like an IPv4 lookup:
curl "https://ipapi.gr/api/lookup/2001:4860:4860::8888?key=YOUR_API_KEY"The response has the same fields: country_code, city, timezone, asn, isp and so on. A field with no known value is the string "No Data".
The differences are around the lookup. IPv6 addresses change more often, one visitor can have two addresses, and code written for IPv4 (string comparisons, per-IP caches, per-IP rate limits, regexes in log parsers) quietly breaks. This guide covers what changes and how to handle it.
How IPv6 addresses are structured
An IPv6 address is 128 bits, written as eight groups of four hex digits, with runs of zeros compressed (2001:db8::1). For geolocation, the split that matters is:
- The first 64 bits: the network prefix. A network assigns a
/64to each subnet, such as a home LAN or a phone's mobile connection. Customers usually get a larger block, often a/56or/48, carved into/64s. - The last 64 bits: the interface identifier. Chosen by the device itself, and on most modern systems randomised and rotated over time.
Geolocation data is attached to prefixes, never to individual interface identifiers. Two addresses in the same /64 are on the same subnet, and so in the same place.
Privacy addresses break per-IP logic
To stop websites from tracking a device by its address, operating systems generate temporary addresses with random interface identifiers and replace them regularly. This is standardised in RFC 8981 and enabled by default on major desktop and mobile systems.
The consequence: the same visitor may have a different IPv6 address tomorrow, or in an hour. For IPv4-era code that means:
- A per-IP geolocation cache misses far more often, and you pay for lookups you've effectively done already.
- A per-IP rate limit is easy to sidestep. One device or network can use many addresses from its prefix.
- Per-IP "seen before" checks treat returning visitors as new.
The fix is to key these things on the prefix, not the full address. A /64 is a sound default for caching geolocation, because it's one subnet. For rate limiting, many operators go wider (a /56 or /48), because one customer can control that many addresses. Choose based on your traffic.
Normalise before you do anything
The same IPv6 address can be written in many ways: 2001:DB8::1, 2001:db8:0:0:0:0:0:1 and 2001:0db8::0001 are all equal. Compare strings and you'll miss matches. RFC 5952 defines one canonical text form (lowercase, maximum compression), and every standard library can produce it.
Also watch for these when you take an address from a request:
- IPv4-mapped addresses like
::ffff:203.0.113.7. Dual-stack sockets in Node.js, Java and others report IPv4 clients this way. The client is really on IPv4, so unwrap it to203.0.113.7. - Zone identifiers like
fe80::1%eth0. They only appear on link-local addresses, which have no public location anyway. - Brackets and ports like
[2001:db8::1]:443in some headers and logs. Strip them.
Special ranges to skip
Like IPv4, IPv6 has ranges that are never seen on the public internet, or don't identify a client location. Don't spend lookups on them:
| Range | What it is |
|---|---|
::1/128 | Loopback |
fe80::/10 | Link-local |
fc00::/7 | Unique local addresses, the IPv6 equivalent of private ranges (RFC 4193) |
2001:db8::/32 | Documentation only (RFC 3849) |
::ffff:0:0/96 | IPv4-mapped: unwrap and look up the IPv4 address |
64:ff9b::/96 | NAT64 well-known prefix (RFC 6052) |
Standard-library checks such as Python's is_global or Go's IsGlobalUnicast plus IsPrivate cover most of this for you.
NAT64 and IPv6-only mobile networks
Some mobile networks give phones only IPv6. To reach IPv4-only websites, the carrier translates the traffic through NAT64 gateways, often with 464XLAT on the phone. If your server is IPv4-only, you never see the phone's IPv6 address. You see the gateway's IPv4 address, and geolocation describes where the carrier operates that gateway.
That's one reason mobile users can appear in another city. Serving your site over IPv6 as well usually lets you see the device's own prefix, which tends to carry better location data.
Dual-stack users have two addresses
Most home and mobile connections today have both IPv4 and IPv6. Browsers pick between them per connection using "Happy Eyeballs" (RFC 8305), preferring IPv6 but falling back to IPv4 if it's slower. So one visitor can reach you over IPv6 for one request and IPv4 for the next.
The two addresses come from different allocations and may be geolocated from different evidence. They often agree on the country, but they can disagree on the city, and occasionally on more. Practical rules:
- Don't treat a v4/v6 switch as suspicious in fraud or session checks. It's normal.
- Decide once per session. Look up the country at the start of the session and store it, rather than re-deciding on every request and flipping between answers.
- Log which family you looked up when you investigate accuracy complaints.
Is IPv6 geolocation less accurate?
It can be, depending on the network. IPv4 has decades of registry records, measurements and corrections behind it. IPv6 is larger and newer, and some operators publish less location detail for it. Other operators publish detailed IPv6 data in RFC 8805 geofeeds, which list prefixes of either family with their country, region and city. For networks that publish good geofeeds, IPv6 results can be as good as IPv4.
We don't publish an accuracy figure for either family, because we have no independent ground truth to back one up. The general advice from how accurate IP geolocation is applies to both: rely on the country, treat the city as approximate, and let users correct you.
Node.js: normalise, filter and key by /64
Node's net module validates addresses, and net.BlockList checks ranges. The helper below returns a cache key, or null for addresses not worth looking up:
import net from 'node:net';
const reserved = new net.BlockList();
reserved.addSubnet('::1', 128, 'ipv6');
reserved.addSubnet('fe80::', 10, 'ipv6');
reserved.addSubnet('fc00::', 7, 'ipv6');
reserved.addSubnet('2001:db8::', 32, 'ipv6');
reserved.addSubnet('64:ff9b::', 96, 'ipv6');
for (const [a, p] of [['10.0.0.0', 8], ['172.16.0.0', 12], ['192.168.0.0', 16], ['127.0.0.0', 8], ['169.254.0.0', 16], ['100.64.0.0', 10]]) {
reserved.addSubnet(a, p, 'ipv4');
}
export function normalise(raw) {
let ip = String(raw).trim().replace(/^\[|\](:\d+)?$/g, '').split('%')[0];
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip);
if (mapped) ip = mapped[1];
const family = net.isIP(ip);
if (!family) return null;
if (reserved.check(ip, family === 6 ? 'ipv6' : 'ipv4')) return null;
return { ip, family, key: family === 6 ? prefix64(ip) : ip };
}
// Expand to 8 groups and keep the first four: the /64 prefix.
function prefix64(ip) {
const [head, tail = ''] = ip.toLowerCase().split('::');
const h = head ? head.split(':') : [];
const t = tail ? tail.split(':') : [];
const groups = ip.includes('::') ? [...h, ...Array(8 - h.length - t.length).fill('0'), ...t] : h;
return groups.slice(0, 4).map((g) => parseInt(g, 16).toString(16)).join(':') + '::/64';
}Then use the key for the cache and the full address for the lookup:
const known = (v) => (v === undefined || v === null || v === '' || v === 'No Data' ? null : v); // missing fields are "No Data"
const cache = new Map(); // key (/64 or IPv4) -> { geo, at }
export async function geoFor(rawIp) {
const n = normalise(rawIp);
if (!n) return null;
const hit = cache.get(n.key);
if (hit && Date.now() - hit.at < 6 * 3600 * 1000) return hit.geo;
const res = await fetch(`https://ipapi.gr/api/lookup/${encodeURIComponent(n.ip)}?key=${process.env.IPAPI_KEY}`,
{ signal: AbortSignal.timeout(2000) });
if (!res.ok) return null;
const d = await res.json();
const geo = { countryCode: known(d.country_code), city: known(d.city), timezone: known(d.timezone), asn: known(d.asn) };
cache.set(n.key, { geo, at: Date.now() });
return geo;
}prefix64 gives the same key for every spelling of the same prefix (2001:db8:0:0::1 and 2001:DB8::1 both become 2001:db8:0:0::/64). It isn't the compressed RFC 5952 form, which doesn't matter for a cache key. It assumes an address that already passed net.isIP. IPv6 addresses with an embedded dotted IPv4 tail, other than the mapped form handled above, are rare in client traffic. They'd need extra handling if you see them.
Python: the ipaddress module does the work
import ipaddress
import os
import time
import requests
session = requests.Session()
_cache = {}
def known(v):
return None if v in (None, "", "No Data") else v # missing fields are "No Data"
def normalise(raw: str):
raw = raw.strip().strip("[]").split("%")[0]
try:
ip = ipaddress.ip_address(raw)
except ValueError:
return None
if ip.version == 6 and ip.ipv4_mapped:
ip = ip.ipv4_mapped # ::ffff:a.b.c.d -> a.b.c.d
if not ip.is_global:
return None # private, loopback, link-local, documentation...
key = str(ipaddress.ip_network(f"{ip}/64", strict=False)) if ip.version == 6 else str(ip)
return str(ip), key # str() gives the canonical RFC 5952 form
def geo_for(raw: str):
n = normalise(raw)
if not n:
return None
ip, key = n
hit = _cache.get(key)
if hit and time.time() - hit[1] < 6 * 3600:
return hit[0]
r = session.get(f"https://ipapi.gr/api/lookup/{ip}", params={"key": os.environ["IPAPI_KEY"]}, timeout=2)
if r.status_code != 200:
return None
d = r.json()
geo = {k: known(d.get(k)) for k in ("country_code", "city", "timezone", "asn")}
_cache[key] = (geo, time.time())
return geo
print(normalise("2001:4860:4860:0:0:0:0:8888")) # ('2001:4860:4860::8888', '2001:4860:4860::/64')
print(normalise("::ffff:8.8.8.8")) # ('8.8.8.8', '8.8.8.8')PHP: inet_pton for canonical forms and prefixes
<?php
function known($v) {
return ($v === null || $v === '' || $v === 'No Data') ? null : $v; // missing fields are "No Data"
}
/** Returns [ip, cacheKey] or null for invalid/non-public addresses. */
function normalise_ip(string $raw): ?array {
$raw = explode('%', trim($raw, " []"))[0];
if (preg_match('/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i', $raw, $m)) $raw = $m[1];
$public = filter_var($raw, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
if ($public === false) return null;
$bin = inet_pton($public);
if (strlen($bin) === 4) return [$public, $public];
$canonical = inet_ntop($bin); // canonical text form
$prefix = inet_ntop(substr($bin, 0, 8) . str_repeat("\0", 8)) . '/64';
return [$canonical, $prefix];
}
[$ip, $key] = normalise_ip($_SERVER['REMOTE_ADDR']) ?? [null, null];
if ($ip !== null) {
$geo = function_exists('apcu_fetch') ? apcu_fetch('geo:' . $key) : false;
if ($geo === false) {
$body = @file_get_contents('https://ipapi.gr/api/lookup/' . rawurlencode($ip) . '?key=' . urlencode(getenv('IPAPI_KEY')),
false, stream_context_create(['http' => ['timeout' => 2, 'ignore_errors' => true]]));
$d = $body ? json_decode($body, true) : [];
$geo = ['country_code' => known($d['country_code'] ?? null), 'city' => known($d['city'] ?? null)];
if (isset($d['ip']) && function_exists('apcu_store')) apcu_store('geo:' . $key, $geo, 21600);
}
}FILTER_FLAG_NO_PRIV_RANGE and FILTER_FLAG_NO_RES_RANGE reject the private and reserved ranges of both families, though the exact list depends on your PHP version. Add your own checks for any range you need to be certain about. The result is only cached when the body contains ip, which successful lookups always do and error bodies don't.
Go: net/netip
Go's net/netip package (Go 1.18+) has everything built in: parsing, unmapping and prefixes.
package geo
import (
"net/netip"
"strings"
)
// Normalise returns the address to look up and the cache key, or ok=false.
func Normalise(raw string) (ip netip.Addr, key string, ok bool) {
raw = strings.Trim(strings.TrimSpace(raw), "[]")
addr, err := netip.ParseAddr(raw)
if err != nil {
return netip.Addr{}, "", false
}
addr = addr.WithZone("").Unmap() // drop %zone; ::ffff:a.b.c.d -> a.b.c.d
if !addr.IsGlobalUnicast() || addr.IsPrivate() || documentation.Contains(addr) || nat64.Contains(addr) {
return netip.Addr{}, "", false
}
if addr.Is6() {
p, _ := addr.Prefix(64)
return addr, p.String(), true // e.g. "2001:4860:4860::/64"
}
return addr, addr.String(), true
}
var (
documentation = netip.MustParsePrefix("2001:db8::/32")
nat64 = netip.MustParsePrefix("64:ff9b::/96")
)Use ip.String() (already canonical) in the lookup URL, and key for your cache. The lookup itself is the same as in get a visitor's country from their IP. Remember that every string field, including country_code, can be "No Data", and that latitude and longitude can be "No Data" too, so don't decode them straight into float64.
At the edge
Edge platforms hand you the client address directly (CF-Connecting-IP on Cloudflare). The same normalisation applies. A compact version for Workers, where Node's net module isn't available by default:
const known = (v) => (v === undefined || v === null || v === '' || v === 'No Data' ? null : v); // missing fields are "No Data"
function cacheKey(ip) {
if (!ip.includes(':')) return ip; // IPv4
const [head, tail = ''] = ip.toLowerCase().split('::');
const h = head ? head.split(':') : [], t = tail ? tail.split(':') : [];
const g = ip.includes('::') ? [...h, ...Array(8 - h.length - t.length).fill('0'), ...t] : h;
return g.slice(0, 4).map((x) => parseInt(x, 16).toString(16)).join(':') + '::/64';
}
export default {
async fetch(request, env, ctx) {
const ip = request.headers.get('CF-Connecting-IP');
const key = new Request(`https://geo-cache.invalid/${encodeURIComponent(cacheKey(ip))}`);
let geo = await caches.default.match(key).then((r) => r && r.json());
if (!geo) {
try {
const res = await fetch(`https://ipapi.gr/api/lookup/${encodeURIComponent(ip)}?key=${env.IPAPI_KEY}`,
{ signal: AbortSignal.timeout(1500) });
if (res.ok) {
const d = await res.json();
geo = { country: known(d.country_code), tz: known(d.timezone) };
ctx.waitUntil(caches.default.put(key, Response.json(geo, { headers: { 'Cache-Control': 'max-age=21600' } })));
}
} catch { /* unknown */ }
}
return new Response(`country=${geo?.country ?? 'unknown'}\n`);
}
};Logs and privacy
IPv6 changes how you anonymise logs, too. Zeroing the last octet is a common way to truncate IPv4 addresses for statistics. The IPv6 equivalent is keeping only the prefix, a /48 or /64, and discarding the interface identifier. That keeps enough to group traffic by network, and to geolocate it later, while dropping the device-specific part.
Remember that in the EU even a prefix can be personal data when it's combined with other information. Truncation reduces risk. It doesn't make the data anonymous by itself.
Checklist
- Accept IPv6 everywhere you parse, store and compare addresses. A database column sized for IPv4 strings (15 characters) truncates IPv6 (up to 39, or 45 with an IPv4 tail).
- Unwrap
::ffff:IPv4-mapped addresses before looking them up. - Skip loopback, link-local, ULA and documentation ranges.
- Cache geolocation per
/64. Rate limit per prefix. - Expect the same visitor over both IPv4 and IPv6, and decide per session.
- Serve your site over IPv6 so you see device prefixes, not NAT64 gateways.
FAQ
Do I need a different endpoint for IPv6?
No. GET /api/lookup/{ip} accepts both IPv4 and IPv6. Pass the address as-is in the path.
Can I look up a whole IPv6 prefix?
The endpoint takes a single address. To geolocate a prefix, look up one address inside it. Its result describes the subnet, so for a /64 any address in it will do.
Why does my own IPv6 address geolocate somewhere different from my IPv4 address?
They come from different allocations, sometimes registered and routed differently by your ISP, and the location evidence for each can differ. It's common and usually harmless at country level.
Does the ASN work the same for IPv6?
Yes. The asn, isp and organization fields work the same way, and the ASN often matches the IPv4 one for the same ISP. See ASN lookup by IP.
Related: get the time zone from an IP address · detect data-centre and hosting IPs by ASN.