If you’re building a backend for a SPA, mobile app, or anything where the frontend is decoupled, Django REST Framework (DRF) is still the most productive way to ship a Python API in 2026. It’s batteries-included, thoroughly battle-tested, and pairs beautifully with the Django you already know.
This post is an end-to-end, hands-on tutorial. We’ll build a small “blog API” with posts and comments — covering models, serializers, viewsets, authentication, permissions, filtering, and pagination. By the end you’ll have the mental model to build any DRF API, not just this one.
Why DRF (and when not to use it)
Pick DRF when:
- You’re already using Django (admin, ORM, auth, etc.) and want an API on top.
- You want a CRUD-style API with sensible defaults and minimal boilerplate.
- You need browsable, self-documenting endpoints during development.
Pick something else when:
- You’re building a pure async-first API and Django feels heavy → consider FastAPI .
- You don’t have a DB or full-stack needs at all → again, FastAPI or even Starlette.
For most Django shops, DRF is the right answer. Let’s build.
Project setup
Assuming you already have a Django project (if not, see Django Project Setup ):
pip install djangorestframework djangorestframework-simplejwt django-filter
Add to settings.py:
INSTALLED_APPS = [
# ...
"rest_framework",
"rest_framework_simplejwt",
"django_filters",
"blog",
]
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
"rest_framework.authentication.SessionAuthentication", # for the browsable API
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticatedOrReadOnly",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 20,
"DEFAULT_FILTER_BACKENDS": [
"django_filters.rest_framework.DjangoFilterBackend",
"rest_framework.filters.SearchFilter",
"rest_framework.filters.OrderingFilter",
],
}
That config alone gives you sane defaults for auth, permissions, pagination, and filtering across every endpoint.
The models
# blog/models.py
from django.conf import settings
from django.db import models
class Post(models.Model):
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="posts")
title = models.CharField(max_length=200)
body = models.TextField()
published = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
indexes = [models.Index(fields=["-created_at"])]
def __str__(self):
return self.title
class Comment(models.Model):
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="comments")
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
body = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["created_at"]
Run migrations:
python manage.py makemigrations
python manage.py migrate
Serializers
A serializer is the bridge between Python objects and JSON. It handles both directions: serializing models to JSON for responses, and validating + deserializing JSON back into models for requests.
# blog/serializers.py
from rest_framework import serializers
from .models import Post, Comment
class CommentSerializer(serializers.ModelSerializer):
author = serializers.ReadOnlyField(source="author.username")
class Meta:
model = Comment
fields = ["id", "post", "author", "body", "created_at"]
read_only_fields = ["id", "created_at", "author"]
class PostSerializer(serializers.ModelSerializer):
author = serializers.ReadOnlyField(source="author.username")
comment_count = serializers.IntegerField(source="comments.count", read_only=True)
class Meta:
model = Post
fields = [
"id", "author", "title", "body",
"published", "created_at", "updated_at",
"comment_count",
]
read_only_fields = ["id", "created_at", "updated_at", "author"]
Notice:
authoris read-only — we’ll set it from the authenticated user, not from request data (otherwise users could pretend to be other users).comment_countis computed.read_only_fieldsprevents the client from setting fields we control.
Viewsets and routers
A viewset is a class that ties a serializer to a queryset and exposes CRUD endpoints. A router turns viewsets into URL patterns.
# blog/views.py
from rest_framework import viewsets, permissions
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter, OrderingFilter
from .models import Post, Comment
from .serializers import PostSerializer, CommentSerializer
from .permissions import IsAuthorOrReadOnly
class PostViewSet(viewsets.ModelViewSet):
queryset = Post.objects.select_related("author").prefetch_related("comments")
serializer_class = PostSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsAuthorOrReadOnly]
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_fields = ["published", "author"]
search_fields = ["title", "body"]
ordering_fields = ["created_at", "updated_at"]
def perform_create(self, serializer):
serializer.save(author=self.request.user)
class CommentViewSet(viewsets.ModelViewSet):
queryset = Comment.objects.select_related("author", "post")
serializer_class = CommentSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsAuthorOrReadOnly]
filterset_fields = ["post"]
def perform_create(self, serializer):
serializer.save(author=self.request.user)
ModelViewSet gives you list, retrieve, create, update, partial_update, and destroy for free. The select_related/prefetch_related matters — without it you’d have N+1 problems on every list call (see Django ORM Deep Dive
).
A custom permission
# blog/permissions.py
from rest_framework import permissions
class IsAuthorOrReadOnly(permissions.BasePermission):
"""Read access for everyone; write only for the object's author."""
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
return obj.author == request.user
DRF runs has_permission on the request and has_object_permission on the specific object — so list views are filtered by the first, detail/update/delete by the second.
URLs
# blog/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from .views import PostViewSet, CommentViewSet
router = DefaultRouter()
router.register(r"posts", PostViewSet)
router.register(r"comments", CommentViewSet)
urlpatterns = [
path("api/", include(router.urls)),
path("api/auth/login/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
path("api/auth/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
path("api-auth/", include("rest_framework.urls")), # browsable API login
]
Mount it in your project’s root urls.py:
# conquered/urls.py
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("blog.urls")),
]
You now have:
GET /api/posts/— list (paginated, filterable, searchable)POST /api/posts/— create (auth required)GET /api/posts/<id>/— detailPUT /api/posts/<id>/— update (author only)PATCH /api/posts/<id>/— partial update (author only)DELETE /api/posts/<id>/— destroy (author only)- Same for
/api/comments/ POST /api/auth/login/— get a JWTPOST /api/auth/refresh/— refresh a JWT
That’s a complete CRUD API in roughly 80 lines of code.
Trying it out
Run the server and hit the JWT endpoint:
curl -X POST http://localhost:8000/api/auth/login/ \
-H "Content-Type: application/json" \
-d '{"username":"alzy","password":"secret"}'
# → {"access": "eyJ...", "refresh": "eyJ..."}
Then create a post:
curl -X POST http://localhost:8000/api/posts/ \
-H "Authorization: Bearer <ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"title":"Hello DRF","body":"My first post","published":true}'
List posts with filtering:
curl "http://localhost:8000/api/posts/?published=true&search=hello&ordering=-created_at"
DRF also gives you a browsable API — visit /api/posts/ in a browser and you get an interactive UI. Great for development; turn it off in production with DEFAULT_RENDERER_CLASSES.
Pagination
The default PageNumberPagination returns:
{
"count": 142,
"next": "http://localhost:8000/api/posts/?page=3",
"previous": "http://localhost:8000/api/posts/?page=1",
"results": [ /* ... */ ]
}
For very large datasets, prefer CursorPagination — it’s stable across inserts and faster on huge tables:
from rest_framework.pagination import CursorPagination
class PostCursorPagination(CursorPagination):
page_size = 20
ordering = "-created_at"
class PostViewSet(viewsets.ModelViewSet):
pagination_class = PostCursorPagination
# ...
Throttling
Rate limit anonymous and authenticated users separately:
REST_FRAMEWORK = {
# ...
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"anon": "30/min",
"user": "300/min",
},
}
Throttling stores counters in the cache, so configure a real cache (Redis, Memcached) in production.
Validation
Add validators directly on the serializer:
class PostSerializer(serializers.ModelSerializer):
# ...
def validate_title(self, value):
if len(value) < 3:
raise serializers.ValidationError("Title must be at least 3 characters")
return value
def validate(self, attrs):
# cross-field validation
if attrs.get("published") and not attrs.get("body"):
raise serializers.ValidationError("Cannot publish without a body")
return attrs
DRF’s error responses are already RFC-friendly; you get clean per-field errors automatically.
Production checklist
- Disable browsable API renderer in production.
- Set
DEBUG = Falseand configureALLOWED_HOSTS. - Use HTTPS-only JWTs (no localStorage if you can avoid it — prefer httpOnly cookies).
- Add CORS headers if your frontend is on a different origin (
django-cors-headers). - Set short access-token lifetimes (5–15 min) and longer refresh-token lifetimes (7–30 days).
- Add throttling globally and per-view as needed.
- Add monitoring (Sentry for errors, structured logging).
- Add OpenAPI schema generation with drf-spectacular for documentation.
Conclusion
DRF is the kind of library that pays you back over time. The defaults are sensible. The extension points are well-designed. The browsable API saves countless Postman tabs. And once you’ve internalized the model–serializer–viewset triangle, you can build any CRUD API in an afternoon.
If you’re choosing between DRF and FastAPI for a new project, see Django vs FastAPI: Which One Should You Pick in 2026? .
Happy API-building!
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .