Skip to content

Validate phone numbers in Django forms

Phone number validation in Django that knows the national numbering plans: what people type is read in the plans you pick, a number no plan accepts gets Django'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 Django'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 django

2. The Django side

fields.py

The field: CharField, with the language guide's parse in to_python.

"""A Django form field for phone numbers, read by phone.py in its plans."""
from django import forms
from django.core.exceptions import ValidationError

import phone


class PhoneNumberField(forms.CharField):
    """Reads what was typed (national numbers in default_region) and cleans to the E.164 form."""

    default_error_messages = {"invalid": "Enter a valid phone number."}

    def __init__(self, *, default_region=None, **kwargs):
        self.default_region = default_region
        kwargs.setdefault("max_length", 40)
        super().__init__(**kwargs)

    def to_python(self, value):
        text = super().to_python(value)
        if text in self.empty_values:
            return text
        number = phone.parse(text, self.default_region)
        if number is None:
            raise ValidationError(self.error_messages["invalid"], code="invalid")
        return number.e164

forms.py

A form that uses it.

from django import forms

from fields import PhoneNumberField


class SignupForm(forms.Form):
    name = forms.CharField()
    phone = PhoneNumberField(default_region="GB")

3. The tests

test_forms.py: The tests, with unittest and a minimal Django setup.

import unittest

import django
from django.conf import settings

settings.configure(USE_I18N=False)
django.setup()

from forms import SignupForm  # noqa: E402


class SignupFormTest(unittest.TestCase):
    def test_cleans_a_national_number_to_e164(self):
        form = SignupForm({"name": "Ada", "phone": "07400 123456"})
        self.assertTrue(form.is_valid(), form.errors)
        self.assertEqual(form.cleaned_data["phone"], "+447400123456")

    def test_reads_an_international_number(self):
        form = SignupForm({"name": "Ada", "phone": "+1 201-555-0123"})
        self.assertTrue(form.is_valid(), form.errors)
        self.assertEqual(form.cleaned_data["phone"], "+12015550123")

    def test_shows_the_error_on_the_field(self):
        form = SignupForm({"name": "Ada", "phone": "+44 7400 12345"})
        self.assertFalse(form.is_valid())
        self.assertEqual(form.errors["phone"], ["Enter a valid phone number."])

    def test_still_required(self):
        form = SignupForm({"name": "Ada", "phone": ""})
        self.assertEqual(form.errors["phone"], ["This field is required."])


if __name__ == "__main__":
    unittest.main()
python -m unittest

Run before publishing with Django 6.1.2 and the file generated for GB, US, FR, as 2026-10-25.

What is specific to Django

  • to_python is where a field turns input into a value: raising ValidationError there puts the message on the field, and required, max_length and your validators then run on the E.164 form.
  • Store it in a models.CharField(max_length=16, unique=True): form.cleaned_data["phone"] is already E.164 (see storing numbers in a database).
  • A ModelForm takes the field the same way: declare phone = PhoneNumberField(default_region="GB") on the form to replace the model field's default.