skip to content

In Django 6.1, how do you declare a CheckConstraint in Meta.constraints, and on which code paths is it enforced?

level: middleimportance: should knowfreq 40%

answer

  1. a rule on one row's values
  2. Q object, not a string
  3. the keyword was renamed
  4. database always, full_clean sometimes

basics

~20 s

Add models.CheckConstraint(condition=Q(...), name=...) to Meta.constraints. The database rejects any write that breaks it with IntegrityError, whatever the code path; full_clean() also checks it and raises ValidationError. The old check= keyword was removed in Django 6.0.

solid answer

~40 s

A `CheckConstraint` states a rule about the values within one row, such as `seats >= 1` or "a subscription ends after it starts". In Django 6.1 you write `models.CheckConstraint(condition=Q(seats__gte=1), name="subscription_seats_positive")`; `condition` must be a `Q` or boolean expression, and the older `check=` keyword was deprecated in 5.1 and removed in 6.0. The migration adds a SQL `CHECK`, so every write path, including `save()`, `update()`, `bulk_create()` and raw SQL, is rejected with `IntegrityError` when it breaks the rule. In Python, `full_clean()` calls `validate_constraints()`, which evaluates the condition and raises `ValidationError` with the constraint's message, so `ModelForm`s show a proper error. Like SQL `CHECK`, a condition that evaluates to unknown because of a NULL passes.

code

python · 13 lines
python
from django.core.exceptions import ValidationError
from django.db import IntegrityError

sub = Subscription(user=user, plan=team, status="active", seats=0, started_at=now)
try:
    sub.full_clean()
except ValidationError as exc:
    print(exc.messages)  # ['Constraint “subscription_seats_positive” is violated.']

try:
    Subscription.objects.filter(pk=existing.pk).update(seats=0)
except IntegrityError:
    pass  # update() skips model validation, but the CHECK still rejects it

go deeper

for a junior

Recall that CheckConstraint goes in Meta.constraints with a Q condition and a name, and that the database enforces it.

for a middle

Explain which paths raise IntegrityError and which raise ValidationError, the check-to-condition rename, and the difference from field validators.

for a senior

Plan adding a check to a populated table, handle NULL semantics deliberately, and make API errors readable with violation messages and codes.

for a principal

Decide which invariants belong in database constraints so every writer, including scripts and other services, is held to them.

## What a check constraint is for Some rules involve only the values in a **single row**: a subscription has at least one seat, a discount is between 0 and 100, an end date comes after a start date. A database `CHECK` constraint enforces exactly this kind of rule. Django declares one with `models.CheckConstraint` in `Meta.constraints`. ## Declaring one in Django 6.1 ```python from django.db import models from django.db.models import F, Q class Meta: constraints = [ models.CheckConstraint( condition=Q(seats__gte=1), name="subscription_seats_positive", ), models.CheckConstraint( condition=Q(ended_at__isnull=True) | Q(ended_at__gt=F("started_at")), name="subscription_ends_after_start", violation_error_message="A subscription must end after it starts.", ), ] ``` API points: - **`condition`** is keyword-only and must be a `Q` object or a boolean expression; anything else raises `TypeError`. - **`name`** is required and must be unique in the database; on an abstract model use `%(app_label)s` and `%(class)s`. - `violation_error_message` and, since Django 5.0, `violation_error_code` control the `ValidationError` raised during model validation. - The keyword used to be `check=`. It was deprecated in Django 5.1 and **removed in 6.0**, so older tutorials that write `CheckConstraint(check=Q(...))` fail on current Django. ## Where it is enforced | Code path | Result when the rule is broken | |---|---| | `save()`, `create()` | database rejects: `IntegrityError` | | `QuerySet.update()`, `bulk_create()`, `bulk_update()` | database rejects: `IntegrityError` | | raw SQL, other services writing the table | database rejects the statement | | `full_clean()` / `validate_constraints()` | `ValidationError` with the constraint's message, before any write | | `ModelForm.is_valid()` | the same model validation, reported as a form error | The database is the guarantee: it catches every writer, including the bulk paths that skip `save()` entirely. Model validation is the courtesy layer that lets forms and APIs show a clear message instead of a server error. ## Two subtleties 1. **NULL passes.** In SQL a `CHECK` fails only when the condition is false, not when it is unknown. `Q(ended_at__gt=F("started_at"))` alone would therefore accept a row with `ended_at` NULL anyway. Django's `validate()` mirrors that on backends that support it by treating an unknown result as passing. Writing the NULL case explicitly, as in the example, makes the intent readable. 2. **Excluded fields skip validation.** When `full_clean()` is called with `exclude` (a `ModelForm` excludes fields it does not show), constraints whose condition mentions an excluded field are skipped in Python. The database still enforces them on save. ## Rolling one out on existing data A new `CheckConstraint` is validated against every existing row when the migration runs. A safe sequence: 1. Query for violators first, for example `Subscription.objects.filter(seats__lt=1)`; remember that rows where the compared column is NULL do not violate a `CHECK`. 2. Fix or archive those rows in a data migration or a script. 3. Add the constraint in its own migration, so a failure points clearly at the data rather than at unrelated schema changes. Keeping the condition in a module-level `Q` object lets the violator query and the constraint share one definition. ## CheckConstraint versus field validators A field's `validators` (for example `MinValueValidator(1)` on `seats`) run only during model and form validation; they never reach the database, so `update()` or a bulk load can bypass them. A `CheckConstraint` is enforced by the database for every writer and is also checked by `full_clean()`. Teams often use both: validators for field-level messages in forms, a constraint for the invariant that must never be broken. Cross-field rules, like start before end, belong in a constraint (or `clean()`), since a single field's validator cannot see the other field. ## Backend support Most backends support check constraints. Where one does not, Django's system checks emit `models.W027` and no constraint is created. Adding a constraint to a table that already holds violating rows fails when the migration runs, so clean the data first.

  • An older Django tutorial writes CheckConstraint(check=Q(seats__gte=1), name=...). What happens on Django 6.1?
    It fails: the `check` keyword was deprecated in Django 5.1 and removed in 6.0, and `condition` is a required keyword-only argument, so constructing the constraint raises a `TypeError`. Rename the argument to `condition=`; the generated SQL is unchanged, and migrations written with the new keyword serialize it as `condition`.

saying these in an interview costs you the question

  • CheckConstraint is only checked by Django forms, not by the database.
  • QuerySet.update() bypasses CheckConstraint because it skips save().
  • CheckConstraint(check=Q(...)) is the current Django 6.1 syntax.
  • A CHECK condition fails when a compared column is NULL.
  • save() calls full_clean() automatically, so constraints raise ValidationError there.