DRF: Serializers

Every lesson so far has been about rendering HTML — a browser asks for a page, Django hands back a template with some data filled in. But plenty of real clients don't want HTML at all: a mobile app, a separate JavaScript frontend, or another service entirely usually wants plain data, typically as JSON. Django REST Framework (DRF) is the toolkit almost every real-world Django project reaches for to build that kind of API — it's not part of Django itself, but it's so widely used that it's fair to call it the de facto standard. This is a genuinely major addition to what TaskFlow can do, and it gets five full lessons starting here.

Installing DRF

DRF is a separate package, installed like any other:

pip install djangorestframework

Then it's registered as an app, exactly the way TaskFlow's own app was registered back in Your First App:

# taskflow_project/settings.py
INSTALLED_APPS = [
    # ...Django's built-in apps...
    'rest_framework',
    'taskflow',
]

The Problem a Serializer Solves

A Task instance is a Python object — it has a .title, a .due_date that's an actual date object, a .project that's another model instance. None of that is JSON on its own; JSON only understands strings, numbers, booleans, lists, and plain objects. Something has to translate between "a Django model instance" and "a JSON-shaped dict," in both directions — reading a model out to JSON, and validating incoming JSON before turning it into a model. That translator is a serializer.

ModelSerializer: The API Equivalent of a ModelForm

If ModelForm from ModelForms already clicked, DRF's ModelSerializer will feel immediately familiar — it's the same idea, aimed at API data instead of HTML forms. Just like a ModelForm, a ModelSerializer uses an inner Meta class pointing at a model and listing which fields to expose, and Django generates the rest automatically:

# taskflow/serializers.py
from rest_framework import serializers
from .models import Task

class TaskSerializer(serializers.ModelSerializer):
    class Meta:
        model = Task
        fields = ['id', 'title', 'due_date', 'priority', 'completed']

Just like Meta.fields on a ModelForm, this list is deliberately explicit — description, project, and tags simply aren't included in this particular serializer's output, the same "only expose what you mean to" reasoning covered back in ModelForms applies here too.

Serializing: Model Instance to JSON

To see what a serializer actually does, run it against one real Task in the Django shell. Wrapping an instance in TaskSerializer(...) and reading its .data attribute produces a plain Python dict — the exact shape that gets turned into JSON on the way out:

>>> my_task = Task.objects.get(title='Wireframe the homepage')
>>> serializer = TaskSerializer(my_task)
>>> serializer.data

The resulting .data is an ordered dict that looks like this once rendered as JSON:

{
  "id": 14,
  "title": "Wireframe the homepage",
  "due_date": "2026-08-20",
  "priority": "high",
  "completed": false
}

Deserializing: Incoming JSON to a Validated Object

The reverse direction — turning JSON sent by a client into a saved Task — mirrors the validate-then-save pattern from ModelForms almost exactly. Pass the incoming data as the data= argument instead of an instance, call .is_valid(), then .save():

# incoming_json arrives as a Python dict, e.g. parsed from a POST request body
incoming_json = {
    'title': 'Draft the release notes',
    'due_date': '2026-09-01',
    'priority': 'medium',
    'completed': False,
}

serializer = TaskSerializer(data=incoming_json)
if serializer.is_valid():
    serializer.save()  # creates and saves a new Task row
else:
    # serializer.errors holds field-by-field validation problems,
    # the same role form.errors plays for a ModelForm
    pass

is_valid() runs the same kind of field-level validation a ModelForm runs — checking priority is one of the allowed choices, that due_date actually parses as a date — and populates serializer.errors if anything fails, exactly the way form.errors does. save() only runs against already-validated data, same rule as a form.

Nested Serializers: Representing a Related Object In Full

The TaskSerializer above would represent project as just its numeric ID, which is often exactly what you want. But sometimes an API response is more useful if a task's project comes through as a full object rather than a bare number. Nesting one serializer inside another does exactly that:

# taskflow/serializers.py
from rest_framework import serializers
from .models import Task, Project

class ProjectSerializer(serializers.ModelSerializer):
    class Meta:
        model = Project
        fields = ['id', 'name']

class TaskSerializer(serializers.ModelSerializer):
    project = ProjectSerializer(read_only=True)

    class Meta:
        model = Task
        fields = ['id', 'title', 'due_date', 'priority', 'project']

With project overridden this way, a task's JSON now nests the project's own id and name instead of a bare integer. read_only=True matters here: nested serializers make writing (creating or updating through the nested object) noticeably more involved, so it's common to keep a nested field read-only and handle writes to project separately, such as by ID.

SerializerMethodField: Computed Values That Aren't Model Fields

Not everything worth returning from an API exists as a column on the model. Whether a task is overdue, for instance, depends on comparing due_date against today's date — there's no is_overdue field on Task to point a serializer at. A SerializerMethodField covers exactly this: it calls a method you define, and includes whatever that method returns.

# taskflow/serializers.py
from datetime import date
from rest_framework import serializers
from .models import Task

class TaskSerializer(serializers.ModelSerializer):
    is_overdue = serializers.SerializerMethodField()

    class Meta:
        model = Task
        fields = ['id', 'title', 'due_date', 'completed', 'is_overdue']

    def get_is_overdue(self, obj):
        return not obj.completed and obj.due_date < date.today()

DRF looks for a method named get_<field_name> automatically — here, get_is_overdue — and calls it with the object being serialized, so the field's value can be anything computed from that object rather than being read straight off a database column.

Note: All of the serializer code above is correct, real DRF code — but this website's own project doesn't run a live Django server, so none of it actually executes on this page.
Next up: A serializer only handles the data shape — something still has to receive an HTTP request and decide when to use it. DRF: Views & ViewSets covers exactly that.