Model Relationship Rest
Model Relationship Rest
com/blog/2022/06/14/model-relationships-django-rest-framework/
• software remodeling
tooling
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!)
The first thing to know is that your Django models define how your app’s database is structured.
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 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:
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
)
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='+'
)
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.
In the case of a ForeignKey or OneToOneField, a column is created. For Pizza, the table looks like
this:
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…
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.
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:
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
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.
# --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--
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.
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--
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
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
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.
Finally, I must expose the ViewSets via the API. In urls.py, I specify the API endpoints:
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.
{
"customer": "Bob Smith",
"address": "123 Example Road"
}
{
"id": "e02c46ce-742a-4656-ba9c-e80afed7304c",
"customer": "Bob Smith",
"address": "123 Example Road"
}
{
"order": "e02c46ce-742a-4656-ba9c-e80afed7304c",
"box": {
"color": "white"
},
"toppings": [
"sausage",
"olives",
"mushrooms"
]
}
[
{
"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!
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 = []
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.
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
)
@property
def message(self):
# return the locally defined value, if there is one
if self.message is not None:
return self.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.
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)
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
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
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).
class PizzaSerializer(serializers.ModelSerializer):
# --snip--
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
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
return instance
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.
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.
# --snip--
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"
}
}
{
"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).
{
{
"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!