skip to content

Why can a Django ModelForm pass is_valid() yet saving raise IntegrityError when the view sets applicant after save(commit=False)?

level: seniorimportance: should knowfreq 40%

answer

  1. which fields model validation sees
  2. exclusions follow the form's fields
  3. constraints with an excluded field
  4. the database has the last word
  5. check it yourself in clean()

basics

~20 s

A Django ModelForm excludes fields that are not on the form from model validation, and skips any unique check or UniqueConstraint that involves one. A constraint on (applicant, pet) is never checked, so the duplicate reaches the database.

solid answer

~40 s

During `is_valid()`, a `ModelForm` builds an exclusion set from its own fields: any model field not on the form, excluded by `Meta`, or already failing is left out of model validation. `validate_unique()` and `validate_constraints()` then skip every uniqueness check that mentions an excluded field; `UniqueConstraint.validate()` returns early. With `UniqueConstraint(fields=['applicant', 'pet'])` and `applicant` set in the view, the form never checks it, and a second application for the same pet raises `IntegrityError` at `app.save()`. Setting `form.instance.applicant` first does not help, because exclusions depend on the form's fields, not instance values. The fix is to pass the applicant into the form, check for an existing application in `clean()` and report it on `pet`, keep the database constraint, and catch `IntegrityError` for the concurrent case.

code

python · 23 lines
python
from django import forms
from django.core.exceptions import ValidationError

from .models import AdoptionApplication

class AdoptionApplicationForm(forms.ModelForm):
    class Meta:
        model = AdoptionApplication
        fields = ['pet', 'home_type', 'has_other_pets', 'message', 'preferred_traits']

    def __init__(self, *args, applicant, **kwargs):
        super().__init__(*args, **kwargs)
        self.instance.applicant = applicant

    def clean(self):
        cleaned = super().clean()           # keeps unique/constraint checks enabled
        pet = cleaned.get('pet')
        if pet and AdoptionApplication.objects.filter(
            applicant=self.instance.applicant, pet=pet,
        ).exclude(pk=self.instance.pk).exists():
            self.add_error('pet', ValidationError(
                'You have already applied for this pet.', code='duplicate_application'))
        return cleaned

go deeper

for a junior

Recall that a ModelForm only checks uniqueness for fields that are on the form.

for a middle

Explain how the exclusion set is built from the form's fields and why a constraint mentioning an excluded field is skipped.

for a senior

Show the three-layer fix, form check, database constraint and IntegrityError handling, and spot a clean() override missing super().

for a principal

Decide which invariants the database must enforce and make forms translate those failures into user-facing messages consistently.

## What ModelForm model validation covers After a `ModelForm`'s own field cleaning and `clean()`, Django validates the model instance. Before doing so it computes **validation exclusions**: model fields that model validation must skip. A field is excluded when: - it is **not on the form** at all (the developer may set it after validation); - it is on the form but excluded through `Meta.fields` or `Meta.exclude`; - it **already failed** form validation; - it is optional on the form, empty, and required on the model, so the model does not report a duplicate required error. That set is passed to the model's field validation and, crucially, to `validate_unique()` and `validate_constraints()`. ## How uniqueness checks use the exclusions - For `unique_together` and total unique constraints, Django only builds a check when **none** of the constraint's fields are excluded. - `UniqueConstraint.validate()` returns immediately if any of its fields is in the exclusion set. - A single-field `unique=True` on an excluded field is skipped the same way. The design is deliberate: if a field is not on the form, the form cannot know its final value, so checking would be guesswork. ## The pet-adoption failure, step by step 1. `AdoptionApplication` has `UniqueConstraint(fields=['applicant', 'pet'], name='one_application_per_pet')`. 2. The public form lists `pet`, `home_type`, `message` and friends, but not `applicant`. 3. An applicant applies for the same pet twice. `is_valid()` returns True: the constraint mentions `applicant`, which is excluded, so it is skipped. 4. The view calls `save(commit=False)`, sets `applicant`, and calls `save()`. The database rejects the row with `IntegrityError`, and the user sees a server error instead of a form message. Setting `form.instance.applicant = request.user` before `is_valid()` puts the value on the instance but does **not** remove `applicant` from the exclusions, which are computed from the form's fields. The check is still skipped. ## Fixing it properly | Layer | What it does | Why it is needed | |---|---|---| | form `clean()` | queries for an existing application and adds an error to `pet` | friendly message in the normal case | | `UniqueConstraint` | rejects the duplicate row | the real guarantee | | `try/except IntegrityError` around `save()` | turns a lost race into a form error | two submissions can both pass `clean()` | In code: accept the applicant in the form's `__init__`, store it on `self.instance`, and in `clean()` call `super().clean()` first, then check `AdoptionApplication.objects.filter(applicant=..., pet=...).exclude(pk=self.instance.pk).exists()`. Excluding the current primary key keeps edits of an existing application from colliding with themselves. ## Testing the rule 1. Submit a first application for a pet: valid, saved. 2. Submit a second for the same pet as the same applicant: `form.has_error('pet', 'duplicate_application')` is True, and no exception escapes. 3. Edit the existing application without changing the pet: still valid, because the query excludes the instance's own primary key. 4. Simulate the race by creating a duplicate row between `is_valid()` and `save()`: the view shows the form error rather than a server error. ## A second way to lose the check Overriding `clean()` on a `ModelForm` **without** calling `super().clean()` disables uniqueness and constraint validation for every field, because `ModelForm.clean()` is what sets the flags that enable them. Field validation and the model's own `clean()` still run, so nothing looks wrong until a duplicate hits the database. ## What interviewers listen for - Exclusions are driven by the form's field list. - Constraints touching an excluded field are skipped, not partially checked. - The fix combines a form-level check, the database constraint and handling `IntegrityError`.

  • Would adding applicant to a Django ModelForm as a hidden field make the uniqueness check run?
    Yes, the constraint would be validated, but the user could then submit any applicant id, reopening mass assignment. Keep `applicant` off the form, set it server-side, and check the rule in `clean()` instead.
  • Why does a Django ModelForm also exclude fields that already failed form validation from model validation?
    Their cleaned values are missing or unreliable, so running model field checks or uniqueness queries on them would duplicate errors or compare against garbage. Excluding them keeps one clear message per field.

saying these in an interview costs you the question

  • ModelForm always checks every model constraint before save.
  • Setting form.instance.applicant before is_valid() re-enables the constraint check.
  • A form-level exists() check makes the database constraint unnecessary.
  • Overriding clean() without super() only skips my own checks.
  • IntegrityError here means the form's validation has a bug in Django.