What is phone number porting (and why it breaks carrier lookups)?
Porting lets a number keep its digits while switching carriers — which is exactly why any offline carrier guess goes stale, and why Boundstone lists carrier and port status as not-performed rather than faking them.
Contents
If you've ever looked up a phone number's carrier from its prefix and gotten the wrong network back, number porting is usually why. This post explains what phone number porting is, why it makes offline carrier data unreliable, and why the honest answer is often "we didn't check that."
What is phone number porting?
Phone number porting is moving a phone number from one carrier to another while keeping the same digits. You leave one network, keep your number, and everything downstream that assumed the number still belongs to the first carrier is now wrong.
Regulators mandate this — Local Number Portability in the US, equivalent rules across the EU, UK, and Australia. It's good for consumers. It's quietly hostile to anyone trying to guess a number's carrier from the number itself.
How a number ever mapped to a carrier
Numbers are allocated in blocks. A national numbering plan hands a range to a carrier, and static datasets — the ones bundled inside libphonenumber and similar libraries — record which block went to whom. Look up the prefix, read the block owner, print a carrier name. Fast, offline, free.
That mapping is an allocation record. It tells you who the block was originally assigned to. It says nothing about who runs the number today.
Why porting breaks offline carrier lookups
The moment a number ports, the allocation record and reality diverge. The prefix still points at the original block owner; the subscriber is on a different network. Any offline lookup — bundled data, a cached CSV, a static prefix table — returns the old carrier with full confidence and no way to know it's stale.
Google's libphonenumber says this plainly in its own docs: its carrier mapper returns the carrier a number was originally allocated to, and is unreliable in regions with number portability. The US is the worst case — porting is routine, and libphonenumber's US carrier data is intentionally sparse for exactly this reason.
An offline carrier "answer" is really a guess with a timestamp you can't see.
What your stdlib and libphonenumber already do
Before reaching for any API, know what you already have. libphonenumber and its ports validate format, identify the region, format to national and E.164, and classify line type from bundled metadata. For a lot of jobs that's the whole task.
import { parsePhoneNumber } from 'libphonenumber-js'
const phone = parsePhoneNumber('+16504472983')
phone.country // 'US'
phone.getType() // 'FIXED_LINE_OR_MOBILE'
phone.formatNational() // '(650) 447-2983'
phone.isValid() // true
Carrier is where it stops being reliable. The full Google port exposes a carrier mapper, and Python's phonenumbers has one too:
import phonenumbers
from phonenumbers import carrier
num = phonenumbers.parse("+16504472983")
print(carrier.name_for_number(num, "en"))
# often empty for US numbers — and where it prints a name,
# that's the original allocation, not the current network
That output is the allocation record. After a port, it's wrong, and nothing in the library can tell you it's wrong.
Why Boundstone reports carrier as not_performed
Boundstone's phone endpoint returns what static data can honestly stand behind, and refuses to fake the rest.
curl -s https://api.boundstone.io/v1/verify/phone \
-H "Authorization: Bearer bs_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"phone":"+16504472983"}'
{
"valid": true,
"e164": "+16504472983",
"country": "US",
"line_type": "fixed_line_or_mobile",
"national_format": "(650) 447-2983",
"checks": {
"performed": ["format", "region", "line_type_metadata"],
"not_performed": ["carrier_lookup", "ported_status", "hlr_liveness"]
}
}
Read the not_performed array. carrier_lookup, ported_status, and hlr_liveness are listed there because they can't be answered from bundled data — and porting is the reason. We could print a carrier name off the same static block table libphonenumber uses. We'd rather tell you we didn't. A valid you can trust is one that arrives with an honest list of what we skipped.
The same contract shows up on every phone check, in every language, so you never reverse-engineer confidence from missing fields. Same idea as line type and E.164 normalization: a value is only as useful as your certainty about how it was derived.
When you actually need live carrier and port status
There is exactly one honest way to know a number's current carrier and whether it's ported: query a live source at lookup time — a portability database (LRN in North America) or an HLR dip against the mobile network. That's a paid, per-lookup operation, not a bundled file.
Boundstone does not run that by default. Carrier and HLR liveness are a paid opt-in, priced per lookup at 5 credits with hlr:true and refunded when the network cannot answer; left off, they stay in not_performed rather than being emulated with stale data. If your use case genuinely needs the current carrier — routing SMS, or filtering ported landlines — you need a live dip, and you should treat any offline carrier field, from any vendor, as a historical guess.
You can try the static tier with no key on the free phone validator and read the not_performed list yourself.
The short version
- Porting moves a number between carriers while keeping the digits, so the numbering-plan prefix no longer identifies the current network.
- Offline carrier data (libphonenumber, cached tables) returns the original allocation — stale the moment a number ports, with no flag to warn you.
- Only a live query (portability database / HLR) knows the current carrier and port status.
- Boundstone returns format, region, and line type from static metadata, and lists
carrier_lookup,ported_status, andhlr_livenessundernot_performedby default, with the live carrier dip priced per lookup on request (hlr:true).