skip to content

In Django, how do you enforce at most one active subscription per user with Meta.constraints, and why not unique_together?

level: middleimportance: must knowfreq 55%

answer

  1. uniqueness for some rows only
  2. a Q object on the constraint
  3. a unique index with WHERE
  4. IntegrityError on save, ValidationError on full_clean

basics

~10 s

Add UniqueConstraint(fields=["user"], condition=Q(status="active"), name=...) to Meta.constraints. The database then allows many cancelled rows per user but only one active one. unique_together cannot take a condition and is the legacy option.

solid answer

~40 s

I declare `models.UniqueConstraint(fields=["user"], condition=Q(status="active"), name="one_active_subscription_per_user")` in `Meta.constraints`. Because it has a `condition`, Django creates a unique index restricted to the active rows, so a user can have many cancelled subscriptions but a second active one fails. `save()` does not validate it; the database rejects the write with an `IntegrityError`. `full_clean()`, and therefore a `ModelForm`, runs `validate_constraints()`, which queries for a clash and raises a `ValidationError` with the constraint's `violation_error_message`. `unique_together` cannot express a condition, and the docs recommend `UniqueConstraint` over it. On MySQL, MariaDB and Oracle conditional unique constraints are not supported: a system check warns (`models.W036`) and no constraint is created.

code

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

# Model validation: a query finds the clash first
sub = Subscription(user=user, plan=pro, status="active", started_at=now)
try:
    sub.full_clean()
except ValidationError as exc:
    print(exc.messages)  # ['This user already has an active subscription.']

# Direct save: the database is the one that says no
try:
    Subscription.objects.create(user=user, plan=pro, status="active", started_at=now)
except IntegrityError:
    pass  # second active row rejected by the partial unique index

go deeper

for a junior

Recall that UniqueConstraint lives in Meta.constraints, needs a name, and can take a condition built with Q.

for a middle

Explain which paths raise IntegrityError and which raise ValidationError, and why unique_together cannot express the one-active rule.

for a senior

Handle the race between validation and insert, order the plan-switch writes correctly, and check backend support before relying on a partial index.

for a principal

Decide which business invariants must live in database constraints rather than application checks, and how the team names and reviews them.

## The rule and why a plain unique column fails A billing app keeps every subscription a user ever had: old ones are `cancelled`, the current one is `active`. The business rule is **at most one active subscription per user**. A plain `unique=True` on `user` is wrong, because it would forbid the history rows too. The uniqueness applies only to a **subset of rows**. ## Declaring it in Django Django expresses this as a `UniqueConstraint` with a `condition`, a `Q` object, in `Meta.constraints`: ```python class Meta: constraints = [ models.UniqueConstraint( fields=["user"], condition=Q(status="active"), name="one_active_subscription_per_user", violation_error_message="This user already has an active subscription.", ), ] ``` Key points of the API: - **`name` is required** and must be unique across the database; on an abstract base use `%(app_label)s` and `%(class)s` placeholders. - **`condition` must be a `Q` instance**; anything else raises `ValueError`. - `fields` and positional `*expressions` are mutually exclusive. - A conditional constraint **cannot be deferrable**; combining `condition` and `deferrable` raises `ValueError`. Django's docs note that a `UniqueConstraint` with only `fields` becomes a true `ADD CONSTRAINT ... UNIQUE`, while adding `condition`, `expressions`, `include` or `opclasses` makes it a **unique index** (`CREATE UNIQUE INDEX ... WHERE ...` on PostgreSQL). Either way the database enforces it. ## Where it is checked | Path | What happens on a second active row | |---|---| | `subscription.save()` or `Subscription.objects.create(...)` | the database rejects it: `IntegrityError` | | `update()`, `bulk_create()`, raw SQL | the database rejects it: `IntegrityError` | | `subscription.full_clean()` | `validate_constraints()` runs a query and raises `ValidationError` | | a `ModelForm` covering the fields | same model validation, shown as a form error | `save()` never calls `full_clean()` on its own, so code that creates rows directly must either validate first or handle the `IntegrityError`. Validation also cannot close a **race**: two requests can both pass `full_clean()` and then both insert. The database constraint is the real guarantee; the validation gives a friendly message in the common case. The error text comes from `violation_error_message` (default: `Constraint “<name>” is violated.`), and Django 5.0 added `violation_error_code` so callers can match the error by code. ## What the migration contains `makemigrations` records the constraint as an `AddConstraint` operation. On PostgreSQL the conditional form becomes a unique index rather than a table constraint, roughly: ```sql CREATE UNIQUE INDEX "one_active_subscription_per_user" ON "billing_subscription" ("user_id") WHERE "status" = 'active'; ``` If existing data already contains two active rows for one user, this statement fails when the migration runs, so find and fix duplicates first. Give constraints descriptive, stable names: the name appears in database error messages, in the default validation message and in any later migration that removes or changes it. ## Why not unique_together `Meta.unique_together = [["user", "plan"]]` declares plain multi-column uniqueness. It has no `condition`, no expressions and no custom error message. Django's documentation tells you to use `UniqueConstraint` in `Meta.constraints` instead and warns that `unique_together` may be deprecated. Existing code keeps working, but new code should not add it. ## Backend support 1. **PostgreSQL and SQLite** create the conditional unique index. 2. **MySQL and MariaDB** do not support partial indexes; Oracle does not either. Django emits the system check warning `models.W036` ("does not support unique constraints with conditions") with the hint that no constraint will be created, and the rule is then **not enforced** by the database. On such a backend you need a different design, such as a nullable column that is set only on the active row and carries a plain unique constraint. ## Switching plans safely Moving a user from one plan to another means ending the old active row and creating a new one. Because the partial unique index is checked statement by statement and cannot be deferred, update the old row to `cancelled` **before** inserting the new active row, inside one transaction so a failure leaves neither half behind.

  • In Django 5.0+, what does UniqueConstraint(nulls_distinct=False) change, and where does it work?
    By default most databases treat NULLs as distinct, so a unique column still accepts many NULL rows. `nulls_distinct=False` asks the database to treat them as equal, allowing only one NULL. It is honoured on PostgreSQL 15+ only; other backends ignore it and the check framework warns (`models.W047`).
  • Why can a Django view still hit IntegrityError after the form validated the one-active-subscription rule?
    `validate_constraints()` runs a separate SELECT before the INSERT. Two concurrent requests can both see no active row, both pass validation, and both insert; the database index rejects the second. The view should catch `IntegrityError` on the write and turn it into the same user-facing message.

It is like a hotel that keeps every past booking on file but lets each guest hold only one checked-in room at a time; the rule looks only at rows whose status is checked in.

saying these in an interview costs you the question

  • save() validates Meta.constraints before writing and raises ValidationError.
  • unique_together accepts a condition for partial uniqueness.
  • A conditional UniqueConstraint works identically on MySQL and PostgreSQL.
  • Passing full_clean() guarantees the insert cannot violate the constraint.
  • A UniqueConstraint name is optional and Django generates one.