skip to content

Why can two concurrent requests both pass a Django REST Framework UniqueTogetherValidator and still collide, and how do you make the uniqueness guarantee hold?

level: seniorimportance: should knowfreq 46%

answer

  1. a check, then a write
  2. nothing locked in between
  3. the database decides
  4. turning IntegrityError into a 400

basics

~20 s

UniqueValidator and UniqueTogetherValidator only query whether a matching row exists during validation; nothing is locked before the insert. The database unique constraint is the real guarantee, and create() should turn its IntegrityError into a ValidationError instead of a 500.

solid answer

~50 s

DRF's uniqueness validators run a query for an existing row with the same values, excluding the instance being updated, and raise `ValidationError` if one exists. That is a check-then-act sequence: two requests booking the same room and start date can both see no row, both pass `is_valid()`, and both reach `save()`. With a `UniqueConstraint` on the model, the second insert fails in the database with `IntegrityError`, which DRF's default exception handler does not handle, so the client gets a 500; without a constraint, the duplicate is simply saved. So the database constraint is the guarantee and the validator is the friendly message for the common case. In `create()` I wrap the insert in `transaction.atomic()` and convert `IntegrityError` into a `ValidationError`. Rules that are not uniqueness, like room capacity summed over overlapping bookings, need a lock or a database constraint of their own.

code

python · 24 lines
python
from django.db import IntegrityError, transaction
from rest_framework import serializers

from bookings.models import Booking


class BookingSerializer(serializers.ModelSerializer):
    # Booking.Meta.constraints holds
    # UniqueConstraint(fields=["room", "check_in"], name="one_booking_per_room_start"),
    # so DRF generates a UniqueTogetherValidator for room + check_in.

    class Meta:
        model = Booking
        fields = ["id", "room", "check_in", "check_out", "guests"]

    def create(self, validated_data):
        try:
            with transaction.atomic():
                return super().create(validated_data)
        except IntegrityError:
            # a concurrent request won the race after our validation passed
            raise serializers.ValidationError(
                {"check_in": ["This room is already booked from that date."]}
            )

go deeper

for a junior

Know that DRF adds uniqueness validators from the model and that they reject duplicates with a 400 in normal use.

for a middle

Explain how the validators query for existing rows, exclude the current instance on update, and are generated from unique_together and UniqueConstraint.

for a senior

Explain the check-then-insert race, rely on database constraints for the guarantee, and convert IntegrityError into a ValidationError inside an atomic block.

for a principal

Set the policy that every invariant has a database-level guarantee, with serializer validation reserved for fast, friendly feedback.

## What the uniqueness validators do Django REST Framework has two validators for uniqueness: | Validator | Attached to | Generated automatically when | Checks | |---|---|---|---| | `UniqueValidator` | one field | the model field has `unique=True` | no other row has this value | | `UniqueTogetherValidator` | the serializer (`Meta.validators`) | the model has `unique_together` or a `UniqueConstraint`; the latter supported since DRF 3.15 | no other row has this combination | Both work the same way: build a queryset filtered by the submitted values, exclude the current instance on updates, ask whether any row **exists**, and raise `ValidationError` if so. For the fields of a unique-together set, a `ModelSerializer` makes each one required on create, unless the model field has a default, is nullable or is an automatic timestamp, in which case DRF supplies that value instead; the validator itself reports `This field is required.` for any that are still missing. It skips the check when one of the values is `None` unless the constraint says nulls are not distinct. Two practical details: - Declaring `Meta.validators` on a `ModelSerializer` **replaces** the generated list; `validators = []` switches the uniqueness checks off. - The validators take no lock and run inside `is_valid()`, before `save()` starts. ## The race For a booking model with `UniqueConstraint(fields=["room", "check_in"], name="one_booking_per_room_start")`: 1. Request A validates: no booking for room 7 on 3 October. Pass. 2. Request B validates the same payload a few milliseconds later: still no row. Pass. 3. A inserts its booking. 4. B inserts its booking. What happens at step 4 depends on the database: - **With the constraint**, B's insert fails with `IntegrityError`. DRF's default exception handler only handles `APIException`, `Http404` and `PermissionDenied`, so the error becomes a **500**. - **Without a constraint**, for example when the validator was declared by hand on a plain `Serializer`, B's booking is saved and the data now holds a duplicate. The validator never promised atomicity; it is a query, and validation and insertion are separate steps. ## Making the guarantee hold 1. **Put the rule in the database**: a `UniqueConstraint` in the model's `Meta.constraints`. This is the only layer that sees both inserts. 2. **Keep the validator** for the common, non-concurrent case: it produces a clean 400 with a clear message and costs one query. 3. **Handle the loser of the race** in `create()` (or `perform_create()`): run the insert inside `transaction.atomic()` and convert `IntegrityError` into `serializers.ValidationError`, so the second client gets a 400 like everyone else. The inner atomic block matters when the request already runs in a transaction, because a failed statement must be rolled back to a savepoint before anything else runs. 4. **Tell constraint violations apart** if the model has several: inspect which constraint failed, or re-query, before choosing the message. ## Where the window is The sequence for one request, in time order: 1. `is_valid()` runs the uniqueness query: a read. 2. `validate()` and any other hooks run. 3. The view calls `save()`, which calls `create()`. 4. The `INSERT` reaches the database, which enforces its constraints. Everything between step 1 and step 4 is the window in which another request can insert the same values. Making the window small does not close it; only step 4 is authoritative. ## Rules that are not uniqueness Many booking rules are not "no two rows share values". Room capacity summed over **overlapping** date ranges, or "no overlapping stays for the same room", cannot be expressed with a unique constraint: - A check in `validate()` has the same race as the uniqueness validator. - Options are a lock on the room row taken inside the transaction that inserts the booking, or a database-level constraint designed for ranges, such as PostgreSQL's exclusion constraints, which Django exposes through `django.contrib.postgres`. - Either way, `validate()` remains the place for the fast, friendly rejection, not the guarantee. ## Summary of responsibilities - **Validator**: good error messages, no guarantee. - **Database constraint**: the guarantee. - **`create()` handling**: turns the guarantee's failure into the same 400 the validator would have produced.

  • Why wrap super().create() in transaction.atomic() inside a DRF serializer's create()?
    When the request already runs inside a transaction, for example with `ATOMIC_REQUESTS`, a failed `INSERT` leaves that transaction unusable until it is rolled back. The inner `atomic()` block creates a savepoint, so catching `IntegrityError` rolls back only the failed insert and the view can still respond normally. Without an outer transaction it simply scopes the insert.
  • What does setting Meta.validators = [] on a DRF ModelSerializer do?
    It replaces the generated serializer-level validators, so the unique-together and unique-for-date checks derived from the model are no longer run. Field-level validators such as `UniqueValidator` on a `unique=True` field are unaffected. Teams do this deliberately when they rely on the database constraint and handle `IntegrityError` themselves.

saying these in an interview costs you the question

  • UniqueTogetherValidator locks the matching rows until save() finishes.
  • If validation passed, the insert cannot violate the unique constraint.
  • DRF converts IntegrityError into a 400 response automatically.
  • A uniqueness check in validate() is enough without a database constraint.
  • Setting Meta.validators = [] also removes UniqueValidator from unique fields.