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:

  • author is read-only — we’ll set it from the authenticated user, not from request data (otherwise users could pretend to be other users).
  • comment_count is computed.
  • read_only_fields prevents 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>/ — detail
  • PUT /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 JWT
  • POST /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 = False and configure ALLOWED_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 .