Non-Geolocatable IP Ranges: Full Table and Fix Code
DEV Community

Non-Geolocatable IP Ranges: Full Table and Fix Code

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 Reachable column that answers "will this ever geolocate" directly. Full IPv4 and IPv6 tables below, including 3fff::/20 (July 2024) and 5f00::/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.45 in 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

Node

Node implementation would be similar, using the ipaddr.js library or Node's built‑in net module for basic checks. The same gap blocks (3fff::/20, 5f00::/16) need to be added manually.

Comments

No comments yet. Start the discussion.