Building a maintainable Django REST Framework (DRF) API in 2026 comes down to a handful of decisions you get right early: split your settings by environment, keep serializers thin and validated, optimise every queryset that backs a list endpoint, and lock down throttling, authentication and security before you ship. Get those right and a Django 5.x + DRF 3.16 service will scale comfortably from a prototype to production.
This guide is a concrete, copy-pasteable best-practices reference for Django (5.x) and DRF on Python 3.12+. It assumes you already know the basics (models, views, serializers) and focuses on the choices that separate a fragile API from a fast, secure, testable one.
Best-practices checklist at a glance
| Area | Do this | Avoid |
|---|---|---|
| Settings | Split base/dev/prod, read secrets from env with django-environ |
Editing one settings.py, secrets in git |
| Project layout | One project, many small focused apps | A single monolithic core app |
| Serializers | ModelSerializer + explicit fields, validate in validate_* |
fields = '__all__', business logic in views |
| Queryset perf | select_related / prefetch_related, .only() / .defer() |
N+1 queries, fetching every column |
| Views | ViewSet + router for CRUD, generics for the rest |
Re-implementing CRUD by hand |
| Pagination | Always paginate list endpoints | Returning unbounded lists |
| Throttling | ScopedRateThrottle per sensitive endpoint |
No rate limits at all |
| Auth | JWT or token + object-level permissions | AllowAny left on by accident |
| Versioning | Namespace from day one (/api/v1/) |
Breaking clients on every change |
| Security | DEBUG=False, real ALLOWED_HOSTS, scoped CORS |
DEBUG=True or CORS_ALLOW_ALL in prod |
| Testing | APITestCase for every endpoint |
Manual curl as your only test |
| Observability | Structured logging + Sentry, slow-query alerts | print() debugging in production |
Project structure and environment-based settings
The single most valuable early decision is to stop treating settings.py as one file. Split it into a package so every environment shares a base and overrides only what it needs, and read all secrets and connection strings from environment variables (a .env file locally, real env vars in production) using django-environ. Never commit secrets or hard-code SECRET_KEY.
A layout that scales:
project/
config/
settings/
__init__.py
base.py # shared defaults
dev.py # DEBUG=True, console email, django-debug-toolbar
prod.py # hardened, secrets from env
urls.py
wsgi.py / asgi.py
apps/
accounts/
orders/
api/ # routing + shared DRF config
manage.py
.env # gitignored
.env.example # committed template, no real values
Keep apps small and single-purpose. "One project, many apps" beats one giant core app: an app should own a tight set of related models and the logic around them, which keeps imports clean and makes the code easy to reuse and reason about.
# config/settings/base.py
import environ
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent.parent
env = environ.Env(DEBUG=(bool, False))
environ.Env.read_env(BASE_DIR / ".env") # local only; prod uses real env vars
SECRET_KEY = env("SECRET_KEY")
DEBUG = env("DEBUG")
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=[])
DATABASES = {"default": env.db("DATABASE_URL")}
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"rest_framework",
"corsheaders",
"apps.accounts",
"apps.orders",
]
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 25,
"DEFAULT_THROTTLE_CLASSES": ["rest_framework.throttling.ScopedRateThrottle"],
"DEFAULT_THROTTLE_RATES": {"login": "5/min", "writes": "60/min"},
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.NamespaceVersioning",
}Serializers: validate hard, stay thin
Serializers are the contract for your API, so keep them strict and predictable. Prefer ModelSerializer for straightforward model-backed endpoints and a plain Serializer when the payload does not map cleanly to a model (search filters, RPC-style actions, composite responses).
Five rules that prevent most serializer pain:
- List fields explicitly. Use
fields = [...](orexclude) instead offields = '__all__'so adding a model column never silently leaks data over the API. - Validate in the serializer, not the view. Field-level
validate_<field>()for single-field rules andvalidate()for cross-field rules. Raiseserializers.ValidationErrorand let DRF format the 400 response. - Use
SerializerMethodFieldfor read-only computed values, but remember it runs per object, so back it with a prefetched/annotated queryset to avoid N+1 queries. - Separate read and write serializers when they diverge: nested objects are great for reads but slow and ambiguous for writes. For writes, accept IDs via
PrimaryKeyRelatedFieldand overridecreate()/update()for nested persistence. - Cap nesting depth. Deeply nested writable serializers are a common performance and correctness trap; flatten the write path.
For deeper, scenario-driven examples see Understanding Django Serializers with Examples, Customizing Django REST API Serializers, and Custom Validations for Serializer Fields in DRF.
from rest_framework import serializers
from .models import Order, OrderItem
class OrderItemSerializer(serializers.ModelSerializer):
product_name = serializers.CharField(source="product.name", read_only=True)
class Meta:
model = OrderItem
fields = ["id", "product", "product_name", "quantity"]
class OrderSerializer(serializers.ModelSerializer):
items = OrderItemSerializer(many=True, read_only=True)
total = serializers.SerializerMethodField()
class Meta:
model = Order
fields = ["id", "customer", "status", "items", "total", "created_at"]
read_only_fields = ["status", "created_at"]
def get_total(self, obj):
# `obj.items` must be prefetched, or this is an N+1 query.
return sum(i.quantity * i.product.price for i in obj.items.all())
def validate_customer(self, value):
if not value.is_active:
raise serializers.ValidationError("Customer account is inactive.")
return valueViews and ViewSets: pick the right abstraction
DRF gives you a ladder of abstractions; climb only as high as you need.
ModelViewSet+ a router for standard CRUD on a resource. It eliminates repetitive create/retrieve/update/list/destroy boilerplate and keeps URL wiring consistent (see Understanding Routers in DRF).- Generic class-based views (
ListCreateAPIView,RetrieveUpdateDestroyAPIView) when you want CRUD-ish behaviour on a single endpoint without a full ViewSet. APIViewwhen the endpoint is genuinely custom and the generics fight you.
Whichever you choose, push data shaping into get_queryset() and authorisation into permission classes; keep the view methods themselves short. For a side-by-side of the options, see Generic, Functional and Class-Based Views in DRF.
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from .models import Order
from .serializers import OrderSerializer, OrderWriteSerializer
class OrderViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
throttle_scope = "writes"
def get_queryset(self):
# Scope to the requesting user AND optimise the query in one place.
return (
Order.objects
.filter(customer=self.request.user)
.select_related("customer")
.prefetch_related("items__product")
.order_by("-created_at")
)
def get_serializer_class(self):
if self.action in ("create", "update", "partial_update"):
return OrderWriteSerializer
return OrderSerializer
# config/urls.py
# from rest_framework.routers import DefaultRouter
# router = DefaultRouter()
# router.register(r"orders", OrderViewSet, basename="order")Queryset performance: kill the N+1 before it ships
The most common reason a DRF API is slow is the N+1 query problem: a list endpoint runs one query for the page of objects, then one more query per object to resolve a related field a serializer touches. DRF does not optimise this for you, so it is your job.
select_related(...)for forwardForeignKey/OneToOnerelations, resolved with a SQLJOIN.prefetch_related(...)for reverse FKs andManyToMany, resolved with a second batched query (useitems__productto span two levels)..only(...)/.defer(...)to fetch just the columns the serializer needs on wide tables.annotate(...)/aggregate(...)to compute totals and counts in the database instead of Python loops.- Set
select_related/prefetch_relatedinget_queryset(), not in the serializer, so every action benefits and the optimisation is visible.
Measure, don't guess: drop in django-debug-toolbar in development, and in production watch query counts and durations. For a deep dive see Django Database Access Optimization.
from django.db.models import Count, Sum, F
# BAD: N+1 β one query per order to count items in the serializer/template
for order in Order.objects.all():
print(order.items.count())
# GOOD: two queries total, related rows prefetched
qs = Order.objects.prefetch_related("items__product").select_related("customer")
# BETTER for aggregates: let the database do the maths
qs = (
Order.objects
.annotate(
item_count=Count("items"),
order_total=Sum(F("items__quantity") * F("items__product__price")),
)
.only("id", "status", "created_at") # fetch only what the serializer reads
)Pagination and throttling
Always paginate list endpoints. An unbounded list is a latency and memory bomb the day your table grows. Set a global DEFAULT_PAGINATION_CLASS and PAGE_SIZE; use PageNumberPagination for admin-style UIs and CursorPagination for large, frequently-changing feeds where consistent ordering matters.
Throttle to protect the service and the bill. Configure ScopedRateThrottle and attach a throttle_scope to sensitive endpoints (login, signup, password reset, expensive search, file uploads) so each gets its own limit. Pair AnonRateThrottle and UserRateThrottle for sane global defaults. Rates live in DEFAULT_THROTTLE_RATES and accept second/minute/hour/day suffixes.
from rest_framework.views import APIView
from rest_framework.response import Response
class LoginView(APIView):
throttle_scope = "login" # uses DEFAULT_THROTTLE_RATES['login'] = '5/min'
def post(self, request):
...
return Response({"token": "..."})
# Cursor pagination for a large, fast-moving feed
from rest_framework.pagination import CursorPagination
class FeedPagination(CursorPagination):
page_size = 25
ordering = "-created_at" # must be a stable, indexed fieldAuthentication, permissions and API versioning
Authentication. For SPAs and mobile clients, stateless JWT via djangorestframework-simplejwt is the common default; classic TokenAuthentication still works well for server-to-server calls (see Token-Based Authentication in DRF). Keep access tokens short-lived and rotate refresh tokens.
Permissions. Default to IsAuthenticated globally and open up deliberately. Use object-level permissions (has_object_permission) so a user can only touch their own records, not just any record they can name. DRF's object- and user-level permissions cover the patterns in depth.
Versioning from day one. Pick a strategy (NamespaceVersioning with /api/v1/ URLs is the most operationally clear) before you have external consumers. It lets you ship breaking changes on v2 without stranding v1 clients.
from rest_framework import permissions
class IsOwner(permissions.BasePermission):
"""Object-level check: only the owner may read or write the object."""
def has_object_permission(self, request, view, obj):
return obj.customer_id == request.user.id
# config/urls.py β namespace versioning
# urlpatterns = [
# path("api/v1/", include(("apps.api.urls_v1", "v1"), namespace="v1")),
# path("api/v2/", include(("apps.api.urls_v2", "v2"), namespace="v2")),
# ]
# In a view, request.version == "v1" lets you branch behaviour safely.Error handling, testing and observability
Consistent errors. Let DRF's exception handler turn ValidationError, PermissionDenied and NotFound into structured JSON. For cross-cutting concerns (adding a request id, normalising the error envelope), register a EXCEPTION_HANDLER rather than scattering try/except through views. Never leak stack traces to clients in production.
Test every endpoint with APITestCase. It ships a self.client that speaks DRF, so you can authenticate, hit URLs and assert on status codes and JSON. Cover the happy path, validation failures, permission boundaries (a user must not see another user's data) and pagination. For broader Django testing patterns, see Django Unit Test Cases with Forms and Views.
Observability. Configure Django's LOGGING to emit structured logs to stdout (so your platform aggregates them), wire up error tracking with Sentry, and alert on 5xx rates and slow queries. Logging beats print() every time.
from rest_framework.test import APITestCase
from rest_framework import status
from django.contrib.auth import get_user_model
User = get_user_model()
class OrderAPITests(APITestCase):
def setUp(self):
self.user = User.objects.create_user("alice", password="pw12345!")
self.other = User.objects.create_user("bob", password="pw12345!")
def test_list_requires_auth(self):
resp = self.client.get("/api/v1/orders/")
self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED)
def test_user_sees_only_own_orders(self):
self.client.force_authenticate(self.user)
resp = self.client.get("/api/v1/orders/")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
# paginated response: bob's orders never appear in alice's list
self.assertIn("results", resp.json())Production security hardening
Most Django security incidents trace back to a misconfigured setting, not an exotic exploit. Lock these down before launch:
DEBUG = Falsein production, always. Debug pages expose settings and stack traces.- Set a real
ALLOWED_HOSTSlist; never leave it empty or['*']. - Keep
SECRET_KEYand all credentials in environment variables, never in source control. - Scope CORS with
django-cors-headers: list exact origins viaCORS_ALLOWED_ORIGINS; never shipCORS_ALLOW_ALL_ORIGINS = True. - Enforce HTTPS and secure cookies:
SECURE_SSL_REDIRECT,SESSION_COOKIE_SECURE,CSRF_COOKIE_SECURE, and HSTS viaSECURE_HSTS_SECONDS. - Run
python manage.py check --deployin CI to catch hardening gaps automatically. - Keep dependencies patched and pin them; subscribe to Django security releases.
If you are inheriting an older codebase, our Django modernization work pairs these hardening steps with dependency upgrades.
# config/settings/prod.py
from .base import * # noqa
DEBUG = False
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS") # e.g. ["api.example.com"]
# CORS: explicit origins only
CORS_ALLOWED_ORIGINS = env.list("CORS_ALLOWED_ORIGINS")
# Transport & cookie security
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Validate with: python manage.py check --deployWhen to reach for FastAPI instead
Django + DRF is the right tool when you want a batteries-included framework: an ORM, migrations, admin, auth and a mature serializer/permission stack out of the box. If your service is a thin, high-throughput async layer (ML inference, websockets, lots of I/O-bound fan-out) where you do not need the ORM or admin, FastAPI can be a leaner fit. Many teams run both: Django for the core product, FastAPI for a focused async microservice.
MicroPyramid has shipped Django and DRF systems for 12+ years across 50+ projects, and we apply exactly these patterns when we build and modernise Django applications for startups and enterprises.
Frequently Asked Questions
What are the most important Django REST Framework best practices?
Split settings by environment and read secrets from env vars; keep serializers strict (explicit fields, validate in validate_*); eliminate N+1 queries with select_related/prefetch_related in get_queryset(); always paginate and throttle list endpoints; default to IsAuthenticated with object-level permissions; version your API from day one; and harden production with DEBUG=False, real ALLOWED_HOSTS, scoped CORS and HTTPS.
How do I fix N+1 query problems in DRF?
DRF does not optimise querysets automatically. In your view's get_queryset(), add select_related() for forward ForeignKey/OneToOne fields and prefetch_related() for reverse FK and ManyToMany fields (including nested spans like items__product). Use annotate()/aggregate() to compute totals in SQL instead of Python loops, and confirm the query count with django-debug-toolbar.
Should I use ModelSerializer or Serializer?
Use ModelSerializer when the payload maps directly to a model, it generates fields and basic validation for you. Use a plain Serializer when the input or output does not correspond to a single model, such as search filters, RPC-style actions, or composite responses. In both cases list fields explicitly and never rely on fields = '__all__' in a public API.
When should I use a ViewSet versus a generic view or APIView?
Use a ModelViewSet with a router for standard CRUD on a resource, it removes the most boilerplate. Reach for generic class-based views (e.g. ListCreateAPIView) for a single CRUD-ish endpoint, and drop to APIView only when the endpoint is genuinely custom and the generics get in the way.
How do I version a Django REST Framework API?
Decide on a strategy before you have external consumers. NamespaceVersioning with URL prefixes like /api/v1/ is the most operationally clear: set DEFAULT_VERSIONING_CLASS, namespace your URL includes, and read request.version in views when behaviour must differ. This lets you ship a breaking v2 without forcing existing v1 clients to change.
How do I secure a Django API for production?
Set DEBUG=False, configure a real ALLOWED_HOSTS, keep SECRET_KEY and credentials in environment variables, restrict CORS to explicit origins, enforce HTTPS with secure cookies and HSTS, throttle sensitive endpoints, and run python manage.py check --deploy in CI. Keep dependencies patched and follow Django security releases.