Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 032 / 100

Django REST Framework tutorial: best practices explained

Build a REST API on top of Django models.

Expona Admin7 min read

Introduction

Building a production REST API demands more than just stringing together Django views and JSON responses. Django REST Framework tutorial best practices explained shows us that the framework provides powerful abstractions—serializers, viewsets, and routers—that transform model-to-API workflows from tedious boilerplate into clean, maintainable code. Whether you're building an e-commerce backend, a CRM system, or a mobile app API, understanding how to combine these pieces correctly will save your team weeks of refactoring.

Over the next 100 days, we're diving deep into Python backend development with real production experience. Today on Day 32, we focus on the architectural patterns that senior engineers use to scale Django REST APIs without accumulating technical debt.

Serializers: The Bridge Between Models and JSON

A serializer is DRF's answer to the fundamental problem: how do we convert a Django model instance into JSON for HTTP responses, and how do we validate and deserialize incoming JSON into model instances? Think of it as a schema layer that sits between your database and your HTTP boundary.

When we worked on a ride-hailing platform, we realized early on that letting views handle serialization logic directly led to inconsistencies. Different endpoints would format the same model differently, validation logic would duplicate across views, and testing became nightmarish. Serializers solve this by centralizing that logic.

The simplest and most powerful approach is using ModelSerializer:

python
from rest_framework import serializers
from .models import Product

class ProductSerializer(serializers.ModelSerializer):
    class Meta:
        model = Product
        fields = ["id", "name", "price", "created_at"]
        read_only_fields = ["id", "created_at"]

This single class definition gives us automatic field mapping, type coercion, and validation. When a client POSTs JSON, DRF validates it against the serializer's rules. If validation fails, the framework returns a 400 response with field-level error messages—no extra work needed.

You can add custom validation for business logic:

python
class ProductSerializer(serializers.ModelSerializer):
    class Meta:
        model = Product
        fields = ["id", "name", "price"]

    def validate_price(self, value):
        if value <= 0:
            raise serializers.ValidationError("Price must be positive.")
        return value

    def validate(self, data):
        # Cross-field validation
        if data.get("name") and len(data["name"]) < 3:
            raise serializers.ValidationError(
                {"name": "Product name must be at least 3 characters."}
            )
        return data

Serializers also handle nested relationships cleanly. If a Product has many Reviews, we can include them without N+1 queries:

python
class ReviewSerializer(serializers.ModelSerializer):
    class Meta:
        model = Review
        fields = ["id", "rating", "comment"]

class ProductDetailSerializer(serializers.ModelSerializer):
    reviews = ReviewSerializer(many=True, read_only=True)
    
    class Meta:
        model = Product
        fields = ["id", "name", "price", "reviews"]

The key insight: serializers should be your single source of truth for what clients see and what they can send. When business rules change—like adding a discount field or making price read-only in certain contexts—you modify the serializer, not multiple views.

ViewSets: CRUD Endpoints in Minimal Code

A ViewSet combines the logic for list, create, retrieve, update, and destroy actions into one class. Instead of writing five separate view classes, we write one ViewSet that DRF expands into five endpoint handlers.

On a CRM project, we needed endpoints for creating, reading, updating, and deleting contacts. The traditional Django class-based view approach would have required three or four separate classes. With ViewSets:

python
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from .models import Product
from .serializers import ProductSerializer

class ProductViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer
    permission_classes = [IsAuthenticated]

This single class automatically provides:

  • GET /products/ — List all products
  • POST /products/ — Create a new product
  • GET /products/{id}/ — Retrieve a specific product
  • PUT /products/{id}/ — Update a product entirely
  • PATCH /products/{id}/ — Partially update a product
  • DELETE /products/{id}/ — Delete a product

We can customize behavior by overriding methods. For instance, if we need to filter products by the logged-in user:

python
class ProductViewSet(viewsets.ModelViewSet):
    serializer_class = ProductSerializer
    permission_classes = [IsAuthenticated]

    def get_queryset(self):
        return Product.objects.filter(owner=self.request.user)

    def perform_create(self, serializer):
        serializer.save(owner=self.request.user)

For endpoints that don't fit the standard CRUD pattern, we use the @action decorator:

python
from rest_framework.decorators import action
from rest_framework.response import Response

class ProductViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer

    @action(detail=True, methods=["post"])
    def mark_available(self, request, pk=None):
        product = self.get_object()
        product.available = True
        product.save()
        return Response({"status": "product marked available"})

This creates a POST /products/{id}/mark_available/ endpoint with minimal code.

Routers: Auto-Generate URLs from ViewSets

Manually writing URL patterns for every ViewSet is error-prone. DRF's DefaultRouter generates them automatically by inspecting your ViewSets:

python
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import ProductViewSet

router = DefaultRouter()
router.register(r"products", ProductViewSet)

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

That's it. The router examines ProductViewSet, discovers it's a ModelViewSet, and registers all six CRUD endpoints plus any custom actions decorated with @action.

For complex projects with many ViewSets, we typically nest routers in our URL configuration:

python
# urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import ProductViewSet, ReviewViewSet

router = DefaultRouter()
router.register(r"products", ProductViewSet, basename="product")
router.register(r"reviews", ReviewViewSet, basename="review")

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

The router also generates a root endpoint at GET /api/v1/ listing all registered resources—useful for API discovery during development.

Browsable API: Built-In Development UI

One of DRF's killer features is the browsable API. When you visit any DRF endpoint in a browser, you get an interactive HTML interface instead of raw JSON. You can test GET requests, fill out forms for POST/PUT/PATCH, and inspect responses without touching curl or Postman.

In a travel booking platform where our team had frontend and backend working in parallel, the browsable API let frontend developers explore endpoints and understand response shapes immediately. It reduced Slack questions about "what does the API return?" significantly.

The browsable API is enabled by default when you include DRF's URLs:

python
urlpatterns = [
    path("api-auth/", include("rest_framework.urls")),
]

When developing locally, you can also use authentication-friendly headers. The browsable API respects session cookies, so if you're logged in, authenticated endpoints work without extra configuration.

In production, you typically disable the browsable API for non-debug environments:

python
REST_FRAMEWORK = {
    "DEFAULT_RENDERER_CLASSES": (
        "rest_framework.renderers.JSONRenderer",
    ) if not DEBUG else (
        "rest_framework.renderers.BrowsableAPIRenderer",
        "rest_framework.renderers.JSONRenderer",
    ),
}

Common Mistakes

Serializer bloat: We often see teams put business logic into serializer methods. Serializers should validate and transform data; they shouldn't orchestrate complex workflows. Move business logic to service classes or the model's manager methods.

N+1 queries: When using nested serializers with many=True, you can easily trigger N+1 queries. Always use prefetch_related or select_related in your ViewSet's queryset:

python
def get_queryset(self):
    return Product.objects.prefetch_related("reviews")

Mixing read and write serializers: Different scenarios often need different serializer formats. Use get_serializer_class() to return different serializers for list, detail, and create actions:

python
def get_serializer_class(self):
    if self.action == "create":
        return ProductCreateSerializer
    return ProductSerializer

Forgetting pagination: A list endpoint without pagination will return thousands of records. Set defaults in settings:

python
REST_FRAMEWORK = {
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 20,
}

Overpermissive ViewSets: Not every endpoint needs to be a ModelViewSet with full CRUD. If you only need read access, use viewsets.ReadOnlyModelViewSet. If you need custom logic, inherit from viewsets.ViewSet and implement only what you need.

Key Takeaways

  • Serializers centralize validation and transformation logic, making your API consistent and testable.
  • ModelSerializer eliminates repetitive field declarations and automatically handles type coercion.
  • ViewSets reduce CRUD endpoints from hundreds of lines to a single class.
  • Custom validation in serializers catches data issues at the API boundary before they reach your models.
  • DefaultRouter generates clean URLs automatically; resist the urge to write URL patterns manually.
  • Use get_queryset() to apply user-specific filtering; use perform_create() to set owner fields automatically.
  • The browsable API is invaluable during development but should be disabled in production.
  • Avoid N+1 queries by using prefetch_related and select_related in your ViewSet querysets.
  • Different serializers for read, write, and list operations prevent over-exposing fields and keep responsibilities clear.
  • Use @action for custom endpoints that don't fit standard CRUD patterns.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles