Rohan Yeole - Homepage Rohan Yeole

Django REST API Best Practices for Production Apps in 2026

May 5, 20261 min read

Every Django REST API that ships to production needs five things done correctly: URL versioning, serializer validation, JWT authentication, request throttling, and standardized error responses. Get all five right from the start and you avoid months of painful refactoring later. Get them wrong and you learn why they matter the hard way.

This guide covers each pattern with code you can drop into a real project.

1. URL Versioning

Version your API from day one. Once clients depend on an endpoint, you cannot change its contract without breaking them. Versioning gives you an escape hatch.

The simplest approach is URL path versioning:

# api/urls.py
from django.urls import path, include

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

Use DRF's built-in versioning for more flexibility:

# settings.py
REST_FRAMEWORK = {
    "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
    "DEFAULT_VERSION": "v1",
    "ALLOWED_VERSIONS": ["v1", "v2"],
}

Then access the version in views:

class ProductView(APIView):
    def get(self, request):
        if request.version == "v2":
            serializer = ProductV2Serializer(products, many=True)
        else:
            serializer = ProductV1Serializer(products, many=True)
        return Response(serializer.data)

The key rule: never break a versioned contract. Add fields freely; removing or renaming requires a new version.

2. Serializer Validation

DRF serializers are your first line of defense against bad data. Use them for validation, not just serialization.

from rest_framework import serializers
from .models import Order

class OrderCreateSerializer(serializers.ModelSerializer):
    class Meta:
        model = Order
        fields = ["product_id", "quantity", "shipping_address"]

    def validate_quantity(self, value):
        if value <= 0:
            raise serializers.ValidationError("Quantity must be at least 1.")
        if value > 100:
            raise serializers.ValidationError("Maximum order quantity is 100.")
        return value

    def validate(self, attrs):
        product = attrs.get("product_id")
        quantity = attrs.get("quantity")
        if product.stock < quantity:
            raise serializers.ValidationError(
                {"quantity": "Insufficient stock for this product."}
            )
        return attrs

Use separate serializers for read vs write operations. A UserSerializer for GET responses should not be the same class handling POST input — the writable fields are different from the readable ones.

3. JWT Authentication with SimpleJWT

Token-based authentication with djangorestframework-simplejwt is the standard for Django APIs:

pip install djangorestframework-simplejwt
# settings.py
from datetime import timedelta

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework_simplejwt.authentication.JWTAuthentication",
    ],
}

SIMPLE_JWT = {
    "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60),
    "REFRESH_TOKEN_LIFETIME": timedelta(days=7),
    "ROTATE_REFRESH_TOKENS": True,
    "BLACKLIST_AFTER_ROTATION": True,
    "ALGORITHM": "HS256",
    "AUTH_HEADER_TYPES": ("Bearer",),
}
# urls.py
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView

urlpatterns = [
    path("api/token/", TokenObtainPairView.as_view()),
    path("api/token/refresh/", TokenRefreshView.as_view()),
]

Critical production settings: keep access tokens short-lived (60 minutes max), enable refresh token rotation so each refresh issues a new refresh token, and use the blacklist to invalidate old ones. Never store tokens in localStorage — use HttpOnly cookies for browser clients.

Understanding why token lifetime matters: a JWT access token is a signed claim — the server doesn't store it, so it cannot be revoked individually. If a token is stolen, the attacker can use it until it expires. A 60-minute lifetime means a stolen token is valid for up to 60 minutes. A 7-day lifetime means 7 days of exposure. The refresh token rotation + blacklist pattern closes this window for refresh tokens: each use of a refresh token issues a new one and invalidates the old one, so a stolen refresh token can only be used once before the blacklist catches it on the second use.

4. Request Throttling

Without rate limiting, a buggy client or a malicious actor can exhaust your server. DRF's throttling is simple to enable:

# settings.py
REST_FRAMEWORK = {
    "DEFAULT_THROTTLE_CLASSES": [
        "rest_framework.throttling.AnonRateThrottle",
        "rest_framework.throttling.UserRateThrottle",
    ],
    "DEFAULT_THROTTLE_RATES": {
        "anon": "100/hour",
        "user": "1000/hour",
    },
}

Apply tighter limits to sensitive endpoints:

from rest_framework.throttling import AnonRateThrottle

class LoginRateThrottle(AnonRateThrottle):
    rate = "10/minute"
    scope = "login"

class LoginView(APIView):
    throttle_classes = [LoginRateThrottle]

For production, back throttling with Redis instead of the default database cache:

# settings.py
CACHES = {
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://127.0.0.1:6379/1",
    }
}

5. Standardized Error Responses

DRF's default error format is inconsistent — validation errors look different from authentication errors look different from permission errors. Clients have to handle three different shapes. Fix this with a custom exception handler:

# api/exceptions.py
from rest_framework.views import exception_handler
from rest_framework.response import Response

def custom_exception_handler(exc, context):
    response = exception_handler(exc, context)

    if response is not None:
        error_data = {
            "status": "error",
            "code": response.status_code,
            "errors": response.data,
        }
        response.data = error_data

    return response
# settings.py
REST_FRAMEWORK = {
    "EXCEPTION_HANDLER": "api.exceptions.custom_exception_handler",
}

Now every error response has the same shape:

{
  "status": "error",
  "code": 400,
  "errors": {
    "quantity": ["Quantity must be at least 1."]
  }
}

Consistent error responses let your frontend handle errors with a single utility function instead of case-by-case parsing.

Bonus: Pagination

Never return unbounded querysets. Always paginate:

# settings.py
REST_FRAMEWORK = {
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.CursorPagination",
    "PAGE_SIZE": 25,
}

Use CursorPagination for large datasets — it scales better than offset pagination because it does not require a COUNT(*) query. Use LimitOffsetPagination only when clients need to jump to arbitrary pages.

Putting It Together

These five patterns are the baseline for a production Django REST API. They are not optional extras — they are the foundation that everything else builds on. APIs that skip versioning get rewritten from scratch when requirements change. APIs without proper throttling get taken down by clients. APIs with inconsistent errors get abandoned by frontend developers who cannot parse them.

If you need a Django REST API built correctly from day one — versioned, authenticated, rate-limited, and tested — hire me as a Django developer and let's get started.

Chat with me on WhatsApp