Django Python REST API DRF

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.