In Django REST Framework, how would you write update() for an order serializer whose nested line items clients can add, change or remove?
answer
- match children by id
- the auto id field drops input
- three sets: create, update, delete
- absent key versus empty list
basics
~20 sOverride 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 sThe 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 linesfrom 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
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.
Explain why the default update() asserts, why ListSerializer.update() raises NotImplementedError, and why the generated id field is read-only.
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.
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.