skip to content

In Django REST Framework, how would you write update() for an order serializer whose nested line items clients can add, change or remove?

level: seniorimportance: should knowfreq 44%

answer

  1. match children by id
  2. the auto id field drops input
  3. three sets: create, update, delete
  4. absent key versus empty list

basics

~20 s

Override update(): pop the items, save the order's own fields, then match incoming items by id against order.items — update matches, create items without an id, delete ones left out. Declare id as a writable IntegerField, because ModelSerializer's auto id is read-only.

solid answer

~40 s

The default `ModelSerializer.update()` asserts against nested data, and a `many=True` serializer's own `update()` raises `NotImplementedError`, so you write the sync yourself. First, declare `id = serializers.IntegerField(required=False)` on the line-item serializer: the generated `id` is read-only, so incoming ids would otherwise vanish from `validated_data`. In `update()`, pop `items` with a default of `None` so a PATCH without items leaves them alone, set the order's own attributes and save. Then load `order.items` into a dict by id: update items whose id matches, create items without one, reject ids belonging to another order, and delete the pre-existing ids the payload left out. Run it in `transaction.atomic()` and return the instance.

code

python · 49 lines
python
from django.db import transaction
from rest_framework import serializers

from shop.models import LineItem, Order


class LineItemSerializer(serializers.ModelSerializer):
    id = serializers.IntegerField(required=False)  # generated id is read-only

    class Meta:
        model = LineItem
        fields = ["id", "product", "quantity"]


class OrderSerializer(serializers.ModelSerializer):
    items = LineItemSerializer(many=True)

    class Meta:
        model = Order
        fields = ["id", "note", "items"]

    def update(self, instance, validated_data):
        items_data = validated_data.pop("items", None)  # absent on a PATCH
        for attr, value in validated_data.items():
            setattr(instance, attr, value)
        with transaction.atomic():
            instance.save()
            if items_data is not None:
                self._sync_items(instance, items_data)
        return instance

    def _sync_items(self, order, items_data):
        existing = {item.id: item for item in order.items.all()}
        seen = set()
        for data in items_data:
            item_id = data.pop("id", None)
            if item_id is None:
                LineItem.objects.create(order=order, **data)
                continue
            item = existing.get(item_id)
            if item is None:
                raise serializers.ValidationError(
                    {"items": [f"Line item {item_id} is not part of this order."]}
                )
            for attr, value in data.items():
                setattr(item, attr, value)
            item.save()
            seen.add(item_id)
        LineItem.objects.filter(id__in=set(existing) - seen).delete()

go deeper

for a junior

Know that DRF does not update nested lists for you and that an update has to decide which child rows to create, change or delete.

for a middle

Explain why the default update() asserts, why ListSerializer.update() raises NotImplementedError, and why the generated id field is read-only.

for a senior

Write the sync with a writable id, an ownership check, pre-existing-id deletion, PATCH-safe popping and one transaction, and say how you would test each branch.

for a principal

Choose the resource model: a nested replace-style update versus an items sub-resource, weighing id churn, concurrency and what clients can realistically send.

## Why you must write it In Django REST Framework (DRF), a **writable nested serializer** — `items = LineItemSerializer(many=True)` on an `OrderSerializer` — validates nested input but does not save it. `ModelSerializer.update()` calls `raise_errors_on_nested_writes('update', ...)` and fails an assertion (`The .update() method does not support writable nested fields by default.`) when `validated_data` holds nested list or dict data. Delegating to the nested field does not help: `ListSerializer.update()` raises `NotImplementedError` — *Serializers with many=True do not support multiple update by default, only multiple create.* — because matching a list of incoming dicts to existing rows is a policy decision. So an order update is a **synchronisation** you write, deciding three sets: | Incoming item | Existing row? | Action | |---|---|---| | has `id`, id belongs to this order | yes | update that row | | no `id` | — | create a new row | | has `id` of another order or no row | no | reject with a validation error | | (not sent) | yes | delete (full replacement semantics) | ## Trap 1: the `id` is silently dropped `ModelSerializer` generates the model's auto primary key as a **read-only** field (`AutoField`, and therefore `BigAutoField`, the `DEFAULT_AUTO_FIELD` default since Django 6.0, is mapped with `read_only=True`). Read-only fields are not in the serializer's writable fields, so `{"id": 7, "quantity": 3}` validates to `{"quantity": 3}`. Every item then looks new, and a naive sync deletes and recreates all lines. Declare the field explicitly: ```python id = serializers.IntegerField(required=False) ``` ## Trap 2: PUT versus PATCH With `partial=True` (a PATCH), a missing `items` key means "not changing items". Pop with a default — `validated_data.pop("items", None)` — and skip the sync when it is `None`. An explicit empty list `[]` is different: under replacement semantics it deletes every line, so decide whether that is allowed, and use `allow_empty=False` or `min_length=1` on the nested field if not. ## Trap 3: the delete set Compute deletions from the ids that **existed before** the sync, not with `order.items.exclude(id__in=seen)` after creating new rows: the new rows are not in `seen`, so that query deletes what you just created. ## The algorithm 1. `items_data = validated_data.pop("items", None)`. 2. Set the remaining attributes on the order and `save()` it. 3. If `items_data` is not `None`: `existing = {item.id: item for item in order.items.all()}`. 4. For each incoming item: without `id`, create it with `order=order`; with an `id` found in `existing`, set its attributes and save; with an `id` not in `existing`, raise `serializers.ValidationError`. 5. Delete `existing` ids that were not seen. 6. Return the order. Run steps 2-5 inside `transaction.atomic()` so a rejected id rolls back the whole update. Raising `serializers.ValidationError` inside `update()` still produces a 400, because it is an `APIException`; many teams prefer to check id ownership earlier, in `validate()`, where `self.instance` is available. ## A worked example The order has lines 7 and 8. The client sends `[{"id": 7, "product": 42, "quantity": 3}, {"product": 57, "quantity": 1}]`: | Line | In payload? | Result | |---|---|---| | 7 | yes, with id | quantity updated to 3 | | new | yes, no id | created for product 57 | | 8 | no | deleted | Had the client sent `{"id": 31, ...}` where line 31 belongs to a different order, the ownership check rejects the whole request and nothing changes. ## Hardening points - **Ownership.** The ownership check is also a security check: without it, a client could edit another order's line by sending its id. - **Concurrency.** Two simultaneous updates can interleave; lock the order row (`select_for_update()`) or use a version column if lines must stay consistent with totals. - **Moving the policy out.** A custom `ListSerializer` — set with `Meta.list_serializer_class` on the child and overriding its `update(instance, validated_data)` — is DRF's documented home for the matching logic when several parents reuse it. - **Recompute derived data.** Totals and stock reservations change with the lines; recompute them in the same transaction. ## Alternatives - Treat lines as a sub-resource (`/orders/12/items/`) and let clients add or remove them one at a time; the order serializer then renders items read-only. - Replace all lines on every PUT (delete then create); simple, but it churns ids and breaks anything that references line ids.

  • Why does raising serializers.ValidationError inside update() still produce a 400 rather than a 500?
    `serializers.ValidationError` subclasses `APIException`, and DRF's exception handler turns any `APIException` raised in the view, including inside `save()`, into a response with its status code. Inside `transaction.atomic()` it also rolls back the writes already made.
  • When would you move the matching logic into a custom ListSerializer?
    When several parent serializers nest the same child and need the same sync rules. Set `Meta.list_serializer_class` on the child and override the list serializer's `update(instance, validated_data)`, where `instance` is the existing queryset or list; the parent's `update()` then delegates to it.

saying these in an interview costs you the question

  • Incoming item ids reach validated_data because ModelSerializer includes the id field.
  • Calling the nested field's ListSerializer.update() syncs the items by primary key.
  • Deleting with exclude(id__in=seen) after creating new items is safe.
  • Any id in the payload can be updated, since the serializer validated it.
  • On a PATCH, a missing items key should delete every line item.