Skip to content

Relations

Relational fields are used to represent model relationships. They can be applied to ForeignKey, ManyToManyField and OneToOneField relationships.

Shared Models

For the following examples, assume these Album and Track models are defined:

python
from django.db import models
from django.utils.text import slugify

class Album(models.Model):
    album_name = models.CharField(max_length=100)
    artist = models.CharField(max_length=100)

class Track(models.Model):
    album = models.ForeignKey(Album, related_name='tracks', on_delete=models.CASCADE)
    order = models.IntegerField()
    title = models.CharField(max_length=100)
    duration = models.IntegerField()
    slug = models.SlugField(unique=True)

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.title)
        super().save(*args, **kwargs)

    def __str__(self):
        return self.title

PrimaryKeyRelatedField

PrimaryKeyRelatedField may be used to represent the target of the relationship using its primary key.

python
class AlbumSerializer(serializers.ModelSerializer):
    tracks = serializers.PrimaryKeyRelatedField(many=True, read_only=True)

    class Meta:
        model = Album
        fields = ['album_name', 'artist', 'tracks']
json
{
    "album_name": "The Dark Side of the Moon",
    "artist": "Pink Floyd",
    "tracks": [
        89,
        90,
        91
    ]
}

NOTE

The name of the variable you assign a relational field to in your serializer must exactly match the name of the relationship field on your Django model. If you want to use a different name for the output field, you must pass the source argument (e.g., song_list = serializers.PrimaryKeyRelatedField(many=True, read_only=True, source='tracks')).

StringRelatedField

StringRelatedField may be used to represent the target of the relationship using its __str__ method.

python
class AlbumSerializer(serializers.ModelSerializer):
    tracks = serializers.StringRelatedField(many=True, read_only=True)

    class Meta:
        model = Album
        fields = ['album_name', 'artist', 'tracks']
json
{
    "album_name": "The Dark Side of the Moon",
    "artist": "Pink Floyd",
    "tracks": [
        "Speak to Me",
        "Breathe",
        "On the Run"
    ]
}

SlugRelatedField

SlugRelatedField may be used to represent the target of the relationship using a unique field on the target, typically its slug.

python
class AlbumSerializer(serializers.ModelSerializer):
    tracks = serializers.SlugRelatedField(
        many=True,
        slug_field='slug',
        queryset=Track.objects.all()
    )

    class Meta:
        model = Album
        fields = ['album_name', 'artist', 'tracks']
json
{
    "album_name": "The Dark Side of the Moon",
    "artist": "Pink Floyd",
    "tracks": [
        "speak-to-me",
        "breathe",
        "on-the-run"
    ]
}

This keeps opaque IDs out of your payloads entirely. Clients send "tracks": ["speak-to-me"] on write and DRF resolves the Track objects by their slugs. The target field does not have to be a SlugField - any unique field on the related model works.

NOTE

slug_field is always required. Unless the field is read_only=True, a queryset is also required so incoming values can be resolved. If the value does not match any object, or matches more than one, DRF raises a validation error (400) rather than returning a 404.

HyperlinkedRelatedField

HyperlinkedRelatedField may be used to represent the target of the relationship using a hyperlink.

python
class AlbumSerializer(serializers.ModelSerializer):
    tracks = serializers.HyperlinkedRelatedField(
        many=True,
        read_only=True,
        view_name='track-detail'
    )

    class Meta:
        model = Album
        fields = ['album_name', 'artist', 'tracks']
json
{
    "album_name": "The Dark Side of the Moon",
    "artist": "Pink Floyd",
    "tracks": [
        "http://api.example.com/tracks/89/",
        "http://api.example.com/tracks/90/",
        "http://api.example.com/tracks/91/"
    ]
}

NOTE

By default, HyperlinkedRelatedField expects a view named '{model_name}-detail'. You can override this using the view_name argument.

Depth

If you only need a quick, read-only nested representation of your relationships, ModelSerializer provides a depth option in the Meta class.

python
class AlbumSerializer(serializers.ModelSerializer):
    class Meta:
        model = Album
        fields = ['album_name', 'artist', 'tracks']
        depth = 1

By setting depth = 1, DRF will automatically traverse all relationships one level deep and serialize them. This is an incredibly convenient shortcut, but it is strictly read-only and you cannot configure exactly which fields are included from the nested model.

Nested Serializers

If you want to fully populate relationships with control over the fields, or you want to support writes, you can use serializers as fields. This allows you to nest serializers to arbitrary depths.

python
class TrackSerializer(serializers.ModelSerializer):
    class Meta:
        model = Track
        fields = ['id', 'order', 'title', 'duration']

class AlbumSerializer(serializers.ModelSerializer):
    tracks = TrackSerializer(many=True, read_only=True)

    class Meta:
        model = Album
        fields = ['album_name', 'artist', 'tracks']
json
{
    "album_name": "The Dark Side of the Moon",
    "artist": "Pink Floyd",
    "tracks": [
        {
            "id": 89,
            "order": 1,
            "title": "Speak to Me",
            "duration": 67
        },
        {
            "id": 90,
            "order": 2,
            "title": "Breathe",
            "duration": 169
        }
    ]
}

Writable Nested Serializers

Django REST Framework does not support writable nested serializers out of the box. While the serializer will accept and validate the nested data, the base ModelSerializer will raise an error if you try to save it because it doesn't automatically know how to handle the child relationships.

To support write operations for a nested serializer, you must ensure the field does not have read_only=True and explicitly override the create() and/or update() methods to handle saving the nested objects yourself.

Furthermore, if you need to support updating existing nested objects, you'll need to make the primary key field available on the nested serializer, without enforcing uniqueness. Adding id = serializers.IntegerField(required=False) achieves this.

python
from django.db import transaction

class TrackSerializer(serializers.ModelSerializer):
    # This allows incoming tracks to be matched to existing database rows on update()
    id = serializers.IntegerField(required=False)

    class Meta:
        model = Track
        fields = ['id', 'order', 'title', 'duration']

class AlbumSerializer(serializers.ModelSerializer):
    tracks = TrackSerializer(many=True)

    class Meta:
        model = Album
        fields = ['album_name', 'artist', 'tracks']

    @transaction.atomic
    def create(self, validated_data):
        tracks_data = validated_data.pop('tracks')

        # Let the base class handle creating the Album
        album = super().create(validated_data)

        for track_data in tracks_data:
            track_data.pop('id', None)
            Track.objects.create(album=album, **track_data)

        return album

    @transaction.atomic
    def update(self, instance, validated_data):
        tracks_data = validated_data.pop('tracks', None)

        # Let the base class handle updating the Album fields
        instance = super().update(instance, validated_data)

        # Update the nested tracks
        if tracks_data is not None:
            existing_tracks = {track.id: track for track in instance.tracks.all()}
            seen_track_ids = set()

            for track_data in tracks_data:
                track_id = track_data.get('id')
                if track_id in existing_tracks:
                    # Update existing track dynamically
                    track = existing_tracks[track_id]
                    for attr, value in track_data.items():
                        if attr != 'id':
                            setattr(track, attr, value)
                    track.save()
                    seen_track_ids.add(track_id)
                else:
                    # Create new track
                    track_data.pop('id', None)
                    Track.objects.create(album=instance, **track_data)

            # Bulk delete omitted tracks
            missing_ids = set(existing_tracks.keys()) - seen_track_ids
            if missing_ids:
                instance.tracks.filter(id__in=missing_ids).delete()

        return instance

@transaction.atomic is essential here. Without it, a failure while saving the third track leaves the album persisted with an incomplete set of tracks and returns a 500 to the client. Wrapping both methods means the whole write succeeds or none of it does.

WARNING

This update() deletes any track missing from the payload. That is correct for PUT, but it makes PATCH destructive: a request sending a partial tracks list will remove every track it omits. If your API supports PATCH, either reject partial nested lists or match on id without deleting.

TIP

Most projects do not hand-roll this. The drf-writable-nested package provides WritableNestedModelSerializer, which handles nested creates, updates and deletes for you, reducing the class above to just its Meta block.