Django’s own tutorial gets you from a model to a database table in about ten minutes. Getting from that same model to working API response takes a lot longer to actually understand - not because the code is long, but because most explanations skip straight to “just use a serializer” without ever showing what breaks first.

This is the first piece in a short series working trough Django REST Framework the way I wish I’d learned it: starting from what already makes sense - the ORM, and building forward from there, one real problem at a time, instead of starting from a definition and hoping it sticks. Now, say hello to the serializers.

Where the ORM stops

Let’s say you have got a row from your database - a real Python object with attributes like .title, .city, .country, .continent, .purchase_date, and .price:

#models.py

from django.db import models

class Purchase(models.Model):
      title = models.TextField(blank=True, null=True)
      city = models.CharField(max_length=255, blank=True, null=True)
      country = models.CharField(max_length=255, blank=True, null=True)
      continent = models.CharField(max_length=255, blank=True, null=True)
      purchase_date = models.DateField()
      price = models.DecimalField(max_digits=10, decimal_places=2)

This is what Django ORM gives you from your models/tables in the database: a Python object with real Python types for each field. Nothing about JSON here:

#views.py
def list_purchase(request):
    purchase = Purchase.objects.get(id=1)

Now, try to send that back as an API response, directly:

import json
json.dumps(purchase)

#TypeError: Object of type Purchase is not JSON serializable

Fails immediately - and even if you tried purchase.dict you would hit a second problem: purchase_date is a datetime.date object, which json.dumps() also can’t handle on its own.

So why does it need to be JSON at all?

Because whatever’s calling your API almost certainly isn’t Python. It could be a JavaScript frontend, a mobile app, another service written in a completely different language, or just someone testing with curl. None of those can understand a Django model instance - but every one of them can parse JSON, because JSON is a universal data format.

JSON is just plain text, built from a handful of universal shapes: strings, numbers, booleans, lists, and key-value pairs. That’s the actual reason JSON won as the standard format for APIs - not because it’s better in some abstract sense, but because it’s the one thing almost every programming language on both sides of the connection already agrees on.

So, Django has to bridge that gap somehow. We already saw that the ORM won’t do it - json.dumps(purchase) failed immediately and even a manual dict dump would still throw an error on purchase_date, since datetime.date isn’t JSON-safe either.

The solution: every field needs its own conversion - text fields are already fine, but dates need to become strings, decimals need to become numbers or strings depending on precision, related objects need to become their IDs or nested data - and someone has to define that conversion, field by field, before Django can actually return valid JSON.

#views.py

def serialize_purchase_by_hand(purchase):
    return {
        "id": purchase.id,
        "title": purchase.title,
        "city": purchase.city,
        "country": purchase.country,
        "continent": purchase.continent,
        "purchase_date": purchase.purchase_date.isoformat(),
        "price": str(purchase.price)
    }

# call the serialized data

import json
json.dumps(serialize_purchase_by_hand(purchase))

That works but pay attention to what it actually required:

  • every field listed by hand, twice really - once in the models.py and another time in views.py.
  • two different manual conversions, each one field-type-specific and easy to get wrong - purchase_date.isoformat() vs. just str(purchase_date) behave differently; str(price) keeps precision exact, but float(price), a very convenient shortcut, silently loses it.
  • this exact function has to be rewritten, field by field for every single model in our project.

What Serializer actually does

#serializer.py
from rest_framework import serializers
from .models import Purchase


class PurchaseSerializer(serializers.ModelSerializer):
    class Meta:
        model= Purchase
        fields= "__all__"

When you write class PurchaseSerializer(serializers.ModelSerializer) with model= Purchase and fields = “__all__”, DRF doesn’t wait until you use it to do any work - it immediately inspects the Purchase model and builds a matching serializer field for every model field: a CharField on the model becomes a CharField on the serializer, DateField becomes DateField, DecimalField becomes DecimalField, and so on.

Each of those serializer fields already knows how to safely convert its own type - the DateField serializer field knows how to call something equivalent to .isoformat(), the DecimalField serializer field knows to stringify rather than call float().

This is the same introspection Django’s own admin panel and forms already rely on to build themselves from a model - DRF is reusing that same idea for API output.

Now, let’s look at how that serialized data actually gets used:

#views.py, usage of the serialized data

from rest_framework.views import APIView
from rest_framework.response import Response

class PurchaseDetailView(APIView):
    def get(self, request, pk):
        purchase = Purchase.objects.get(id=pk)
        serializer = PurchaseSerializer(purchase)
        serializer.data
        return Response(serializer.data)

Then PurchaseSerializer(purchase) doesn’t convert anything yet - it just wraps the purchase instance, ready to be converted. The actual conversion only happens when you access .data: at that point, the serializer walks through every field it built, pulls the matching attribute off purchase, runs it through that field’s conversion, and assembles the result into a plain Python dict.

One detail worth mentioning here: .data is a Python dict, not a JSON string yet. It is fully JSON-safe: every value in it is a type json.dumps() can now handle without complaint - but turning it into actual JSON text is a separate, final step (which, inside DRF, a Response object does for you automatically).

Not everything has to be __all__

fields = __all__ is the fastest way to get a serializer working, but it means every field on the model gets exposed, including ones you might not want to send back to a client - say the model also had an internal_notes field for staff use only:

#models.py
class Purchase(models.Model):
    ...
    internal_notes = models.TextField(blank=True, null=True)

Now, there are two ways to control this, and they’re opposite of each other:

#serializer.py
class PurchaseSerializer(serializers.ModelSerializer):
    class Meta:
        model = Purchase
        fields = ["id", "title", "city", "country", "continent", "purchase_date", "price"]
#serializer.py
class PurchaseSerializer(serializers.ModelSerializer):
    class Meta:
        model = Purchase
        exclude = ["internal_notes"]

fields is an allow-list - you name exactly what gets out, and anything new added to the model later stays hidden by default until you explicitly add it. exclude is a block-list - everything gets out except what you name, which is less typing when a model has a lot of fields you want, but means a new sensitive field added later leaks automatically unless you remember to exclude it too. Same mechanism DRF is already doing for __all__, just told to skip specific fields instead of none.

Fields that don’t exist on the model at all

fields and exclude only work with things the model actually stores. But sometimes what you want to send back isn’t a column - it’s something computed from one. Say you wanted to tell the client whether a purchase was recent, without storing that as its own field:

#serializer.py
from datetime import date

class PurchaseSerializer(serializers.ModelSerializer):
    is_recent = serializers.SerializerMethodField()
    class Meta:
        model = Purchase
        fields = ["id", "title", "purchase_date", "price", "is_recent"]

    def get_is_recent(self, obj):
        return (date.today() - obj.purchase_date).days < 30

SerializerMethodField is DRF’s escape hatch for exactly this - is_recent isn’t a real column, so the automatic model-introspection we relied on earlier can’t build it for you. Instead, you declare it explicitly and write get_<field_name> yourself; DRF calls that method with the object and drops whatever it returns straight into .data. Same output shape as every other field, computed instead of copied.

ModelSerializer isn’t the only option

That’s about which fields show up - but ModelSerializer itself isn’t the only serializer you can reach for either.

ModelSerializer is actually built on top of a plainer class, serializers.Serializer, which doesn’t inspect a model at all - you declare every field manually:

#serializer.py
class PurchaseSerializer(serializers.Serializer):
    id = serializers.IntegerField(read_only=True)
    title = serializers.CharField()
    purchase_date= serializers.DateField()
    price= serializers.DecimalField(max_digits=10, decimal_places=2)

More typing, but it doesn’t need a model behind it at all - useful for serializing a dict, a computed report, or a response from some other API. ModelSerializer is Serializer plus the auto-generation step we relied on earlier; it’s not a different thing, it’s a shortcut on top of the same base.

There’s also HyperlinkedModelSerializer, a variant of ModelSerializer that represents relationships as a URL ("/api/purchase/22") instead of a raw ID - it needs a working router behind it to build those URLs, so it’s a natural fit once we get to routers.

And separately, from a different angle entirely: pass many=True to any of these - PurchaseSerializer(queryset, many=True) - and DRF swaps in ListSerializer behind the scenes to handle a collection instead of one instance. Same .data, just now a list of dicts instead of one.

What about the other half of the job?

Everything so far has gone one direction: a Purchase row, out to JSON. But as we mentioned at the beginning, one of the superpowers of the serializers is the ability to turn the incoming data back into something real. This process is called deserialization.

This reverse direction is harder than it sounds, because incoming data can’t be trusted the way a row already in your database can. It might be missing a field, have price sent as the string “free”, or a date that isn’t a real date at all. Something has to check all of that before it’s allowed anywhere near .save().

By hand, that would mean manually checking each field exists, manually checking each one is the right type, converting strings back into Decimal/date objects yourself, and raising your own errors when something’s wrong - the same field-by-field repetition as serialize_purchase_by_hand(), just running in the opposite direction and now also responsible for catching bad input.

In our case, the same PurchaseSerializer does this for us, we just use it differently:


serializer = PurchaseSerializer(data=request.data)
serializer.is_valid()
serializer.save()

So, our complete class becomes:

#views.py, usage of the serialized data

from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status

class PurchaseDetailView(APIView):
    def post(self, request):
        serializer = PurchaseSerializer(data=request.data)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data, status=status.HTTP_201_CREATED)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

This is the standard, complete pattern - not a simplified version. A few things worth knowing as you look at it:

  • serializer.is_valid() returns True/False - it doesn’t raise an error on bad data, it just tells you whether validation passed.
  • On success: serializer.save() creates the actual Purchase row, and returning serializer.data back gives the client the newly created object (now including its DB-assigned id) with 201 Created.
  • On failure: serializer.errors is a dict of exactly what went wrong, field by field (e.g. {“price”: [“A valid number is required.”]}) - returning with 400 Bad Request, so the client knows precisely what to fix.
  • That if/else branching on is_valid() is the piece that replaces all the manual “check each field, raise my own errors” work we would otherwise have to write by hand for the incoming side.

Checking the things DRF doesn’t know

is_valid() already catches a lot for free - a missing required field, a price that isn’t a valid number, a purchase_date that isn’t a real date. But none of that is business logic; it’s just “does this look the right type.” DRF has no way of knowing on its own that a negative price doesn’t make sense for a purchase.

For a signle field, you add validate_<field_name> method to the serializer, named after the field it checks:

#serializer.py
from rest_framework import serializers
from .models import Purchase

class PurchaseSerializer(serializers.ModelSerializers):
    class: Meta
        model = Purchase
        fields = "__all__"

    def validate_price(self, value):
        if value <= 0:
            raise serializers.ValidationError("Price must be greater than zero. ")
        return value

DRF finds validate_price automatically - the naming convention (validate + field name) is enough, you don’t register it anywhere. It runs after the field’s own type conversion already succeeded, and before is_valid() returns. If validate_price raises ValidationError, that error lands in serializer.errors under price, the same shape as built-in type error - the client can’t tell “price waqsn’t a number” apart from “price was a number but not allowed.”


class PurchaseSerializer(serializers.ModelSerializers):
    class: Meta
        model = Purchase
        fields = "__all__"

    def validate_price(self, data):
        if data["purchase_date"] > date.today():
            raise serializers.ValidationError("Purchase date can't be in the future")
        return data

Same idea, wider scope: validate() runs after every individual field - and every validate_<field_name> - has already passed, and it receives the whole cleaned dict instead of one value, which is the only way to compare two fields against each other.

Where serializers stop and views start

Notice something about PurchaseDetailView and its post() method: the serializer never once dealt with HTTP. It didn’t know about status codes, didn’t touch request directly, didn’t decide what “success” or “failure” means at the API level. That was all the view’s job - APIView in this case, about as plain as it gets.

Next up — DRF Views: what that APIView is actually doing for us, and where it starts to fall short - once you are writing the same list/create/update/delete pattern across six different models, and generic views and ViewSets start looking less like magic and more like the obvious next step.