Validate phone numbers in Flask with WTForms
Phone number validation in Flask and WTForms that knows the national numbering plans: what people type is read in the plans you pick, a number no plan accepts gets Flask and WTForms's usual error, and what you store is its E.164 form. The reading is done by one file with no dependency, from the Python guide; the rest is Flask and WTForms's own validation hook, a few lines long.
1. The file
Take phone.py from the Python guide, with the plans of the countries you serve (the link opens it for the United Kingdom, the United States and France, the plans the tests below use). It needs Python 3.9 or later; standard library only. Its parse reads a number as typed and returns the number with its E.164 form, region and line type, or None when no plan accepts it.
pip install flask-wtf # WTForms comes with it2. The Flask and WTForms side
validators.py
The validator and the filter: the language guide's parse, twice.
"""A WTForms validator and filter for phone numbers, read by phone.py in its plans."""
from wtforms.validators import ValidationError
import phone
def e164_or_text(default_region=None):
"""A filter: the E.164 form of what was typed when a plan accepts it, the text otherwise."""
def to_e164(text):
number = phone.parse(text or "", default_region)
return number.e164 if number else text
return to_e164
class PhoneNumber:
"""Fails a field no plan accepts; national numbers are read in default_region."""
def __init__(self, default_region=None, message="Not a valid phone number."):
self.default_region = default_region
self.message = message
def __call__(self, form, field):
if phone.parse(field.data or "", self.default_region) is None:
raise ValidationError(self.message)forms.py
A form that uses both (FlaskForm in an app, Form here).
from wtforms import Form, StringField
from wtforms.validators import DataRequired
from validators import PhoneNumber, e164_or_text
class SignupForm(Form):
name = StringField("Name", [DataRequired()])
phone = StringField("Phone", [DataRequired(), PhoneNumber("GB")], filters=[e164_or_text("GB")])3. The tests
test_forms.py: The tests, with unittest.
import unittest
from forms import SignupForm
class SignupFormTest(unittest.TestCase):
def test_a_national_number_becomes_e164(self):
form = SignupForm(data={"name": "Ada", "phone": "07400 123456"})
self.assertTrue(form.validate(), form.errors)
self.assertEqual(form.phone.data, "+447400123456")
def test_an_international_number(self):
form = SignupForm(data={"name": "Ada", "phone": "+1 201-555-0123"})
self.assertTrue(form.validate(), form.errors)
self.assertEqual(form.phone.data, "+12015550123")
def test_the_error_on_the_field(self):
form = SignupForm(data={"name": "Ada", "phone": "+44 7400 12345"})
self.assertFalse(form.validate())
self.assertEqual(form.errors, {"phone": ["Not a valid phone number."]})
if __name__ == "__main__":
unittest.main()python -m unittestRun before publishing with WTForms 3.3.1 and the file generated for GB, US, FR, as 2026-10-25.
What is specific to Flask and WTForms
- Filters run when the form takes its data, before the validators: the field already holds the E.164 form when
PhoneNumberchecks it, and keeps the text when no plan accepts it, so the error shows next to what was typed. - In Flask, subclass
FlaskForminstead ofFormfor CSRF protection, and callform.validate_on_submit()in the view; the validators are the same. - A WTForms validator is any callable taking the form and the field that raises
ValidationError; a class lets the message and the region vary per field.