Django REST Framework: serializers and viewsets

Turn models into JSON with serializers, expose resources with generic views and routers, and lock the API down with permissions, pagination and authentication.

Serializers

A serializer has two jobs: converting model instances to native Python for the response, and validating incoming data into a validated dictionary you can save. Unlike a Django form it does not own HTML rendering, which is why the same class can back a JSON endpoint and a background import.

# api/serializers.py
from rest_framework import serializers
from blog.models import Category, Post

class CategorySerializer(serializers.ModelSerializer):
    post_count = serializers.IntegerField(read_only=True)   # supplied by annotate()

    class Meta:
        model = Category
        fields = ["id", "name", "slug", "post_count"]

class PostSerializer(serializers.ModelSerializer):
    author = serializers.StringRelatedField(read_only=True)
    category = CategorySerializer(read_only=True)
    category_id = serializers.PrimaryKeyRelatedField(
        queryset=Category.objects.all(), source="category", write_only=True,
        required=False, allow_null=True)
    url = serializers.HyperlinkedIdentityField(view_name="post-detail")

    class Meta:
        model = Post
        fields = ["id", "url", "title", "slug", "body", "status",
                  "author", "category", "category_id", "published_at"]
        read_only_fields = ["published_at"]

    def validate_slug(self, value):
        if value.startswith("draft-"):
            raise serializers.ValidationError("Slugs may not start with 'draft-'.")
        return value

    def validate(self, attrs):
        if attrs.get("status") == "published" and not attrs.get("body"):
            raise serializers.ValidationError({"body": "A published post needs a body."})
        return attrs
serializer = PostSerializer(data=request.data)
serializer.is_valid(raise_exception=True)     # -> 400 with a field->messages dict
post = serializer.save(author=request.user)   # extra kwargs go to create()

PostSerializer(post).data                     # serialize one instance
PostSerializer(qs, many=True).data            # serialize a queryset
PostSerializer(post, data=request.data, partial=True).is_valid()   # PATCH
  • write_only=True keeps a field out of responses; read_only=True keeps it out of validation. Getting the pair wrong is the usual cause of a field that cannot be set, or a field that can be overwritten by any client.
  • validate_<field>() checks one field; validate() sees the whole payload and is where cross-field rules belong.
  • raise_exception=True returns a 400 with a structured error body. Without it, a forgotten if serializer.is_valid() calls save() on unvalidated data.
  • Nest read and write representations separately (as above): nested serializers are convenient to read but ambiguous to write.

APIView, generics and viewsets

from rest_framework import generics, permissions, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response

class PostListCreate(generics.ListCreateAPIView):
    serializer_class = PostSerializer
    permission_classes = [permissions.IsAuthenticatedOrReadOnly]
    pagination_class = None

    def get_queryset(self):
        qs = Post.objects.select_related("author", "category")
        if self.request.user.is_staff:
            return qs
        return qs.filter(status=Post.Status.PUBLISHED)

    def perform_create(self, serializer):
        serializer.save(author=self.request.user)     # never take author from the client

class PostViewSet(viewsets.ModelViewSet):
    serializer_class = PostSerializer
    permission_classes = [permissions.IsAuthenticatedOrReadOnly]

    def get_queryset(self):
        return Post.objects.select_related("author", "category")

    @action(detail=True, methods=["post"], permission_classes=[permissions.IsAdminUser])
    def publish(self, request, pk=None):
        post = self.get_object()
        post.publish()
        return Response(PostSerializer(post).data)

# api/urls.py
from rest_framework.routers import DefaultRouter
router = DefaultRouter()
router.register("posts", PostViewSet, basename="post")
urlpatterns = router.urls
Base classYou get
APIViewExplicit get/post methods, no queryset machinery
GenericAPIViewget_queryset, get_object, get_serializer
ListCreateAPIViewGET list + POST create, with pagination and filtering
RetrieveUpdateDestroyAPIViewGET + PUT/PATCH + DELETE on one object
ReadOnlyModelViewSetlist + retrieve only, wired through a router
ModelViewSetFull CRUD plus @action extras, one line per resource

Permissions, pagination and authentication

# config/settings.py
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework.authentication.SessionAuthentication",
        "rest_framework.authentication.TokenAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 20,
    "DEFAULT_THROTTLE_CLASSES": ["rest_framework.throttling.ScopedRateThrottle"],
    "DEFAULT_THROTTLE_RATES": {"anon": "60/hour", "user": "5000/day"},
    "DEFAULT_RENDERER_CLASSES": ["rest_framework.renderers.JSONRenderer"],
}

# api/permissions.py
from rest_framework.permissions import SAFE_METHODS, BasePermission

class IsAuthorOrReadOnly(BasePermission):
    def has_object_permission(self, request, view, obj):
        if request.method in SAFE_METHODS:
            return True
        return obj.author_id == request.user.id
curl -s http://127.0.0.1:8000/api/posts/?page=2
curl -s -X POST http://127.0.0.1:8000/api/posts/ \
  -H "Authorization: Token $API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title": "Hello", "slug": "hello", "body": "..."}'
⚠️
DRF's own default for DEFAULT_PERMISSION_CLASSES is AllowAny. A viewset written in a hurry — and the router registers every standard route, including DELETE — is then fully public and writable. Set IsAuthenticated as the project default and opt out per view; and remember that list endpoints must filter querysets by owner, because get_object protects detail routes but never the collection.

FAQ

Serializer or plain Django form for an API?
Serializers. They emit structured errors that map to field names, handle nested and related objects, and know about many=True. A form can validate the same input, but you would reimplement the response format and the error shape.
How do I version an API without breaking clients?
Put the version in the URL (/api/v1/) or use Accept headers, freeze the v1 serializers when you start v2, and never change a field's type or remove a key inside one version. Additive fields are safe; renames and removals are not.

Testing Django apps Authentication, users and permissions

Last refreshed 2026-09-18.