Формы Django: ModelForm и валидация без дублей
Где на самом деле срабатывают clean_field, clean и валидаторы модели, почему form.is_valid() пропускает мусор в базу и как не размазать одну проверку по трём местам.

Вы пишете форму регистрации на Django 5.x. Проверяете email в clean_email, а через месяц оказывается, что через Django admin в базу попал тот же email в другом регистре — потому что проверка жила в форме, а не в модели. Классическая ошибка: валидация ModelForm выглядит простой, но у неё три разных уровня, и они срабатывают в строгом порядке. Разберём, где какая проверка живёт, почему form.is_valid() иногда пропускает то, что не должно, и как не дублировать одну и ту же логику в форме, модели и вьюхе.
Материал для Django 5.x (актуально и для 4.2 LTS — поведение валидации там то же). Формат — практика: предполагается, что вы уже писали формы и упёрлись в вопрос «куда положить проверку».
Три уровня валидации и порядок их вызова
Когда вы вызываете form.is_valid() у ModelForm, внутри происходит full_clean(), а он запускает проверки в фиксированном порядке. Понимание этого порядка снимает 90% вопросов «почему моя проверка не сработала».
- Валидация полей формы — каждое поле приводит данные к питоновскому типу (
to_python) и прогоняет свои валидаторы. Здесь отсекаются пустые обязательные поля и неверные типы. - Методы
clean_<fieldname>()— по одному на поле, в порядке объявления полей. Работают только с уже очищенным значением этого поля. - Метод
clean()формы — единственное место, где доступны сразу несколько полей. Сюда идут проверки вида «пароль и подтверждение совпадают». - Валидация экземпляра модели — у ModelForm дополнительно вызывается
instance.full_clean(), который прогоняет валидаторы полей модели, её методclean()и проверки уникальности.
Важный нюанс: если поле не прошло шаг 1, его clean_<field>() не вызывается вовсе, и в cleaned_data его не будет. Поэтому в clean() формы нельзя слепо обращаться к cleaned_data['email'] — сначала проверьте, что ключ там есть.
Правило, которое экономит часы отладки: проверка одного поля — вclean_<field>(), проверка связи двух полей — вclean(), инвариант самой сущности — вModel.clean(). Три разных места для трёх разных задач, а не «куда рука дотянулась».
Куда класть проверку: форма или модель
Главная развилка. Одна и та же проверка email на уникальность может жить в форме или в модели, и от выбора зависит, сработает ли она в админке, в DRF-сериализаторе и в скриптах через ORM.
| Где | Когда срабатывает | Цена |
|---|---|---|
Валидатор поля модели (validators=[...]) | При любом full_clean(): форма, админка, ручной вызов | Не срабатывает при прямом save() без full_clean() |
Model.clean() | При full_clean() модели, в том числе через ModelForm | Тоже пропускается при голом save(); в DRF нужно вызывать вручную |
clean_<field>() формы | Только через эту форму | Админ и API её не увидят — легко получить дубли мимо формы |
Ограничение БД (UniqueConstraint) | Всегда, на уровне базы | Ошибка приходит как IntegrityError, не как понятное сообщение формы |
Практический вывод: инварианты данных держите ближе к модели и БД, а форма пусть отвечает за то, что специфично именно для этого экранного сценария (капча, согласие с условиями, повтор пароля — этих полей в модели нет).
Проверка одного поля
from django import forms
from .models import Profile
class ProfileForm(forms.ModelForm):
class Meta:
model = Profile
fields = ["email", "phone"]
def clean_email(self):
# email уже прошёл валидацию поля и лежит в cleaned_data
email = self.cleaned_data["email"].lower().strip()
# нормализуем и возвращаем — это значение уйдёт дальше
return email
Обратите внимание: clean_email обязан вернуть значение. Если забыть return, поле молча станет None — это одна из самых частых ошибок новичков в формах.
Проверка связи двух полей
class SignupForm(forms.ModelForm):
password = forms.CharField(widget=forms.PasswordInput)
password_confirm = forms.CharField(widget=forms.PasswordInput)
class Meta:
model = User
fields = ["email"]
def clean(self):
cleaned = super().clean()
pwd = cleaned.get("password")
confirm = cleaned.get("password_confirm")
# оба поля могли не пройти шаг 1 — проверяем наличие
if pwd and confirm and pwd != confirm:
# ошибку вешаем на конкретное поле, а не в общий __all__
self.add_error("password_confirm", "Пароли не совпадают")
return cleaned
Здесь self.add_error("field", ...) лучше, чем raise ValidationError: ошибка привяжется к нужному полю в шаблоне, а не свалится в общий блок form.non_field_errors.
Где валидация ModelForm вас подведёт
Есть неочевидные места, где проверка не срабатывает так, как ожидаешь.
- Поле не в
fields— его валидация модели не выполнится. ModelForm вызываетinstance.full_clean(exclude=...)и исключает из проверки все поля, которых нет в форме. Проверка уникальности по такому полю тоже пропадёт. - Голый
save()не валидирует ничего. НиModel.clean(), ни валидаторы полей не вызываются при прямом сохранении объекта через ORM. Валидация в Django — это отдельный вызовfull_clean(), аsave()его не делает. Хотите гарантию на уровне данных — используйтеUniqueConstraintиCheckConstraintвMeta.constraints. - Уникальность в форме — это гонка. Проверка «нет ли такого email» в
clean_emailделаетSELECT, но между ним иsave()другой запрос может вставить дубль. Без ограничения БД это не защита, а видимость защиты. commit=Falseобходит вызовfull_cleanмодели повторно. Если вы делаетеobj = form.save(commit=False), дописываете поля вручную и потомobj.save()— валидация дописанных полей уже не пройдёт. Вызовитеobj.full_clean()сами, если это критично.
Когда что выбирать
Критерий простой — от того, кто ещё пишет в эту таблицу.
- Только эта форма создаёт объекты — можно держать проверку в форме, но уникальность всё равно продублируйте ограничением БД.
- Есть админка, DRF или management-команды — проверка должна быть в модели (
Model.clean()или валидаторы полей), иначе часть точек входа её обойдёт. - Инвариант, который никогда не должен нарушиться (сумма неотрицательна, email уникален) —
Meta.constraintsна уровне БД. Это единственная защита, которую нельзя обойти забытымfull_clean(). - Проверка касается полей, которых нет в модели (согласие, капча, повтор) — только форма, в модели их и быть не может.
Кастомный валидатор для переиспользования
Если одну проверку нужно применять в нескольких местах, вынесите её в вызываемый объект и подключайте к полю модели — тогда она попадёт в full_clean() автоматически.
from django.core.exceptions import ValidationError
from django.db import models
def validate_no_plus(value):
# запрещаем плюс-адресацию в email на уровне поля
if "+" in value.split("@")[0]:
raise ValidationError("Плюс-адресация запрещена")
class Subscriber(models.Model):
email = models.EmailField(unique=True, validators=[validate_no_plus])
class Meta:
constraints = [
models.UniqueConstraint(
models.functions.Lower("email"),
name="unique_lower_email",
)
]
Теперь и форма, и админка прогонят validate_no_plus, а UniqueConstraint с Lower закроет уникальность без учёта регистра прямо в базе — даже если кто-то вставит объект в обход валидации.
Что делать дальше
Пройдите по своим формам и ответьте на один вопрос: что случится, если объект создадут не через форму, а через админку или ORM. Если проверка при этом теряется — перенесите инвариант в модель или в Meta.constraints. Дальше стоит посмотреть в сторону clean() модели против валидаторов полей (это отдельная развилка, здесь мы её только обозначили) и разобраться, как ModelForm ведёт себя с ManyToMany при commit=False — там свои правила сохранения через save_m2m(). Точный список того, что вызывает full_clean() в вашей версии, сверяйте по разделу Validation в документации.
Проверка рекламы на риски: Что в макете вызовет вопросы ФАС: превосходные степени, сравнения, обещания результата, маркировка.
Спросить за 40 ₽Источники: Django docs — Form and field validation, Django docs — Validating objects (Model.full_clean)
Частые вопросы
Почему form.is_valid() возвращает True, а в базе всё равно появляется дубль?
У меня Django 4.2, а не 5.x — порядок валидации отличается?
Почему мой clean_email не срабатывает?
Валидация в Model.clean() не срабатывает при obj.save() — это баг?
Как повесить ошибку на конкретное поле из метода clean() формы?
Куда деваются проверки полей, которых нет в Meta.fields?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.