Model Relationships in Django REST Framework

Picture of a laptop with Django in floating text above the image.

Model Relationships in Django REST Framework

I’ve spent the last couple of months working on an API written using Python, Django, and Django REST Framework (DRF). The latter is a popular, well-established framework for building APIs in Python, so I assumed it would have decent documentation surrounding a fairly common situation in any model-view-controller (MVC) framework: relational fields.

It turns out that was a faulty assumption.

Unfortunately, the documentation on working with relational fields in serializers is sparse, and many online answers and solutions are incomplete, case-specific, overengineered, or just plain wrong. It took me entirely too long to find out how to make these relationship fields to work with DRF. My solution even changed while writing this article!

In hopes of sparing other developers that pain, here’s everything I discovered about Django relational fields, and how to work with them in the Django REST Framework. In exploring this topic, I will build out a small-but-complete example, so you can experiment with these concepts yourself.

If you want to follow along, take a minute and create a Django project for yourself, with an app called pizzaria. The examples below work in Django 2.2/DRF 3.11 through at least Django 4.0/DRF 3.13, and probably beyond. If you’ve never set up a Django project before, take a look at this guide, which you can adapt to your purposes.

In addition to your favorite Python IDE, I also recommend you use Hoppscotch for crafting and testing calls to your example API, and DBeaver Community Edition for viewing your database. (Alternatively, Postman and JetBrains DataGrip work well too!)

Understanding Relationships in Django

Before we bring Django REST Framework into the mix, let’s start with an explanation of how relationships work in MVC frameworks like Django.

The first thing to know is that your Django models define how your app’s database is structured.

There are three major kinds of relationship between database entries:

  • One-to-many (a.k.a. a ForeignKey): for example, a Pizza is associated with exactly one Order, but an Order can have more than one Pizza.
  • One-to-one: for example, a Pizza has exactly one Box, and each Box belongs to one Pizza.
  • Many-to-many: for example, a Pizza can have more than one Topping, and a single Topping can be on more than one Pizza.

In Django, we represent these relationships as fields on models, using ForeignKey, OneToOneField, and ManyToManyField to represent them.

In the case of ForeignKey in a one-to-many relationship, you’d put that field on the model that represents the “one” side relationship: the Pizza model would have an order field, not the other way around.

When defining all three, you must specify the model that the relationship is to. See the following code; the comments will explain what different parts are for:

from django.db import models
import uuid
 
 
class Order(models.Model):
    # In this example, I'll use UUIDs for primary keys
    id = models.UUIDField(
        primary_key=True,
        default=uuid.uuid4,
        editable=False
    )
 
    customer = models.CharField(max_length=256, blank=False, null=False)
 
    address = models.CharField(max_length=512, blank=True, null=False)
 
 
class Box(models.Model):
    id = models.UUIDField(
        primary_key=True,
        default=uuid.uuid4,
        editable=False
    )
 
    color = models.CharField(
        max_length=32, default="white", blank=False, null=False
    )
 
 
class Topping(models.Model):
    name = models.CharField(
        primary_key=True,
        max_length=64
    )
 
 
class Pizza(models.Model):
    id = models.UUIDField(
        primary_key=True,
        default=uuid.uuid4,
        editable=False
    )
 
    order = models.ForeignKey(
        "pizzaria.Order",
        on_delete=models.CASCADE,
        related_name="pizzas",
        null=False
    )
 
    box = models.OneToOneField(
        "pizzaria.Box",
        on_delete=models.SET_NULL,
        related_name="contents",
        null=True
    )
 
    toppings = models.ManyToManyField(
        "pizzaria.Topping",
        related_name='+'
    )
 

There are a few interesting arguments on the relational fields:

on_delete: what should happen to this object when the other object is deleted? In the above, deleting an Order deletes the associated Pizza objects. However, deleting a box does not delete the Pizza. (This argument is not available on ManyToManyField.)

related_name is a sort of “virtual” field that is automatically created on the other object. (It is NOT actually added as a database column.) For example, the Box model does not actually have a contents field (and must not, to avoid colliding with the related_name here). However, within Box, I can access the associated Pizza object by accessing contents as I would any field. In the case of a one-to-many or many-to-many relationship, this will instead refer to a list of objects.

If I do not define related_name, one will be automatically created. If I set related_name to + (or end the name with +), then no “virtual” field will be created.

null defines whether the field can be null, which determines whether it is required (null=False), or optional (null=True).

There are additional parameters, which are covered in Django documentation — Model field reference: Relationship fields.

How Relationships Appear in the Database

Before we continue, it’s important to note how these relationships actually manifest in the database.

In the case of a ForeignKey or OneToOneField, a column is created. For Pizza, the table looks like this:

Table: pizzaid: uuidorder_id: uuidbox_id: uuid

Notice that the columns are not order and box, but order_id and box_id. The order and box fields are simulated by Django, and use these _id fields to store and retrieve the primary key of the associated object.

You’ll also notice that no column has been defined for toppings. Rather, we have a separate table…

Table: pizza__toppingsid: intpizza_id: uuidtopping_id: uuid

Don’t worry, Django knows how to work with this, so you can still refer to all three via their fields.

Typically, you can let Django work out how to build this table for ManyToManyField, but if you really need control — or if you need to add additional fields to the relationship, you can specify another model on the through= parameter of the models.ManyToManyField. (See the documentation.) However, spoiler alert, a “through” model does NOT play well with DRF.

Meanwhile, in the tables for box, order, and toppings, there is no column for pizzas, contents, or the like. Django handles these related_name references outside of the database.

Serializing Relationships in DRF

In DRF, you have to define four different components to connect your model to your API endpoint:

  1. The Model itself in models.py (or models/«whatever».py),
  2. One or more Serializers in serializers.py (or serializers/«whatever».py),
  3. One or more ViewSets in views.py (or views/«whatever».py),
  4. The API endpoint specified in url.py.

The serializers are where things get interesting.

Basic Serializers

The serializer is responsible for translating data between the API request (or any other source) and the Model/database. You can use your serializer to control which fields are exposed, which are read-only, and even to simulate fields that don’t really exist.

Here is the first part of serializers.py, containing the serializers for my Order, Box, and Topping models:

from rest_framework import serializers
 
from pizzaria.models import Box, Order, Pizza, Topping
 
 
class BoxSerializer(serializers.ModelSerializer):
    class Meta:
        model = Box
        fields = ('id', 'color')
 
 
class OrderSummarySerializer(serializers.ModelSerializer):
    class Meta:
        model = Order
        fields = ('id', 'customer', 'address')
 
 
class ToppingSerializer(serializers.ModelSerializer):
    class Meta:
        model = Topping
        fields = ('name')
 

In each serializer, I must at a minimum specify a Meta subclass, containing the model and a tuple of fields to expose.

Handling Payload

I’ll be adding a serializer for Pizza shortly, which handles the relational fields. Before I can, however, I need to write a special function to handle the payload, the data sent with the request via the API.

Depending on how the API is used, you may receive Python objects that were deserialized from JSON or some other structured data format, or you may receive a string represention of JSON data. This gets tricky to handle on serializers!

To get around this, I created a function.py module with a function to handle the payload. It will attempt to deserialize JSON, returning the raw input if it isn’t a string representation of JSON. That way, no matter what is sent to the API, we can work with the data.

import json
 
def attempt_json_deserialize(data, expect_type=None):
    try:
        data = json.loads(data)
    except (TypeError, json.decoder.JSONDecodeError): pass
 
    if expect_type is not None and not isinstance(data, expect_type):
        raise ValueError(f"Got {type(data)} but expected {expect_type}.")
 
    return data

This code also ensures that the data type being returned is the one specified. That is, if you expect a string, the function will raise an exception if you don’t get a string. It helps cut down on silent bugs originating from the data provided via the API being the wrong type.

Serializing Relational Fields

I can now begin to write a serializer for Pizza:

from rest_framework import serializers
 
from pizzaria.functions import attempt_json_deserialize
from pizzaria.models import Box, Order, Pizza, Topping
 
# --snip--
 
class PizzaSerializer(serializers.ModelSerializer):
    order = OrderSummarySerializer(read_only=True)
    box = BoxSerializer(read_only=True)
    toppings = ToppingSerializer(read_only=True, many=True)
 
    class Meta:
        model = Pizza
        fields = ('id', 'order', 'box', 'toppings')

I have to explicitly specify which serializers to use for relational fields. We typically serialize the relational field with the serializer for the other model. This is known as a nested serializer.

For any serializer that inherits from serializers.ModelSerializer, I can pass the read_only=True parameter to indicate that I won’t be writing to the nested serializer. In most cases, DRF simply doesn’t support writing to a nested serializer.

Actually writing to a relational field is a little tricker. The best way to handle it is to write explicit create() and update() methods on the serializer. I’ll start by breaking down the create() method:

# --snip--
 
class PizzaSerializer(serializers.ModelSerializer):
 
	# --snip--
 
    def create(self, validated_data):
        request = self.context['request']
 
        order_pk = request.data.get('order')
        order_pk = attempt_json_deserialize(order_pk, expect_type=str)
        validated_data['order_id'] = order_pk
 
        box_data = request.data.get('box')
        box_data = attempt_json_deserialize(box_data, expect_type=dict)
        box = Box.objects.create(**box_data)
        validated_data['box'] = box
 
        toppings_data = request.data.get('toppings')
        toppings_data = attempt_json_deserialize(toppings_data, expect_type=list)
        validated_data['toppings'] = toppings_data
 
        instance = super().create(validated_data)
 
        return instance
	
	# --snip--

For each of the relational fields, I must intercept and interpret the payload value. That looks different for each field.

For order, I expect the UUID of an existing Order as a string. I attempt to deserialize it, in case it’s a string containing a JSON representation of a string (yes, I’ve had that happen, and it’s annoying!) I store the extracted UUID back to validated_data on the key order_id, where it will be used later when creating the Pizza object.

For box, I want to create a new Box object each time. I accept a dictionary (or string representation of a JSON object, which deserializes to a dictionary). Then, I create the new box with Box.objects.create(), unpacking the contents of the box_data dictionary in as the arguments. Then, I store the created object on validated_data on the box key.

Note: Calling Box.object.create() will bypass the serializers. If I defined create() or update() in BoxSerializer, they would never get called.

For toppings, I expect a list of topping names, which are also the primary keys for Topping objects. I wind up storing this list on validated_data on the toppings key.


As a useful aside, if I wanted to create Topping objects instead, I would use the following code instead:

toppings_data = request.data.get('toppings')
toppings_data = attempt_json_deserialize(toppings_data, expect_type=list)
toppings_objs = [Topping.objects.create(**data) for data in toppings_data]
validated_data['toppings'] = toppings_objs

Once I’ve finished refining validated_data for my use, I can create the new instance with instance = super().create(validated_data).

Interestingly, the official DRF documentation demonstrates creating the instance first, and then creating and adding items to the ManyToManyField with instance.toppings.add(). This is valid, however, I don’t like it as much because any unhandled exception at this stage will still result in the row for the Pizza being created, but with all data yet to be processed to be quietly dropped.

The update() method is almost exactly the same, except we call super().update(instance, validated_data):

--snip--
 
class PizzaSerializer(serializers.ModelSerializer):
 
	# --snip--
 
    def update(self, instance, validated_data):
        request = self.context['request']
 
        order_data = request.data.get('order')
        order_data = attempt_json_deserialize(order_data, expect_type=str)
        validated_data['order_id'] = order_data
 
        box_data = request.data.get('box')
        box_data = attempt_json_deserialize(box_data, expect_type=dict)
        box = Box.objects.create(**box_data)
        validated_data['box'] = box
 
        toppings_data = request.data.get('toppings')
        toppings_ids = attempt_json_deserialize(toppings_data, expect_type=list)
        validated_data['toppings'] = toppings_ids
 
        instance = super().update(instance, validated_data)
 
        return instance

Multiple Serializers

There’s another serializer I want: one that shows the Pizza objects on an order. I can’t just add pizzas as a field to OrderSummarySerializer, as that one is used by the PizzaSerializer, and I don’t want a circular dependency.

I’ll create OrderDetailsSerializer, which will show the pizzas on the order. To do this, I also must create PizzaSummarySerializer, which shows everything in Pizza except the order (because, again, circular dependency):

class PizzaSummarySerializer(serializers.ModelSerializer):
    box = BoxSerializer(read_only=True)
 
    class Meta:
        model = Pizza
        fields = ('id', 'box', 'toppings')
 
 
class OrderDetailSerializer(serializers.ModelSerializer):
    pizzas = PizzaSummarySerializer(read_only=True, many=True)
 
    class Meta:
        model = Order
        fields = ('id', 'customer', 'address', 'pizzas')

Creating the ViewSet

I have to write a ViewSet to expose my serializers to API endpoints, in views.py:

from rest_framework.viewsets import ModelViewSet
 
from pizzaria.models import Box, Order, Pizza, Topping
from pizzaria.serializers import (
    OrderSummarySerializer,
    OrderDetailSerializer,
    PizzaSerializer,
    ToppingSerializer
)
 
 
class ToppingViewSet(ModelViewSet):
    queryset = Topping.objects.all()
    serializer_class = ToppingSerializer
 
 
class PizzaViewSet(ModelViewSet):
    queryset = Pizza.objects.all()
    serializer_class = PizzaSerializer
 
 
class OrderViewSet(ModelViewSet):
    queryset = Order.objects.all()
 
    def get_serializer_class(self):
        if self.action in ("create", "update", "partial_update"):
            return OrderSummarySerializer
        return OrderDetailSerializer

I have a ViewSet for Topping, Pizza, and Order; there’s no sense having one for Box, since I’m only creating new boxes when creating a Pizza.

One item of note is the OrderViewSet, where different serializers are needed for different usages. When the user is creating the order, they should not have to specify the pizzas, as that relationship is handled by the Pizza model instead. However, when the user is viewing the order, they should see all the details about the pizzas.

To handle this, I define get_serializer_class(), and check for an API action of create, update, or partial_update; for any of those actions, I use OrderSummarySerializer, which does not include the pizzas field. However, for everything else, I use OrderDetailSerializer, which includes pizzas.

Creating the API Endpoints

Finally, I must expose the ViewSets via the API. In urls.py, I specify the API endpoints:

from django.urls import path, include
from rest_framework import routers
 
from pizzaria.views import BoxViewSet, OrderViewSet, PizzaViewSet, ToppingViewSet
 
router = routers.DefaultRouter()
router.register("orders", OrderViewSet, "orders")
router.register("pizzas", PizzaViewSet, "pizzas")
router.register("toppings", ToppingViewSet, "toppings")
 
urlpatterns = [
    path("pizzaria/", include(router.urls))
]

All this assumes that the pizzaria app has been configured in my Django Rest Framework project, although I’m omitting that here, since that part is pretty well documented.

Now I can interact with the API at url.to.api/pizzaria/pizzas/ and the other endpoints, and the GET and POST operations will work. I can also use url.to.api/pizzaria/pizzas/«uuid-of-a-pizza-object»/ for the PUT, PATCH, and DELETE operations.

Using the API

I sent the following payload via POST to /pizzaria/orders/:

{
    "customer": "Bob Smith",
    "address": "123 Example Road"
}

That created the base order. I received the following response:

{
    "id": "e02c46ce-742a-4656-ba9c-e80afed7304c",
    "customer": "Bob Smith",
    "address": "123 Example Road"
}

Next, I add a pizza to the order via POST to /pizzaria/pizzas/:

{
    "order": "e02c46ce-742a-4656-ba9c-e80afed7304c",
    "box": {
        "color": "white"
    },
    "toppings": [
        "sausage",
        "olives",
        "mushrooms"
    ]
}

Now if I call GET on /pizzaria/orders/, I see the pizza is on the order:

[
    {
        "id": "e02c46ce-742a-4656-ba9c-e80afed7304c",
        "customer": "Bob Smith",
        "address": "123 Example Road",
        "pizzas": [
            {
                "id": "92171c46-6526-46d0-a534-fd07fb542611",
                "box": {
                    "id": "59b84a9e-4da9-4d3e-bf5c-b157ab98f2a9",
                    "color": "red"
                },
                "toppings": [
                    "mushrooms",
                    "olives",
                    "sausage"
                ]
            }
        ]
    }
]

One mushroom, olive, and sausage pizza, coming right up, Mr. Smith!

Handling a “through” model on ManyToManyField

Our data models are working pretty well, but the API requires us to manually list all the toppings on each pizza. To save time, let’s add support for a menu. There are two considerations here:

  1. We need to store extra information, like “medium size”.

  2. We need to support modifications atop the menu item, like “add extra cheese”. These modifications should only affect the one order, and not the menu item itself.

In Django, we typically specify a “through” model on a ForeignKey to allow adding extra information to the field, such as in the example pizza = models.ForeignKey("pizzaria.Pizza", through=PizzaInstance) or something of that sort. However, this doesn’t work here for two reasons: first, Pizza has the key to Order, and not the other way around, and second, DRF serializers do not play well with through=!

The Models

Instead, I will create separate, standalone models for PizzaMenuItem and Pizza, where the latter represents a single instance of a pizza, associated with an order.

I’ll start this change by adding a new model for a menu item:

class PizzaMenuItem(models.Model):
    name = models.CharField(
        primary_key=True,
        max_length=128
    )
 
    box = models.OneToOneField(
        "pizzaria.Box",
        on_delete=models.SET_NULL,
        related_name="contents",
        null=True
    )
 
    toppings = models.ManyToManyField(
        "pizzaria.Topping",
        related_name='+'
    )

This is almost the same as a Pizza, except I don’t have the order field, and I’ve swapped the id for a name as a primary key, like I have for Topping.

Next, I’ll adjust my Pizza model to add a size field, a menu_item ForeignKey field, a remove_toppings field, and to rename toppings to extra_toppings

class Pizza(models.Model):
    id = models.UUIDField(
        primary_key=True,
        default=uuid.uuid4,
        editable=False
    )
 
    menu_id = models.ForeignKey(
        "pizzaria.Pizza",
        on_delete=models.SET_NULL,
        related_name="+",
        null=True
    )
 
    order = models.ForeignKey(
        "pizzaria.Order",
        on_delete=models.CASCADE,
        related_name="pizzas",
        null=False
    )
 
    SIZE_CHOICES = Choices(
        'small',
        'medium',
        'large',
        'x-large'
    )
 
    size = models.CharField(
        max_length=32,
        choices=SIZE_CHOICES,
        blank=False,
        null=False
    )
 
    extra_toppings = models.ManyToManyField(
        "pizzaria.Topping",
        related_name="+"
    )
 
    remove_toppings = models.ManyToManyField(
        "pizzaria.Topping",
        related_name="+"
    )
 
    @property
    def toppings(self):
        toppings = []
 
        if self.menu_id is not None:
            toppings += self.menu_id.toppings
        toppings += self.extra_toppings
 
        for topping in self.remove_toppings:
            try:
                toppings.remove(topping)
            except ValueError:
                pass  # don't worry about removing absent toppings
 

Notice that I’ve provided a toppings property at the end. My purpose with this is to allow toppings to be added and removed atop the ones defined in the menu, without the menu item actually being modified.

I’ve also changed the toppings field to extra_toppings, and added a toppings property. When I read toppings, I want to see the toppings from the menu item and the extra toppings I added.

Models for Non-Destructive Editing

One situation where this pattern comes in handy is where you need to allow non-destructive editing. Departing briefly from our example, consider if we had a GreetingCard model, and we wanted to allow someone to modify the message on one card, without modifying the GreetingCard itself for everyone:

class GreetingCard(models.Model):
    message = models.CharField(max_length=256, blank=True, null=False)
 
class GreetingCardInstance(models.Model):
	# a ForeignKey to the GreetingCard this expands on
	card = models.ForeignKey(
		'cards.GreetingCard',
		on_delete=models.CASCADE,
		related_name='instances'
		null=False
	)
 
	# a ForeignKey to another GreetingCardInstance
	on_instance = model.ForeignKey(
		'cards.GreetingCardInstance',
		on_delete=models.CASCADE,
		related_name='modified_by',
		null=True
	)
 
	# a private field to store local data
	_message = models.CharField(max_length=256, blank=True, null=True)
 
	@property
	def message(self):
		# return the locally defined value, if there is one
		if self.message is not None:
			return self.message
 
		# otherwise, if this edits another instance...
		elif on_instance is not None:
			# delegate to that message property
			return self.on_instance.message
 
		# if all else fails, get the message from the card
		return self.card.message
 
	@message.setter
	def _(self, value):
		# store the value locally
		self._message = value
 
	@message.deleter
	def _(self):
		# delete the value locally only
		del self._message

This results in GreetingCardInstance acting like it has a message field that supports CRUD like normal, but in fact, is a bit more nuanced. The read operation checks for a value locally, on any GreetingCardInstance that this instance is intended to modify (if one exists), and then finally on the Card. However, the create, update, and delete operations occur locally only.

One interesting but important detail here is that the _message field supports both null and blank. This is usually not recommended, as it leaves the field with two distinct empty states; in this case, however, it’s actually helpful! If _message is blank, we want to treat that as an override of GreetingCard.message; however, if _message is null, we’d want to fall back on the value in GreetingCard.message instead.

The serializer coming up supports this non-destructive editing pattern, the same as it supports the primary example of the Pizza and PizzaMenuItem models.

Handling Properties in Serializers

Back to our pizzaria, the serializer for PizzaMenuItem isn’t very surprising. Here it is:

class PizzaMenuItemSerializer(serializers.ModelSerializer):
    box = BoxSerializer(read_only=True)
    toppings = ToppingSerializer(read_only=True, many=True)
 
    def create(self, validated_data):
        request = self.context['request']
 
        box_data = request.data.get('box')
        box_data = attempt_json_deserialize(box_data, expect_type=dict)
        box = Box.objects.create(**box_data)
        validated_data['box'] = box
 
        toppings_data = request.data.get('toppings')
        toppings_data = attempt_json_deserialize(toppings_data, expect_type=list)
        validated_data['toppings'] = toppings_data
 
        instance = super().create(validated_data)
 
        return instance
 
    def update(self, instance, validated_data):
        request = self.context['request']
 
        box_data = request.data.get('box')
        box_data = attempt_json_deserialize(box_data, expect_type=dict)
        box = Box.objects.create(**box_data)
        validated_data['box'] = box
 
        toppings_data = request.data.get('toppings')
        toppings_ids = attempt_json_deserialize(toppings_data, expect_type=list)
        validated_data['toppings'] = toppings_ids
 
        instance = super().update(instance, validated_data)
 
        return instance
 
    class Meta:
        model = PizzaMenuItem
        fields = ('name', 'box', 'toppings')

The serializer for Pizza requires a little more work. Since a property isn’t actually a Django field, so it is always necessary to explicitly declare the serializer that must be used with the property.

class PizzaSerializer(serializers.ModelSerializer):
    order = OrderSummarySerializer(read_only=True)
    toppings = ToppingSerializer(read_only=True, many=True)
    box = BoxSerializer(source='menu_item.box', read_only=True)
 
    class Meta:
        model = Pizza
        fields = ('id', 'order', 'box', 'menu_item', 'toppings', 'size')

The serializer specified is used to interpret the Python data returned by either the field or property; in this case, toppings works because the property is returning a list of primary keys for Topping objects.

Another interesting detail here is the box field, which I don’t define on the Pizza model! I can use the source= field to expose a field across a relational field; in this case, I get the box field from menu_item and expose that directly here as a read-only property. This requires menu_item to be required (null=False).

You’ll notice that I don’t expose extra_toppings or remove_toppings in fields. I plan to support these indirectly in create() and update(), but I don’t want to expose these fields directly; read operations should go through toppings.

Here are those two methods for the serializer:

class PizzaSerializer(serializers.ModelSerializer):
	# --snip--
 
	def create(self, validated_data):
        request = self.context['request']
 
        menu_item_pk = request.data.get('menu_item')
        menu_item_pk = attempt_json_deserialize(menu_item_pk)
        if menu_item_pk is not None:
            validated_data['menu_item_id'] = menu_item_pk
 
        order_pk = request.data.get('order')
        order_pk = attempt_json_deserialize(order_pk, expect_type=str)
        validated_data['order_id'] = order_pk
 
        extra_toppings_data = request.data.get('extra_toppings')
        extra_toppings_data = attempt_json_deserialize(extra_toppings_data, expect_type=list)
        validated_data['extra_toppings'] = extra_toppings_data
 
        remove_toppings_data = request.data.get('remove_toppings')
        remove_toppings_data = attempt_json_deserialize(remove_toppings_data, expect_type=list)
        validated_data['remove_toppings'] = remove_toppings_data
 
        instance = super().create(validated_data)
 
        return instance
 
    def update(self, instance, validated_data):
        request = self.context['request']
 
        menu_item_pk = request.data.get('menu_item')
        menu_item_pk = attempt_json_deserialize(menu_item_pk)
        if menu_item_pk is not None:
            validated_data['menu_item_id'] = menu_item_pk
 
        order_pk = request.data.get('order')
        order_pk = attempt_json_deserialize(order_pk, expect_type=str)
        validated_data['order_id'] = order_pk
 
        extra_toppings_data = request.data.get('extra_toppings')
        extra_toppings_data = attempt_json_deserialize(extra_toppings_data, expect_type=list)
        validated_data['extra_toppings'] = extra_toppings_data
 
        remove_toppings_data = request.data.get('remove_toppings')
        remove_toppings_data = attempt_json_deserialize(remove_toppings_data, expect_type=list)
        validated_data['remove_toppings'] = remove_toppings_data
 
        instance = super().update(instance, validated_data)
 
        return instance

Serializers for Non-Destructive Editing

I’d like to briefly revisit the greeting card non-destructive editing example.

The serializer for GreetingCard is as simple as it comes. Since message is just a CharField, a Django primitive field, I don’t need to do anything special to serialize that field:

class GreetingCardSerializer(serizaliers.ModelSerializer):
	class Meta:
		model = GreetingCard
		fields = ('message',)

The serializer for GreetingCardInstance requires a bit more work. Because message is a property, I still have to explicitly specify the serializer to use, despite the fact the property returns a value from a primitive Django field type:

class GreetingCardInstanceSerializer(serializers.ModelSerializer):
	message = serializers.CharField(max_length=256)
 
	class Meta:
		model = GreetingCardInstance
		fields = ('message',)

The CharField serializer accepts the same arguments as the field in regard to what the data looks like. That’s always the case for primitive serializers: decimal places, length, choices, and the like, all have to be defined on the serializer. I like to think of these as defining the “shape” of the data, which both a field and a serializer would need to know. However, it is not necessary to specify whether null or blank are allowed, nor do you have to repeat the verbose name or other such field metadata.

Admittedly, it gets a little annoying to have to duplicate the arguments defining the “shape” of your data, but it’s worth the extra effort to get this non-destructive editing.

WARNING: If you try to create an object, like GreetingCardInstance, via GreetingCardInstance.objects.create(), it will bypass the serializer! As such, you would not be able to include a value for message in that call; you’d have to specify a value for _message instead.

Views, URLs, and API usage

Returning once again to the pizzaria example, my update to views.py is not surprising in the least:

# --snip--
 
class PizzaViewSet(ModelViewSet):
    queryset = Pizza.objects.all()
    serializer_class = PizzaSerializer
 
 
class PizzaMenuItemViewSet(ModelViewSet):
    queryset = PizzaMenuItem.objects.all()
    serializer_class = PizzaMenuItemSerializer
 
# --snip--

Warning: Some solutions online suggest using your ViewSet objects to manipulate the create and update payloads before they reach the serializer, but I strongly advise against this. The further the logic is from the model, the more edge cases the logic is going to be unable to elegantly handle.

Finally, I expose the new menu endpoint in urls.py:

# --snip--
 
from pizzaria.views import OrderViewSet, PizzaViewSet, ToppingViewSet, PizzaMenuItemViewSet
 
router = routers.DefaultRouter()
router.register("orders", OrderViewSet, "orders")
router.register("pizzas", PizzaViewSet, "pizzas")
router.register("toppings", ToppingViewSet, "toppings")
router.register("menu", PizzaMenuItemViewSet, "menu")
 
# --snip--

Now I can make some API calls. On the api/pizzaria/menu/ endpoint, I call POST with the following payload:

{
    "name": "ansi standard",
    "toppings": [
        "pepperoni",
        "mushrooms"
    ],
    "box": {
        "color": "black"
    }
}

Now on api/pizzaria/pizzas/, I call POST with the following payload:

{
    "order": "017eae04-5123-41ac-b944-8f8208d75298",
    "menu_item": "ansi standard",
    "size": "large",
    "extra_toppings": [
        "sausage",
        "olives"
    ],
    "remove_toppings": [
        "mushrooms"
    ]
}

You’ll notice that I specify extra_toppings and remove_toppings as lists of strings (primary keys for Topping objects).

Calling GET on the same endpoint shows me the following:

{
    {
        "id": "166de02c-3753-46db-8cab-451ad0be5a4a",
        "order": {
            "id": "017eae04-5123-41ac-b944-8f8208d75298",
            "customer": "Bob Smith",
            "address": "123 Example Road"
        },
        "box": {
            "id": "5f5aafd6-352d-48e9-926a-25d7bb0be9f9",
            "color": "black"
        },
        "menu_item": "ansi standard",
        "toppings": [
            {
                "name": "olives"
            },
            {
                "name": "sausage"
            },
            {
                "name": "pepperoni"
            }
        ],
        "size": "large"
    }
]

Notice that toppings is displayed, but extra_toppings and remove_toppings are absent. Remember, the “ansi standard” pizza is pepperoni and mushroom, but on this pizza, I asked to remove mushroom (leaving only pepperoni) and to add olives and sausage. Thus, I see pepperoni, which is coming from the PizzaMenuItem object, and olives and sausage from the Pizza object. Mushrooms are omitted because of how the toppings property uses remove_toppings on the Pizza object.

I also see the box field here, which is coming from the PizzaMenuItem object; it never existed on the Pizza object.

Summary

Despite the many surprises DRF presents to the developer when working with relational fields for the first time, once you understand the patterns, it’s not terribly difficult.

  • Define relationships in your models with ForeignKey, OneToOneField, and ManyToManyField fields.

  • Use properties on your models to simulate fields that store and interpret data from related models.

  • Use your serializers to intercept and interpret data meant for these relational fields, to expose properties as fields, and to expose fields on related models.

  • Use viewsets to expose these serializers. Avoid using a viewset to manipulating the payload being passed to the serializer.

Happy coding!

Want to be alerted when we publish future blogs? Sign up for our newsletter!