Validate Email and Phone Formats in Excel Data with Python
A contact list maintained in Excel is only as good as the data people typed into it: "cai@example" with no domain ending, "dee@@example.com", a phone number stored as 7911123456 because Excel dropped the leading zero, and the same office number written three different ways. Import that into a CRM or mailing tool and the bad rows bounce, fail silently or create duplicates. This guide, part of Validating Excel Data with Python, checks both columns properly, normalises the valid values and produces a report of the invalid cells with their row numbers in the sheet.
Prerequisites
pip install pandas openpyxl email-validator phonenumbers
The sample sheet, with Eve's phone stored as a number the way Excel stores digits typed without spaces:
import pandas as pd
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.append(["name", "email", "phone"])
for row in [["Ann", "ann@example.com", "020 7946 0958"],
["Bob", "Bob.Ray@Example.COM ", "+44 20 7946 0958"],
["Cai", "cai@example", "07520 123456"],
["Dee", "dee@@example.com", "12345"],
["Eve", "eve@example.co.uk", 7911123456]]:
ws.append(row)
wb.save("contacts.xlsx")
df = pd.read_excel("contacts.xlsx", dtype={"phone": str, "email": str})
df.insert(0, "sheet_row", df.index + 2)
print(df["phone"].tolist())
# ['020 7946 0958', '+44 20 7946 0958', '07520 123456', '12345', '7911123456']
Reading phone as text is the most important line on this page. Without it, a column where every number is stored as digits comes back as integers — or as floats like 7911123456.0 if any cell is blank — and the phone library then sees a different number. The sheet_row column records the spreadsheet row for each record, for the report at the end.
Step 1 — A quick regex screen
A regular expression catches the gross errors cheaply and needs no extra library. Keep it deliberately loose; its job is to flag obvious junk, not to judge borderline addresses:
SCREEN = r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
email = df["email"].str.strip()
print(email.str.match(SCREEN).tolist()) # [True, True, False, False, True]
The pattern requires one @, no spaces, and a dot somewhere after the @. That rejects Cai and Dee. It would also accept plenty of invalid addresses — a@b..c, for instance — which is why the next step uses a real validator.
Step 2 — Validate and normalise emails
email-validator implements the address syntax rules, including internationalised domains, and returns a normalised form. Turn off the DNS lookup for bulk checks; it is slow and needs network access:
from email_validator import EmailNotValidError, validate_email
def check_email(value):
if pd.isna(value) or not str(value).strip():
return None, "missing"
try:
result = validate_email(str(value).strip(), check_deliverability=False)
return result.normalized, None
except EmailNotValidError as exc:
return None, str(exc)
checked = df["email"].map(check_email)
df["email_norm"] = checked.str[0]
df["email_error"] = checked.str[1]
print(df[["email_norm", "email_error"]].values.tolist())
Bob's address comes back as Bob.Ray@example.com: the domain is lower-cased, while the part before the @ keeps its case, because in principle a mail server may treat it as case-sensitive. The errors are readable sentences — "The part after the @-sign is not valid. It should have a period." — which can go straight into a report for the person who maintains the sheet. Valid syntax does not prove the mailbox exists; only sending a message does.
Step 3 — Parse phone numbers with a default region
Phone numbers need a library: the rules for valid lengths and prefixes differ by country and change over time. phonenumbers, a port of Google's libphonenumber, parses national and international formats. A default region tells it how to read numbers written without a country code:
import phonenumbers as pn
def check_phone(value, region="GB"):
if pd.isna(value) or not str(value).strip():
return None, "missing"
try:
number = pn.parse(str(value), region)
except pn.NumberParseException as exc:
return None, str(exc)
if not pn.is_valid_number(number):
return None, "not a valid number for its region"
return pn.format_number(number, pn.PhoneNumberFormat.E164), None
checked = df["phone"].map(check_phone)
df["phone_norm"] = checked.str[0]
df["phone_error"] = checked.str[1]
print(df[["name", "phone_norm", "phone_error"]].values.tolist())
Ann's "020 7946 0958" and Bob's "+44 20 7946 0958" both become +442079460958. Eve's 7911123456 — the number Excel stripped of its leading zero — is parsed as a UK mobile and restored to +447911123456. Dee's "12345" is rejected: it is not a valid UK number. If a contact list spans several countries, store the country in its own column and pass it as the region per row. pn.number_type(number) also tells you whether a number is a mobile, which matters when the list is used for text messages.
Step 4 — Find duplicates after normalising
Normalised values expose duplicates that formatting hid. Ann and Bob share an office number written two different ways:
dupe_phones = df[df["phone_norm"].notna() & df["phone_norm"].duplicated(keep=False)]
print(dupe_phones[["sheet_row", "name", "phone"]].values.tolist())
# [[2, 'Ann', '020 7946 0958'], [3, 'Bob', '+44 20 7946 0958']]
A shared switchboard number is legitimate; a shared mobile or email usually means the same person entered twice. Report duplicates for review rather than dropping them. Run the same check on email_norm — the domain is already lower-cased there, so "Ann@EXAMPLE.com" and "Ann@example.com" match. Duplicate detection more generally is covered in Find Duplicate Rows in Excel with Python.
Step 5 — Report the invalid cells
Collect every problem as one row — sheet row, column, the value as typed and the reason — which is the most useful shape for whoever has to fix the source:
problems = pd.concat([
df.loc[df["email_error"].notna(), ["sheet_row", "email", "email_error"]]
.set_axis(["sheet_row", "value", "problem"], axis=1).assign(column="email"),
df.loc[df["phone_error"].notna(), ["sheet_row", "phone", "phone_error"]]
.set_axis(["sheet_row", "value", "problem"], axis=1).assign(column="phone"),
]).sort_values("sheet_row")[["sheet_row", "column", "value", "problem"]]
print(problems.values.tolist())
problems.to_excel("contact_problems.xlsx", index=False)
Three problems, each pointing at a cell someone can open and correct: rows 4 and 5 for the two bad emails and row 5 for Dee's phone. Colouring those cells in the original workbook makes the fix even faster; see Highlight Invalid Cells in Excel with Python.
Step 6 — Write the normalised values back
Once the problems are fixed at the source, the normalised columns are what should travel on. Write them next to the originals so the change is visible, and use a text format for the phone column so Excel never turns +447911123456 into a number again:
from openpyxl import load_workbook
out = df[["sheet_row", "name", "email", "email_norm", "phone", "phone_norm"]]
out.to_excel("contacts_checked.xlsx", index=False)
wb = load_workbook("contacts_checked.xlsx")
ws = wb.active
for (cell,) in ws.iter_rows(min_row=2, min_col=6, max_col=6):
cell.number_format = "@"
wb.save("contacts_checked.xlsx")
print(pd.read_excel("contacts_checked.xlsx", dtype=str)["phone_norm"].tolist()[:2])
The @ number format marks the cells as text, so a person who later edits a number in Excel keeps the plus sign and leading digits. Keeping the original columns alongside the normalised ones lets reviewers check that normalisation did what they expected before the old values are dropped.
Common pitfalls
- Reading phone numbers as numbers. Leading zeros and plus signs disappear, and blanks turn the column into floats. Always use
dtype=str. - Rejecting valid addresses with a strict regex. Plus signs, long domain endings and non-ASCII characters are all legal. Use the regex only as a screen.
- Extensions and notes in phone cells.
phonenumbersunderstands "020 7946 0958 ext. 12" and keeps the extension innumber.extension, but E.164 has no place for it. Store extensions in their own column, and treat free text such as "call after 5pm" as a problem to report. - Validating once. Formats rot as people edit the sheet. Run the checks on every import, not just the first.
Conclusion
Read contact columns as text, screen emails with a loose regex, then validate and normalise them with email-validator and phone numbers with phonenumbers using a default region. Store emails with lower-cased domains and phones in E.164 so that duplicates become visible, and report every invalid cell with its sheet row, column and reason so the source can be fixed.
Frequently asked questions
Is a regular expression enough to validate email addresses? It is enough for a quick screen that catches blanks, missing @ signs and spaces. For real validation, email-validator checks the full syntax, including domains and internationalised addresses, and returns a normalised form. Neither proves the mailbox exists.
Why do phone numbers from Excel lose their leading zero? When a phone number is typed without spaces, Excel stores it as a number, and 07911123456 becomes 7911123456. Read the column as text with dtype=str and parse it with a default region, which restores the national format correctly.
What is E.164 format? The international format with a plus sign, country code and national number without spaces or punctuation, such as +447911123456. It is the best form to store and compare phone numbers, because every way of writing the same number normalises to one string.
How do I find duplicate contacts with different formatting? Normalise first — lower-case email domains and E.164 phone numbers — and then look for duplicates on the normalised columns. "020 7946 0958" and "+44 20 7946 0958" are the same number once normalised.
Related
- Up one level: Validating Excel Data with Python — checks before data moves on.
- Validate Excel Data with Pandera Schemas — declaring rules for whole sheets.
- Validate Excel Columns Before Import with pandas — structural checks first.
- Remove Non-Printable Characters from Excel Data with Python — invisible characters that break validation.
More on validation & data quality
- How-toDetect Outliers in Excel Data with pandas
- How-toHighlight Cells Containing Specific Text with openpyxl
- How-toFind Near-Duplicate Names Within One Excel Column
- How-toRead Dropdown Lists and Validation Rules from an Existing Workbook
- How-toFind and Report Missing Values in an Excel File
- How-toFlag Rows with Too Many Missing Values