skip to content

In Django REST Framework, what does partial=True change during validation, and why can a PATCH request break cross-field checks in validate()?

level: middleimportance: should knowfreq 52%

answer

  1. missing is not the same as invalid
  2. what PATCH passes to the serializer
  3. attrs holds only what was sent
  4. fall back to self.instance

basics

~20 s

partial=True makes every field that is absent from the request skip validation entirely: no required error, no default, no validate_<field>(). validate() therefore receives only the sent fields, so cross-field rules must fill the rest from self.instance.

solid answer

~40 s

DRF's generic views call the serializer with `partial=True` for PATCH, through `partial_update()`. During validation each field that is missing from the data raises `SkipField`: no `This field is required.` error, no default applied, and its `validate_<field_name>()` does not run. `validate(attrs)` then receives only the fields the client sent, so code written for create, such as `attrs["check_out"] <= attrs["check_in"]`, raises `KeyError` when a PATCH sends only `check_out`, and that escapes as a 500. The fix is to resolve each value from `attrs` first and `self.instance` second before comparing. `ModelSerializer.update()` then sets only the keys in `validated_data`, so unsent fields keep their stored values. `UniqueTogetherValidator` already does this fallback for its own fields on updates.

code

python · 26 lines
python
from rest_framework import serializers

from bookings.models import Booking


class BookingSerializer(serializers.ModelSerializer):
    class Meta:
        model = Booking
        fields = ["id", "room", "check_in", "check_out", "guests"]

    def _resolved(self, attrs, name):
        # value after this request: sent value first, stored value second
        if name in attrs:
            return attrs[name]
        return getattr(self.instance, name, None)

    def validate(self, attrs):
        check_in = self._resolved(attrs, "check_in")
        check_out = self._resolved(attrs, "check_out")
        if check_in and check_out and check_out <= check_in:
            raise serializers.ValidationError({"check_out": "Check-out must be after check-in."})
        room = self._resolved(attrs, "room")
        guests = self._resolved(attrs, "guests")
        if room and guests and guests > room.capacity:
            raise serializers.ValidationError("Too many guests for this room.")
        return attrs

go deeper

for a junior

Know that PATCH sends only some fields and that DRF handles it with partial=True, while PUT expects every required field.

for a middle

Explain SkipField for absent fields, why defaults and validate_<field>() are skipped, and what validate() receives on a PATCH.

for a senior

Write cross-field rules that resolve values from the instance, test them with single-field PATCH requests, and recognise 500s caused by KeyError in validate().

for a principal

Decide whether an API offers PATCH, PUT or both, and what partial-update guarantees it makes when several clients edit the same resource.

## PUT versus PATCH in DRF's generic views In Django REST Framework, `UpdateModelMixin` handles both update methods: | Request | View method | Serializer call | Missing required field | |---|---|---|---| | `PUT` | `update()` | `partial=False` | error: `This field is required.` | | `PATCH` | `partial_update()` | `partial=True` | skipped silently | `partial_update()` sets `partial=True` and delegates to `update()`, which builds the serializer with the existing instance, the request data and that flag, then calls `is_valid(raise_exception=True)`. ## What partial=True changes, field by field For each writable field, DRF first decides what to do with an absent or empty value. With `partial=True`: - an **absent** field raises `SkipField` straight away, before the `required` check; - its **default is not applied**, even when the field declares `default=`; - its **`validate_<field_name>()` does not run**, because only fields that produced a value reach that hook; - a field that **is** sent is validated exactly as in a full update, including `null` handling and its validators. Read-only fields behave as always: they are never taken from input. ## Why validate() breaks `validate(attrs)` is written by people who picture a full payload. Under `partial=True`, `attrs` contains **only the fields that were sent and passed**. For a booking: 1. The client sends `PATCH /bookings/42/` with `{"check_out": "2026-10-09"}`. 2. `check_out` is parsed; `check_in`, `room` and `guests` are skipped. 3. `validate()` runs `attrs["check_out"] <= attrs["check_in"]` and raises **`KeyError`**. 4. `is_valid()` only turns `ValidationError` into errors, so the `KeyError` escapes, and the client receives a **500**. Using `attrs.get("check_in")` instead avoids the crash but is wrong too: comparing a date with `None` raises `TypeError`, and skipping the check lets a PATCH move `check_out` before the stored `check_in`. ## The fix: resolve values from the instance - For each field a rule needs, take the value from `attrs` if present, otherwise from `self.instance` when updating. - Only then compare, so the rule sees the state the row will have **after** the update. - On create there is no instance; `self.instance` is `None`, and every required field is present anyway because create does not use `partial=True`. - Keep the helper in one place on the serializer, since every cross-field rule needs it. `UniqueTogetherValidator` already works this way for its own fields: on an update, a field missing from the data is filled from the instance before the uniqueness query, and the "fields are required" check is applied only on create. ## What gets saved After validation, `save()` calls `update(instance, validated_data)`. `ModelSerializer.update()` sets an attribute for **each key in `validated_data`** and calls `instance.save()`. Unsent fields are not in `validated_data`, so they keep the values already loaded on the instance. Note that `instance.save()` writes every column of the loaded instance, not only the changed ones; if two PATCH requests edit different fields of the same booking at the same moment, the later save can overwrite the other's change with the value it loaded. That is a concurrency concern rather than a validation one. ## partial=True without an instance `partial` is a serializer argument, not something tied to PATCH. Passing it on a create, `BookingSerializer(data=request.data, partial=True)`, skips every missing field there as well, so a booking could reach `create()` without `check_in`; the model's default is then used if it has one, and otherwise the database rejects the row or silently stores a null in a nullable column. Keep `partial=True` for updates, where the instance supplies what the request leaves out. ## Testing partial updates 1. Send a PATCH with each field of a cross-field rule on its own. 2. Send a PATCH that breaks a rule only in combination with the stored value, such as moving `check_out` before the stored `check_in`. 3. Assert a 400 with the error under the expected key, never a 500. Before shipping a serializer that serves PATCH: - Never index `attrs[...]` for a field that might be absent. - Resolve cross-field inputs from `attrs` then `self.instance`. - Do not rely on field defaults being applied on PATCH. - Test each cross-field rule with a PATCH that sends only one of its fields.

  • Are field defaults applied in DRF when partial=True?
    No. With `partial=True` an absent field raises `SkipField` before its default is considered, so a field declared with `default=` keeps its stored value on PATCH instead of being reset. That is usually what you want for an update, but it means code must not assume the default appears in `attrs`.
  • How do you make a DRF PUT endpoint behave like PATCH?
    Pass `partial=True` to the serializer for that request, for example by overriding `update()` in the view to set `kwargs["partial"] = True`. Then missing fields are skipped instead of reported as required. It changes the API's contract, so clients relying on PUT meaning full replacement must be considered.

saying these in an interview costs you the question

  • partial=True makes missing fields None in validated_data.
  • With partial=True, DRF fills missing fields from the instance before validate() runs.
  • A PATCH still applies field defaults for fields the client left out.
  • Using attrs.get() in validate() is enough to make cross-field rules PATCH-safe.
  • PUT and PATCH both use partial=True in DRF's generic views.