Django REST API Best Practices
Building REST APIs with Django REST Framework is straightforward to start, but production APIs demand much more. These are the patterns I've refined across years of shipping Django APIs for real clients.
1. Structure Your Project for Scale
The most common mistake is dumping everything into a single views.py or
serializers.py. As your API grows, this becomes unmanageable. A better pattern:
myproject/
├── apps/
│ ├── users/
│ │ ├── models.py
│ │ ├── serializers.py
│ │ ├── views.py
│ │ ├── urls.py
│ │ ├── permissions.py
│ │ └── filters.py
│ └── products/
│ ├── models.py
│ ├── serializers.py
│ ├── views.py
│ ├── urls.py
│ └── filters.py
├── config/
│ ├── settings/
│ │ ├── base.py
│ │ ├── development.py
│ │ └── production.py
│ ├── urls.py
│ └── wsgi.py
└── manage.py
Keep each app self-contained. If a feature spans two apps, it usually means you need a third app or need to reconsider your domain boundaries.
2. Use Separate Read and Write Serializers
One serializer trying to handle both reads and writes leads to messy
to_representation overrides and conditional field logic. Split them from the start:
class UserWriteSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, min_length=8)
class Meta:
model = User
fields = ('email', 'password', 'first_name', 'last_name')
def create(self, validated_data):
password = validated_data.pop('password')
user = User(**validated_data)
user.set_password(password)
user.save()
return user
class UserReadSerializer(serializers.ModelSerializer):
full_name = serializers.SerializerMethodField()
class Meta:
model = User
fields = ('id', 'email', 'full_name', 'date_joined')
def get_full_name(self, obj):
return obj.get_full_name()
Use get_serializer_class() in your ViewSet to switch between them:
class UserViewSet(viewsets.ModelViewSet):
def get_serializer_class(self):
if self.action in ('create', 'update', 'partial_update'):
return UserWriteSerializer
return UserReadSerializer
This pattern scales cleanly — you can add fields to the read serializer (computed fields, nested relations) without ever risking them being writable.
3. Keep Views Thin
Serializers handle validation and representation. Models handle business logic. Views should only wire them together. A well-written ViewSet often needs almost no custom code:
class ProductViewSet(viewsets.ModelViewSet):
queryset = (
Product.objects
.select_related('category')
.prefetch_related('tags')
)
permission_classes = [IsAuthenticatedOrReadOnly]
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_class = ProductFilter
search_fields = ['name', 'description']
ordering_fields = ['price', 'created_at']
ordering = ['-created_at']
def get_serializer_class(self):
if self.action in ('create', 'update', 'partial_update'):
return ProductWriteSerializer
return ProductReadSerializer
If your view method is running database queries, transforming data, or calling external services, that logic belongs in a service layer or model method — not in the view.
4. Authenticate with JWT
For stateless APIs, JWT is the standard. Use
djangorestframework-simplejwt — it's actively maintained and integrates
cleanly with DRF:
# settings/base.py
from datetime import timedelta
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
}
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True,
}
# config/urls.py
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
urlpatterns = [
path('api/token/', TokenObtainPairView.as_view()),
path('api/token/refresh/', TokenRefreshView.as_view()),
]
Keep access tokens short-lived (15 minutes) and use refresh tokens for session continuity.
Enable ROTATE_REFRESH_TOKENS so each refresh issues a new token, limiting the
blast radius of a stolen refresh token.
5. Write Precise Permission Classes
Avoid checking request.user.is_staff inside view methods. Encapsulate
all permission logic in reusable, testable classes:
from rest_framework import permissions
class IsOwnerOrReadOnly(permissions.BasePermission):
"""Allow read access to anyone; write access only to the object owner."""
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
return obj.owner == request.user
class IsOwner(permissions.BasePermission):
message = 'You do not have permission to perform this action.'
def has_object_permission(self, request, view, obj):
return obj.owner == request.user
Compose them at the view level:
class CommentViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated, IsOwnerOrReadOnly]
Always call self.check_object_permissions(request, obj) when retrieving objects
manually — DRF only invokes it automatically in the default get_object() flow.
6. Filter, Search, and Order Consistently
Install django-filter and configure the default backends globally so every
ViewSet inherits them without extra boilerplate:
REST_FRAMEWORK = {
'DEFAULT_FILTER_BACKENDS': [
'django_filters.rest_framework.DjangoFilterBackend',
'rest_framework.filters.SearchFilter',
'rest_framework.filters.OrderingFilter',
],
}
Write explicit filter classes rather than handling query params inside views — they're easier to test, document, and reuse:
import django_filters
from .models import Product
class ProductFilter(django_filters.FilterSet):
min_price = django_filters.NumberFilter(field_name='price', lookup_expr='gte')
max_price = django_filters.NumberFilter(field_name='price', lookup_expr='lte')
in_stock = django_filters.BooleanFilter(
field_name='stock', lookup_expr='gt', label='In stock only'
)
class Meta:
model = Product
fields = ['category', 'min_price', 'max_price', 'in_stock']
7. Always Paginate List Endpoints
An unbounded list endpoint will eventually take down your server when the table grows large enough. Set a global default so it is impossible to forget:
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 25,
}
For large datasets where clients may jump to deep pages, switch to cursor pagination — it is O(1) regardless of depth and prevents abuse:
from rest_framework.pagination import CursorPagination
class StandardCursorPagination(CursorPagination):
page_size = 50
ordering = '-created_at'
Cursor pagination also prevents users from requesting ?page=999999 and
forcing a full table scan.
8. Version Your API
Never break existing clients. Add URL path versioning from day one — it costs almost nothing upfront and saves painful migrations later:
REST_FRAMEWORK = {
'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.URLPathVersioning',
'ALLOWED_VERSIONS': ['v1', 'v2'],
'DEFAULT_VERSION': 'v1',
}
# config/urls.py
urlpatterns = [
path('api/v1/', include('apps.api.urls_v1')),
path('api/v2/', include('apps.api.urls_v2')),
]
Access the version in any view via request.version. When you need to change
behaviour in v2, copy the serializer or view and extend it — never mutate a stable version.
9. Return Consistent Error Responses
DRF's default errors are inconsistent across validation failures, auth errors, and 404s. A custom exception handler enforces one standard envelope across every endpoint:
# utils/exceptions.py
from rest_framework.views import exception_handler
def custom_exception_handler(exc, context):
response = exception_handler(exc, context)
if response is not None:
response.data = {
'error': {
'status': response.status_code,
'detail': response.data,
}
}
return response
REST_FRAMEWORK = {
'EXCEPTION_HANDLER': 'utils.exceptions.custom_exception_handler',
}
Now every error has the same shape:
{"error": {"status": 400, "detail": {...}}}.
Frontend developers will thank you.
10. Optimise Database Queries
The N+1 query problem is the most common performance issue in DRF APIs. Use the ORM correctly on your base querysets:
# Bad — triggers N extra queries, one per product's category
queryset = Product.objects.all()
# Good — one JOIN for category, one extra query for tags
queryset = (
Product.objects
.select_related('category')
.prefetch_related('tags')
)
Only fetch the columns your serializer actually uses:
queryset = Product.objects.only('id', 'name', 'price', 'category_id')
Use django-silk or Django Debug Toolbar in development to catch N+1 queries before they reach production. Set a query count assertion in your CI pipeline for critical list endpoints.
11. Test Every Endpoint
Use DRF's APIClient, not Django's standard Client — it handles
content negotiation and authentication headers correctly:
from rest_framework.test import APITestCase
from rest_framework import status
class ProductAPITest(APITestCase):
def setUp(self):
self.user = User.objects.create_user(
email='test@example.com', password='testpass123'
)
self.client.force_authenticate(user=self.user)
def test_list_products(self):
Product.objects.create(name='Widget', price='9.99', owner=self.user)
response = self.client.get('/api/v1/products/')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 1)
def test_create_unauthenticated(self):
self.client.logout()
response = self.client.post('/api/v1/products/', {'name': 'Widget'})
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_cannot_edit_others_product(self):
other = User.objects.create_user(email='other@example.com', password='pass')
product = Product.objects.create(name='Gadget', price='19.99', owner=other)
response = self.client.patch(f'/api/v1/products/{product.id}/', {'name': 'X'})
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
Use factory_boy with Faker to generate realistic test data. Always test the unhappy paths — unauthenticated requests, wrong owners, invalid payloads — not just the success cases.
Wrapping Up
Good Django REST APIs aren't just about making things work — they're about making them maintainable, predictable, and robust under load. The patterns here have been proven across real projects: split your serializers, keep your views thin, paginate everything, handle errors consistently, and test everything.
DRF gives you the tools. These practices give you the discipline to use them well.