Skip to content

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 it

2. 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 unittest

Run 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 PhoneNumber checks it, and keeps the text when no plan accepts it, so the error shows next to what was typed.
  • In Flask, subclass FlaskForm instead of Form for CSRF protection, and call form.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.