Why Your IP Lookup Returns Nothing: A Range Reference
An IP geolocation lookup returns nothing for four different reasons, and most code only handles one of them.
Private ranges are the obvious case. Everyone remembers 192.168.0.0/16. The other three catch people out: address space no registry has handed out yet, space that was handed out but nobody is announcing, and addresses that resolve cleanly to a place that has nothing to do with your user.
That last one is the expensive one. It doesn't look like a failure. It looks like data.
TL;DR
- Four distinct failure modes, and only one is private address space.
- The IANA special-purpose registries are the authoritative list, and they publish a
Globally Reachablecolumn that answers "will this ever geolocate" directly. - Full IPv4 and IPv6 tables below, including
3fff::/20(July 2024) and5f00::/16(April 2024), which most reference pages predate. - APIs signal this either through a status code or a response field. Check which one yours does before you write the branch.
- Filtering locally costs nothing and skips the round trip. Tested Python and Node at the end.
A lookup that comes back empty is usually correct, not broken. The address genuinely has no location. What you do about it depends on which of the four cases you hit, and telling them apart takes about fifteen lines of code plus one table you can bookmark.
Four reasons a lookup comes back empty
These are not variations on a theme. They need different handling, and two of them are invisible in your logs unless you go looking.
| Case | What happened | Will it ever resolve? | Your move |
|---|---|---|---|
| Special-purpose range | Private, loopback, CGNAT, documentation, reserved | No, by design | Filter before the call |
| Unallocated | No registry has assigned the block | Not today, maybe later | Cache the miss, retry rarely |
| Allocated, not routed | Assigned to an org, no BGP announcement | Possibly, if it gets announced | Same as unallocated |
| Resolves, but wrong | Shared or carrier address returns a real location | It already did, and misled you | Weight it lower, don't trust city |
The first case is a lookup you should never have made. The middle two are honest gaps in global routing data. The fourth one is the one that quietly corrupts fraud rules and analytics, because the API did its job and the answer is still useless.
The ranges that can never be geolocated
Both tables come from the IANA IPv4 Special-Purpose Address Registry and its IPv6 counterpart. If you only remember one thing from this article, remember that those registries carry a Globally Reachable column. Every block below has it set to false, and that single boolean is a better predictor than any vendor's list.
IPv4 special-purpose ranges
The "why it reaches you" column matters more than the range itself. Knowing that 169.254.0.0/16 exists is trivia. Knowing that it shows up because a DHCP lease failed, or because something is hitting the cloud metadata endpoint, is actionable.
| Range | Name (RFC) | Why it reaches your logs |
|---|---|---|
0.0.0.0/8 |
This network (RFC 791) | Misconfigured client, DHCP before assignment |
10.0.0.0/8 |
Private-Use (RFC 1918) | Internal traffic, or a missing X-Forwarded-For
|
100.64.0.0/10 |
Shared Address Space (RFC 6598) | Carrier-grade NAT, mobile and fixed ISP subscribers |
127.0.0.0/8 |
Loopback (RFC 1122) | Local dev, health checks, same-host reverse proxy |
169.254.0.0/16 |
Link Local (RFC 3927) | DHCP failure, cloud metadata at 169.254.169.254
|
172.16.0.0/12 |
Private-Use (RFC 1918) | Docker bridge networks live here by default |
192.0.0.0/24 |
IETF Protocol Assignments (RFC 6890) | NAT64 discovery, protocol machinery |
192.0.2.0/24 |
Documentation, TEST-NET-1 (RFC 5737) | Someone copied an example from a tutorial |
192.168.0.0/16 |
Private-Use (RFC 1918) | Home and small office LANs |
198.18.0.0/15 |
Benchmarking (RFC 2544) | Load generators and network test rigs |
198.51.100.0/24 |
Documentation, TEST-NET-2 (RFC 5737) | Same as TEST-NET-1 |
203.0.113.0/24 |
Documentation, TEST-NET-3 (RFC 5737) | Same as TEST-NET-1 |
224.0.0.0/4 |
Multicast (RFC 5771) | Malformed source, or something badly wrong |
240.0.0.0/4 |
Reserved (RFC 1112) | Reserved since 1989 and still unused |
255.255.255.255/32 |
Limited Broadcast (RFC 919, RFC 8190) | Broadcast leaking into an application log |
Pitfall: the documentation ranges are worth flagging to your team.
203.0.113.45in a bug report almost always means someone pasted an example instead of a real address, and every API on the market will reject it.
IPv6 special-purpose ranges
IPv6 is where reference pages go stale, because the registry keeps growing. Two blocks below were allocated in 2024 and are missing from most published bogon lists.
3fff::/20 is the one to know. RFC 9637 registered it as a documentation prefix in July 2024, expanding what 2001:db8::/32 used to cover alone. If you are validating IPv6 input against a list you copied from somewhere in 2023, 3fff::/20 is a hole in it.
| Range | Name (RFC) |
|---|---|
::/128 |
Unspecified Address (RFC 4291) |
::1/128 |
Loopback (RFC 4291) |
::ffff:0:0/96 |
IPv4-mapped Address (RFC 4291) |
100::/64 |
Discard-Only Address Block (RFC 6666) |
100:0:0:1::/64 |
Dummy IPv6 Prefix (RFC 9780, April 2025) |
2001:2::/48 |
Benchmarking (RFC 5180) |
2001:20::/28 |
ORCHIDv2 (RFC 7343) |
2001:db8::/32 |
Documentation (RFC 3849) |
3fff::/20 |
Documentation (RFC 9637, July 2024) |
5f00::/16 |
Segment Routing SRv6 SIDs (RFC 9602, April 2024) |
fc00::/7 |
Unique-Local (RFC 4193, RFC 8190) |
fe80::/10 |
Link-Local Unicast (RFC 4291) |
Two entries you may have in an older list should come out. 2001:10::/28 was ORCHID and was deprecated in March 2014, replaced by 2001:20::/28. And fec0::/10 site-local is gone entirely, not merely deprecated. It is no longer in the registry at all.
The 6to4 range people miss
2002::/16 (RFC 3056) embeds an IPv4 address inside an IPv6 one, so a 6to4 address wrapping a private IPv4 range is non-routable even though the outer prefix looks ordinary. 2002:a00::/24 is 10.0.0.0/8 in disguise. Rare in 2026, still worth a line in your filter if you handle tunnel traffic.
What the API sends back instead
Here is the part no reference page covers, and the reason I wrote this: the ranges are easy to find, but what your provider actually does when you send one is not documented anywhere consistently.
Status code or response field
There are two designs in the wild, and they need different code.
IPinfo returns HTTP 200 with a bogon field set to true, so you branch on the payload. IPGeolocation returns 423 Locked with a message body and no location object, so you branch on the status code. Both are reasonable. Neither is guessable, and getting it wrong means your handler never fires.
If you are on the status-code design, the body looks like this:
{
"message": "'10.0.0.0' is a bogon IP address."
}
Being blunt about a limitation here, since I work on one of these: there is no is_bogon boolean in the successful response on our side. The signal is the 423. If you were hoping to check one field on a 200 and be done, that works with IPinfo's shape and not with ours.
For contrast, a successful lookup on the same endpoint returns this, on the free plan, with no include parameters:
{
"ip": "91.128.103.196",
"location": {
"continent_code": "EU",
"continent_name": "Europe",
"country_code2": "SE",
"country_code3": "SWE",
"country_name": "Sweden",
"country_name_official": "Kingdom of Sweden",
"country_capital": "Stockholm",
"state_prov": "Stockholms lรคn",
"state_code": "SE-AB",
"district": "Stockholm",
"city": "Stockholm",
"zipcode": "164 40",
"latitude": "59.40510",
"longitude": "17.95510",
"is_eu": true,
"country_flag": "https://ipgeolocation.io/static/flags/se_64.png",
"geoname_id": "9972319",
"country_emoji": "<SE flag emoji>"
},
"country_metadata": {
"calling_code": "+46",
"tld": ".se",
"languages": ["sv-SE", "se", "sma", "fi-SE"]
},
"currency": { "code": "SEK", "name": "Swedish Krona", "symbol": "kr" },
"asn": {
"as_number": "AS1257",
"organization": "Tele2 Sverige AB",
"country": "SE"
},
"time_zone": {
"name": "Europe/Stockholm",
"offset": 1,
"offset_with_dst": 1,
"current_time": "2026-03-07 10:37:38.987+0100",
"current_time_unix": 1772876258.987,
"current_tz_abbreviation": "CET",
"current_tz_full_name": "Central European Standard Time",
"standard_tz_abbreviation": "CET",
"standard_tz_full_name": "Central European Standard Time",
"is_dst": false,
"dst_savings": 0,
"dst_exists": true,
"dst_tz_abbreviation": "CEST",
"dst_tz_full_name": "Central European Summer Time",
"dst_start": {
"utc_time": "2026-03-29 TIME 01:00",
"duration": "+1.00H",
"gap": true,
"date_time_after": "2026-03-29 TIME 03:00",
"date_time_before": "2026-03-29 TIME 02:00",
"overlap": false
},
"dst_end": {
"utc_time": "2026-10-25 TIME 01:00",
"duration": "-1.00H",
"gap": false,
"date_time_after": "2026-10-25 TIME 02:00",
"date_time_before": "2026-10-25 TIME 03:00",
"overlap": true
}
}
}
Two type traps in there that will bite you. latitude and longitude are strings, not numbers, so anything doing arithmetic needs a cast. And as_number carries the AS prefix as part of the string, so AS1257 rather than 1257.
What a rejected lookup costs you
Nothing, which is the useful part. Private, bogon, and malformed addresses are not counted as valid IPs, so they do not consume credits. That rule applies to both the single and bulk endpoints, per the documented status codes and credit rules.
Every response carries an X-Credits-Charged header, and that header is the billing source of truth rather than your own arithmetic. Base lookups cost 1 credit, adding security costs 2 more for a total of 3, and adding abuse contact data costs 1 more.
That makes the API safe to use as a validation layer if you want to. I still wouldn't. A network round trip to learn that 192.168.1.1 is private is a round trip you can skip with a local check, and the latency is the cost that matters, not the credit.
Bulk lookups return a mixed array
This one is a genuine footgun. Post three addresses where one is private, and the array you get back is not homogeneous:
[
{ "message": "'10.0.0.0' is a bogon IP address." },
{ "ip": "91.128.103.196", "location": { "country_code2": "SE" } },
{ "ip": "107.161.145.198", "location": { "country_code2": "US" } }
]
Index 0 has no ip key and no location object. Any code that maps over the response reaching for entry.location.country_code2 throws on the first element. There is also an X-Successful-Record header that appears only when the batch contained something invalid, telling you how many entries actually returned data.
Allocated, but nobody is announcing it
Two more classes of empty response have nothing to do with reserved ranges, and they are why you cannot solve this problem with a filter alone.
An address can sit in space that no regional registry has assigned yet. IPv4 has very little of this left after exhaustion, but IPv6 has enormous unallocated regions and will for decades.
More common: the block is allocated to a real organization, and no autonomous system is announcing a route to it. Geolocation providers lean on BGP data to place addresses, so an unannounced prefix has nothing to place. The address is real, owned, and invisible.
Both cases return a normal empty result rather than an error, because nothing is wrong with the request. Cache the miss for a day or two and move on. Retrying in a tight loop will not conjure a route, and if the prefix does get announced next month your cache expiry will pick it up on its own.
CGNAT is the one that actually costs you money
The other three cases fail loudly. This one succeeds and hands you a plausible answer, which is worse.
When a subscriber sits behind carrier-grade NAT, their device holds an address in 100.64.0.0/10, the shared space RFC 6598 set aside for exactly this. That address never reaches your server, so you will rarely see it in production logs. What reaches you is the carrier's shared public address, and that geolocates perfectly well: real country, real city, real coordinates. The country is usually right. The city is the carrier's aggregation point, which can be several hundred kilometres from the person.
Cloudflare's engineering team documented the scale of this in October 2025, noting that a single IPv4 address can now represent hundreds or thousands of users, and that security mechanisms treating an address as one accountable entity cause collateral damage as a result. Their detection approach relies on traceroute analysis spotting a 100.64.0.0/10 hop between the customer router and the public address, which tells you how much work it takes to identify from the outside.
The practical consequence: any rule that blocks, rate-limits, or scores an IP is aimed at a group, not a person. On mobile carriers that group can be a city.
My take, and this is where I'd push back on how most teams use this data: city-level geolocation deserves to be an input with a confidence weight, never a gate. Country level is dependable enough to act on. City level on mobile traffic is a hint. If your fraud rules escalate on a city mismatch, you are flagging commuters and mobile users, and you will not see it in your metrics because the false positives look exactly like caught fraud.
Filter before you call
Every block in those tables is knowable locally. Checking costs microseconds and saves a round trip.
Python
The standard library already knows most of this. Two gaps it does not.
import ipaddress
import os
import requests
# Blocks the stdlib does not classify yet. Both were allocated after
# Python 3.12's tables were written. Verified against 3.12.3.
_STDLIB_GAPS = [
ipaddress.ip_network("3fff::/20"), # Documentation, RFC 9637
ipaddress.ip_network("5f00::/16"), # SRv6 SIDs, RFC 9602
]
def is_lookupable(raw_ip):
try:
addr = ipaddress.ip_address(raw_ip)
except ValueError:
return False # malformed input, not an address at all
# is_global alone is not enough. Multicast reports is_global True,
# and 100.64.0.0/10 reports False for both is_global and is_private,
# so a filter written only on is_private lets CGNAT through.
if not addr.is_global or addr.is_multicast or addr.is_reserved:
return False
return not any(addr in net for net in _STDLIB_GAPS)
def geolocate(raw_ip):
if not is_lookupable(raw_ip):
return None
api_key = os.environ.get("IPGEO_API_KEY")
if not api_key:
raise RuntimeError("IPGEO_API_KEY is not set")
try:
response = requests.get(
"https://api.ipgeolocation.io/v3/ipgeo",
params={"apiKey": api_key, "ip": raw_ip, "fields": "location"},
timeout=(1.0, 1.5),
)
except requests.RequestException as exc:
# Fail open. A geolocation miss must never block the request.
print(f"geolocation lookup failed for {raw_ip}: {exc}")
return None
if response.status_code == 423:
return None # bogon or private, and it cost no credits
if response.status_code != 200:
print(f"unexpected geolocation status {response.status_code}")
return None
location = response.json().get("location") or {}
latitude = location.get("latitude")
return {
"country": location.get("country_code2"),
# Coordinates arrive as strings, so cast before any arithmetic.
"latitude": float(latitude) if latitude else None,
}
That filter passes all 26 addresses I tested it against, including boundary cases either side of each block. The is_global and is_private asymmetry around 100.64.0.0/10 is the part I'd flag to anyone reviewing this kind of code, because it reads correct and isn't.
Node
No standard library equivalent exists, so IPv4 needs bit math. One line in it is load-bearing and easy to get wrong.
import net from 'node:net';
const toInt = (ip) =>
ip.split('.').reduce((acc, octet) => ((acc << 8) >>> 0) + Number(octet), 0) >>> 0;
const BLOCKED_V4 = [
'0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8',
'169.254.0.0/16', '172.16.0.0/12', '192.0.0.0/24', '192.0.2.0/24',
'192.168.0.0/16', '198.18.0.0/15', '198.51.100.0/24', '203.0.113.0/24',
'224.0.0.0/4', '240.0.0.0/4', '255.255.255.255/32',
].map((cidr) => {
const [base, bits] = cidr.split('/');
const prefix = Number(bits);
const mask = (0xffffffff << (32 - prefix)) >>> 0;
// The >>> 0 here is not decoration. JS bitwise ops work on signed int32,
// so every network above 127.255.255.255 comes back negative and silently
// never matches. Dropping it breaks 11 of the 15 blocks above.
return { network: (toInt(base) & mask) >>> 0, mask };
});
export function isLookupable(raw) {
const version = net.isIP(raw);
if (version === 0) return false;
// Hand-rolled IPv6 prefix matching is a bug factory. Reach for ipaddr.js
// if your traffic is v6-heavy; this path deliberately declines to guess.
if (version === 6) return false;
const value = toInt(raw);
return !BLOCKED_V4.some(({ network, mask }) => ((value & mask) >>> 0) === network);
}
export async function geolocate(raw) {
if (!isLookupable(raw)) return null;
const apiKey = process.env.IPGEO_API_KEY;
if (!apiKey) throw new Error('IPGEO_API_KEY is not set');
const url = new URL('https://api.ipgeolocation.io/v3/ipgeo');
url.searchParams.set('apiKey', apiKey);
url.searchParams.set('ip', raw);
url.searchParams.set('fields', 'location');
try {
// 1.5s ceiling. Enrichment should never hold a request open longer.
const res = await fetch(url, { signal: AbortSignal.timeout(1500) });
if (res.status === 423) return null; // bogon or private, no credit spent
if (!res.ok) {
console.warn(`unexpected geolocation status ${res.status}`);
return null;
}
const body = await res.json();
const lat = body?.location?.latitude;
return {
country: body?.location?.country_code2 ?? null,
latitude: lat ? Number.parseFloat(lat) : null,
};
} catch (err) {
// Fail open, including on the timeout abort.
console.warn(`geolocation lookup failed for ${raw}: ${err.message}`);
return null;
}
}
I shipped that comment because I wrote the bug first. The missing >>> 0 made every block above 127.255.255.255 fail to match, which meant 192.168.1.1 sailed through as lookupable and the tests were the only thing that caught it.
A curl sanity check
# Expect 423. Anything else means the range handling changed.
curl -s -o /dev/null -w '%{http_code}\n' \
"https://api.ipgeolocation.io/v3/ipgeo?apiKey=${IPGEO_API_KEY}&ip=10.0.0.1"
Put that in a smoke test if you depend on the status-code branch.
Fail open, not closed
When a lookup returns nothing, the request still has to be served.
Fail open by default: treat the location as unknown and let the user through. Geolocation is enrichment, and enrichment failing should never take down a signup flow. The exception is a rule that legally requires a known jurisdiction, such as gambling or regulated financial access, where unknown has to mean blocked. Decide that explicitly rather than inheriting it from a timeout.
Log the reason, not just the miss. "No location for 10.0.0.1" and "no location for 45.83.122.7" look identical in a dashboard and mean completely different things: one is a proxy configuration bug, the other is a routing gap. Store which of the four cases you hit and the difference becomes visible.
A few extra notes
If your X-Forwarded-For handling is producing private addresses, the filter is treating a symptom. Check your trust-proxy configuration first, because seeing 10.x.x.x at the application layer usually means you are reading the wrong hop rather than that your users are internal.
The IPv4 special-purpose list is effectively frozen now that allocation has run out, so a hardcoded IPv4 table will stay accurate. IPv6 is the opposite. Two blocks landed in 2024 and another in 2025, so pin your source to the IANA registry and re-check it once or twice a year rather than trusting a list you copied.
For logs, store the raw address alongside the enriched result. Re-running enrichment later is cheap when the input survived, and prefixes that were unrouted last quarter are sometimes announced this quarter.
Start by filtering the fifteen IPv4 blocks locally, since that removes the majority of pointless calls for a few microseconds of work. Then decide what unknown means in your specific flow, and write that decision down somewhere your future self will find it. If you are picking a provider, the question to ask is not which ranges they reject but how they tell you: a status code and a response field lead to genuinely different error handling, and finding out after you have written the handler is annoying.
Top comments (0)