Django REST Framework: Serializers, ViewSets and Routers Complete Guide

A complete walkthrough of the Django REST Framework request cycle — how serializers validate and transform data, how ViewSets collapse CRUD into a few lines, and where each layer belongs.

Django REST Framework splits an API into three responsibilities: a serializer converts between model instances and JSON while validating input, a view maps HTTP methods to operations, and a router maps URLs to views. Most confusion about DRF comes from putting logic in the wrong one of those three.

This guide builds a small API end to end, then covers the validation, permission and pagination behaviour that production endpoints need.

Validation and Representation in One Class

A serializer is bidirectional. Reading, it turns model instances into primitive types ready for JSON. Writing, it validates incoming data and produces a dictionary of clean values in validated_data.

Python
A ModelSerializer with field-level and object-level validation.
from rest_framework import serializers
from .models import Post, Author


class PostSerializer(serializers.ModelSerializer):
    # A computed, read-only field
    author_name = serializers.CharField(source="author.name", read_only=True)
    comment_count = serializers.IntegerField(read_only=True)

    class Meta:
        model = Post
        fields = [
            "id", "title", "body", "author", "author_name",
            "comment_count", "published_at",
        ]
        read_only_fields = ["id"]

    def validate_title(self, value):
        """Field-level validation: runs for the `title` field only."""
        if len(value.strip()) < 5:
            raise serializers.ValidationError("Title must be at least 5 characters.")
        return value.strip()

    def validate(self, attrs):
        """Object-level validation: runs once, after all field validation."""
        if attrs.get("published_at") and not attrs.get("body"):
            raise serializers.ValidationError(
                {"body": "A published post must have a body."}
            )
        return attrs

Note the naming convention: validate_<field> methods are discovered automatically and run during field validation, while a single validate method runs afterwards with every field available. Cross-field rules belong in the latter, because the former cannot see other fields.

Note: Always specify fields explicitly rather than using fields = "__all__". With __all__, adding a sensitive column to the model silently exposes it through the API.

Representing Related Objects

By default a ForeignKey serialises to a primary key. DRF offers several representations, and nested writes require an explicit create override because the framework cannot guess how to handle them.

Python
Read-nested, write-by-id, and a fully writable nested serializer.
class AuthorSerializer(serializers.ModelSerializer):
    class Meta:
        model = Author
        fields = ["id", "name", "email"]


class PostReadSerializer(serializers.ModelSerializer):
    # Nested object on read
    author = AuthorSerializer(read_only=True)
    tags = serializers.SlugRelatedField(
        many=True, read_only=True, slug_field="slug"
    )

    class Meta:
        model = Post
        fields = ["id", "title", "author", "tags"]


class PostWriteSerializer(serializers.ModelSerializer):
    """Accepts author as a plain id, which is what clients usually send."""

    class Meta:
        model = Post
        fields = ["id", "title", "body", "author", "tags"]


class PostWithAuthorSerializer(serializers.ModelSerializer):
    """Fully writable nested relation — requires an explicit create()."""

    author = AuthorSerializer()

    class Meta:
        model = Post
        fields = ["id", "title", "body", "author"]

    def create(self, validated_data):
        author_data = validated_data.pop("author")
        author, _ = Author.objects.get_or_create(
            email=author_data["email"], defaults=author_data
        )
        return Post.objects.create(author=author, **validated_data)

Using separate read and write serializers is a common production pattern. It lets responses be rich and nested while keeping request bodies simple, without the two concerns fighting over one class.

Choosing the Right Amount of Abstraction

DRF offers a ladder of view abstractions. ModelViewSet provides all five CRUD actions; generic views provide one each; APIView provides none and leaves the HTTP handling to you.

Python
A ModelViewSet with per-action serializers and an optimised queryset.
from django.db.models import Count
from rest_framework import viewsets, permissions, status
from rest_framework.decorators import action
from rest_framework.response import Response


class PostViewSet(viewsets.ModelViewSet):
    permission_classes = [permissions.IsAuthenticatedOrReadOnly]
    filterset_fields = ["author", "tags__slug"]
    search_fields = ["title", "body"]
    ordering_fields = ["published_at", "title"]

    def get_queryset(self):
        # Optimise once, here, rather than in every action
        return (
            Post.objects
            .select_related("author")
            .prefetch_related("tags")
            .annotate(comment_count=Count("comments"))
        )

    def get_serializer_class(self):
        if self.action in ("list", "retrieve"):
            return PostReadSerializer
        return PostWriteSerializer

    def perform_create(self, serializer):
        # Attach the request user rather than trusting a client-sent id
        serializer.save(author=self.request.user.author)

    @action(detail=True, methods=["post"])
    def publish(self, request, pk=None):
        post = self.get_object()
        post.published_at = timezone.now()
        post.save(update_fields=["published_at"])
        return Response({"status": "published"}, status=status.HTTP_200_OK)

Two details above matter disproportionately. Optimising the queryset in get_queryset applies to every action at once. And perform_create is where ownership is assigned — taking the author from request.user instead of the payload closes the hole where a client sets someone else's id.

Python
Routers generate the URL patterns from a ViewSet.
from django.urls import path, include
from rest_framework.routers import DefaultRouter

router = DefaultRouter()
router.register(r"posts", PostViewSet, basename="post")
router.register(r"authors", AuthorViewSet, basename="author")

urlpatterns = [
    path("api/", include(router.urls)),
]

# Generated routes:
#   GET    /api/posts/              list
#   POST   /api/posts/              create
#   GET    /api/posts/{pk}/         retrieve
#   PUT    /api/posts/{pk}/         update
#   PATCH  /api/posts/{pk}/         partial_update
#   DELETE /api/posts/{pk}/         destroy
#   POST   /api/posts/{pk}/publish/ the @action above
ClassProvidesUse when
ModelViewSetAll CRUD actionsStandard resource, conventional routes
ReadOnlyModelViewSetlist + retrievePublic catalogues, reference data
ListCreateAPIViewOne or two methodsA single endpoint, not a resource
APIViewNothingNon-CRUD actions, webhooks, RPC-style calls

The Cross-Cutting Settings Every API Needs

Pagination and throttling are configured globally; permissions are usually a mix of a safe global default and per-view overrides.

Python
Global DRF configuration in settings.py.
REST_FRAMEWORK = {
    # Deny by default; open up per view. Safer than AllowAny.
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework_simplejwt.authentication.JWTAuthentication",
        "rest_framework.authentication.SessionAuthentication",
    ],
    "DEFAULT_PAGINATION_CLASS":
        "rest_framework.pagination.LimitOffsetPagination",
    "PAGE_SIZE": 25,
    "DEFAULT_FILTER_BACKENDS": [
        "django_filters.rest_framework.DjangoFilterBackend",
        "rest_framework.filters.SearchFilter",
        "rest_framework.filters.OrderingFilter",
    ],
    "DEFAULT_THROTTLE_CLASSES": [
        "rest_framework.throttling.ScopedRateThrottle",
    ],
    "DEFAULT_THROTTLE_RATES": {
        "anon": "60/hour",
        "user": "1000/hour",
    },
}
Note: Without a DEFAULT_PAGINATION_CLASS, a list endpoint returns every row. On a table that grows, that single omission is the most common cause of an API falling over in production.

Object-Level Permissions

A permission class can check the request against a specific object, which is how you express "only the owner may edit this".

Python
A reusable owner-or-read-only permission.
from rest_framework import permissions


class IsOwnerOrReadOnly(permissions.BasePermission):
    def has_object_permission(self, request, view, obj):
        if request.method in permissions.SAFE_METHODS:
            return True
        return obj.author.user_id == request.user.id


class PostViewSet(viewsets.ModelViewSet):
    permission_classes = [permissions.IsAuthenticated, IsOwnerOrReadOnly]
Note: has_object_permission only runs for views that call get_object(). It is never consulted on a list endpoint, so list querysets must be filtered in get_queryset to avoid leaking other users' rows.
• List fields explicitly in Meta; never use fields = "__all__".
• Put cross-field rules in validate(), single-field rules in validate_().
• Optimise the queryset once in get_queryset so every action benefits.
• Assign ownership in perform_create from request.user, not the payload.
• Set a default pagination class before the table grows.
• Filter list querysets by owner — has_object_permission does not protect lists.

Summary

Django REST Framework is best understood as three clean layers: serializers own validation and representation, views own HTTP semantics and querysets, and routers own URL structure.

Reach for ModelViewSet when a resource maps onto conventional CRUD, split read and write serializers when responses need to be richer than requests, and apply query optimisation in get_queryset so it covers every action.

The failure modes worth guarding against are consistent across projects: __all__ field lists, missing pagination, and unfiltered list querysets. Address those three and most of the rest is ordinary Django.