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.