Normalizing OSM Phone Numbers to E.164 Jump to heading
A phone tag in OSM is free text, and the range of what appears there is wider than any regular expression usefully covers. The thing that makes normalization tractable is not a better pattern — it is knowing which country the feature is in.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
E.164 is the international canonical form: a plus sign, a country calling code, and the national number, with no spaces or punctuation and a maximum of fifteen digits. Two numbers in E.164 are equal if and only if they are the same number, which is what makes it worth converting to.
Three facts about OSM’s phone values shape the work.
Most values are national. A number written as it would be dialled locally has no country code, so converting it requires knowing the country — and the only reliable source of that is the feature’s location, not the string.
Several keys carry phone numbers. phone, contact:phone, contact:mobile, fax and contact:fax all appear, sometimes on the same feature with different values.
Multi-valued is common. A business with two lines writes both, separated by a semicolon, and occasionally by a slash or the word “or”.
The rule that keeps this honest is that an unparseable value is not dropped and not guessed at. It is preserved and flagged, because a phone number that cannot be resolved is still information a human can use.
Runnable solution Jump to heading
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
import phonenumbers
from phonenumbers import NumberParseException, PhoneNumberFormat
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.clean.phone")
PHONE_KEYS = ("phone", "contact:phone", "contact:mobile", "mobile",
"fax", "contact:fax")
# Separators people actually use, beyond the conventional semicolon.
SPLIT = re.compile(r"\s*(?:;|/|\bor\b|,(?=\s*\+))\s*", re.I)
EXTENSION = re.compile(r"\s*(?:ext\.?|x|extension|durchwahl)\s*(\d{1,6})\s*$", re.I)
@dataclass(frozen=True)
class Phone:
e164: str | None
extension: str | None
original: str
key: str
status: str # 'ok' | 'invalid' | 'unparseable' | 'no_country'
def split_values(value: str) -> list[str]:
return [p.strip() for p in SPLIT.split(value) if p.strip()]
def normalize_one(raw: str, country: str | None, key: str) -> Phone:
"""Parse one value. `country` comes from the feature's location."""
text = raw.strip()
extension = None
match = EXTENSION.search(text)
if match:
# Extensions are not part of E.164; keep them separately rather than
# letting the parser silently absorb or reject them.
extension = match.group(1)
text = text[:match.start()].strip()
is_international = text.startswith("+") or text.startswith("00")
if not is_international and not country:
# A national number with no country is genuinely unresolvable. Guessing
# produces a valid-looking number that dials somewhere else.
return Phone(None, extension, raw, key, "no_country")
try:
parsed = phonenumbers.parse(text, None if is_international else country)
except NumberParseException:
return Phone(None, extension, raw, key, "unparseable")
if not phonenumbers.is_valid_number(parsed):
# Parsed but not a real number for that country: a typo or a
# placeholder. Keep the original for review rather than discarding it.
return Phone(None, extension, raw, key, "invalid")
return Phone(phonenumbers.format_number(parsed, PhoneNumberFormat.E164),
extension, raw, key, "ok")
def normalize_tags(tags: dict[str, str], country: str | None) -> list[Phone]:
out: list[Phone] = []
for key in PHONE_KEYS:
value = tags.get(key)
if not value:
continue
for part in split_values(value):
out.append(normalize_one(part, country, key))
return out
def audit(results: list[Phone]) -> dict[str, int]:
counts: dict[str, int] = {}
for phone in results:
counts[phone.status] = counts.get(phone.status, 0) + 1
total = sum(counts.values()) or 1
logger.info("phone normalisation: %s (%.1f%% resolved)", counts,
100 * counts.get("ok", 0) / total)
if counts.get("no_country", 0):
logger.warning("%d value(s) had no country: resolve administrative "
"containment before phone normalisation",
counts["no_country"])
return counts
if __name__ == "__main__":
tags = {"phone": "+48 12 345 67 89; 012 345 67 90 ext. 12",
"contact:fax": "12 345 67 91"}
for phone in normalize_tags(tags, country="PL"):
logger.info("%-12s %-16s %-8s from %r", phone.key, phone.e164 or "-",
phone.status, phone.original)
Step-by-step walkthrough Jump to heading
- Split on more than semicolons. Slashes and the word “or” appear regularly, and a comma before a plus sign is almost always a separator rather than punctuation.
- Strip extensions before parsing. E.164 has no place for them, and leaving one attached causes the parser either to reject the number or to absorb the digits into it.
- Detect international form from the prefix. A value starting with a plus or a double zero carries its own country code and needs no context, which is worth checking before reaching for the feature’s country.
- Refuse to guess a country. A national number with no known country is unresolvable, and defaulting to a likely one produces a syntactically valid number that reaches a different country entirely.
- Distinguish unparseable from invalid. A string the parser cannot make sense of and a well-formed number that does not exist in that country are different problems with different fixes.
- Keep the original always. Every outcome retains the source value, so a human reviewing the flagged ones has something to work with.
- Report the status distribution. A high proportion lacking a country means the containment step has not run, which is a pipeline ordering problem rather than a data problem.
Verification Jump to heading
- A known number round-trips. Take a number you can verify and confirm the canonical form matches its published international form.
- National numbers resolve. With a country supplied, a locally-written number should produce the right country code.
- No country means no guess. Without a country, a national number must return the no-country status rather than any number at all.
- Extensions survive. A value with an extension should produce both a canonical number and the extension separately.
- Multi-values split. A tag with two numbers should produce two results, both resolved.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Numbers resolve to the wrong country | A default country assumed | Return a no-country status rather than guessing |
| Extensions lost or mangled | Extension left attached during parsing | Strip and store it before parsing the number |
| Only the first number kept | Value not split | Split on semicolons, slashes and the word “or” |
| Valid numbers rejected | Written in international form with a double zero | Treat a leading double zero as international |
| Everything reported unresolvable | Country not resolved before this stage | Run administrative containment first |
| Typos silently dropped | Invalid numbers discarded | Keep the original and flag it for review |
| Fax numbers missed | Only the primary key read | Read every key that carries a phone number |
Specification reference Jump to heading
E.164 defines the international public telecommunication numbering plan: a number consists of a country code followed by a national number, with a maximum of fifteen digits in total and no formatting characters. Parsing a national-format number into this form requires knowing the region it belongs to. See the libphonenumber documentation for the parsing and validation semantics the library implements.
Frequently Asked Questions Jump to heading
Why can a national number not be parsed without a country?
Because the same digits mean different numbers in different countries, and nothing in the string says which. A number written as it would be dialled locally omits the country code precisely because the caller is assumed to know it. Supplying a default country produces a syntactically valid number that reaches somewhere else entirely, which is worse than returning nothing because it looks correct.
Where does the country come from?
From the feature’s location, through the administrative containment that a warehouse schema materialises anyway. That makes phone normalisation dependent on containment having run first, which is a pipeline ordering constraint worth stating explicitly — a large proportion of unresolvable phone values is usually a symptom of the two stages being in the wrong order.
What should happen to an extension?
Keep it in its own field. E.164 has no representation for an extension, so leaving it attached either causes the parse to fail or lets its digits be absorbed into the number, producing something that dials the wrong place. Splitting it out before parsing preserves both pieces, and a consumer that needs the extension has it while one that does not can ignore it.
Should invalid numbers be dropped?
No. A value that parses but is not a real number for its country is usually a typo, a transposed digit or a placeholder, all of which a human can recognise and often correct. Dropping it discards the evidence; keeping the original with an invalid flag routes it somewhere useful. The same applies to values the parser cannot make sense of at all, which are frequently addresses or notes entered in the wrong field.
Related Jump to heading
- Value Standardization & Regex Cleaning — the parent topic and the wider cleaning stage.
- Cleaning OSM Website and Contact URL Tags — the same discipline for the neighbouring keys.
- Splitting Semicolon-Separated OSM Tag Values — the multi-value handling this depends on.
- Designing a Star Schema for OSM Features — where the country this needs comes from.
- Fixing Malformed OSM Tags During ETL Ingestion — the general pattern for values that will not parse.
Up one level: Value Standardization & Regex Cleaning.