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:
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:
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 dataSerializers also handle nested relationships cleanly. If a Product has many Reviews, we can include them without N+1 queries:
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:
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 productsPOST /products/— Create a new productGET /products/{id}/— Retrieve a specific productPUT /products/{id}/— Update a product entirelyPATCH /products/{id}/— Partially update a productDELETE /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:
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:
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:
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:
# 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:
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:
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:
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:
def get_serializer_class(self):
if self.action == "create":
return ProductCreateSerializer
return ProductSerializerForgetting pagination: A list endpoint without pagination will return thousands of records. Set defaults in settings:
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; useperform_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_relatedandselect_relatedin your ViewSet querysets. - Different serializers for read, write, and list operations prevent over-exposing fields and keep responsibilities clear.
- Use
@actionfor custom endpoints that don't fit standard CRUD patterns.






Comments
Loading comments…