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 attrsserializer = 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() # PATCHwrite_only=Truekeeps a field out of responses;read_only=Truekeeps 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=Truereturns a 400 with a structured error body. Without it, a forgottenif serializer.is_valid()callssave()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 class | You get |
|---|---|
APIView | Explicit get/post methods, no queryset machinery |
GenericAPIView | get_queryset, get_object, get_serializer |
ListCreateAPIView | GET list + POST create, with pagination and filtering |
RetrieveUpdateDestroyAPIView | GET + PUT/PATCH + DELETE on one object |
ReadOnlyModelViewSet | list + retrieve only, wired through a router |
ModelViewSet | Full 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.idcurl -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.Related
Testing Django apps Authentication, users and permissions
Last refreshed 2026-09-18.