DEV Community

Cover image for How Django Works Internally: The Complete Request Response Cycle
Alanso Mathew
Alanso Mathew

Posted on

How Django Works Internally: The Complete Request Response Cycle

A backend engineer's deep dive into what actually happens inside Django — from the moment a client sends a byte to the moment your server writes one back.


Originally published at:https://alansomathewdev.blogspot.com/2026/03/how-django-works-internally-complete.html

Table of Contents

  1. Introduction
  2. Why This Matters in Production Systems
  3. Core Concepts
  4. Architecture Design
  5. Step-by-Step Implementation
  6. Code Examples
  7. Performance Optimization
  8. Security Best Practices
  9. Common Developer Mistakes
  10. Real Production Use Cases
  11. Conclusion

Introduction

Every time a user clicks a link in a Django-powered application, a chain reaction begins. A packet leaves their browser, travels the internet, reaches a server, is unwrapped by Nginx, passed to Gunicorn, handed to Django, threaded through middleware, matched against URL patterns, handed to a view, which queries a database through the ORM, renders a template, packages a response, and sends it back the same way it came — all in under 100 milliseconds if you're good at your job.

Most Django developers can build views, write models, configure URLs, and ship features. Far fewer can explain — with precision — what happens between the HTTP request arriving and the response leaving. That knowledge gap is exactly where production bugs hide, where performance bottlenecks live, and where security vulnerabilities breed.

This article changes that.

We're going to tear the entire Django request/response cycle open and look at every layer: WSGI and ASGI, the middleware stack, the URL dispatcher, the view layer, the ORM, the template engine, and the response pipeline. We'll look at the actual Django source code, build production-grade examples, and cover the optimization and security techniques that separate good Django developers from great ones.

Whether you're a beginner learning what "MVT architecture" actually means in practice, or a senior engineer who's been shipping Django apps for years and wants a crisp mental model, this is the guide you've been looking for.

Let's go.


Why This Matters in Production Systems

You might be thinking: "I ship features with Django every day. Why do I need to know its internals?"

Here's why.

Performance. The difference between a 50ms response and a 3-second response is almost always found somewhere in the request/response cycle. Is it a middleware doing a synchronous database call? A view fetching 200 related objects without select_related? A template rendering thousands of rows? You can't fix what you can't see.

Debugging. "A 500 error appears randomly in production but never in development." Nine times out of ten, the answer lives in the middleware stack or the ORM query layer. Knowing the cycle tells you exactly where to look.

Security. Django's security features — CSRF protection, session handling, clickjacking prevention, SQL injection protection — are all implemented as middleware or ORM abstractions. Understanding where they live tells you what's protected, what isn't, and what you can accidentally bypass.

Architecture decisions. When should you use middleware vs a decorator on a view? When does an ASGI setup outperform WSGI? When should you use StreamingHttpResponse? These decisions require understanding the mechanics.

Scaling. When you need to scale a Django application to thousands of concurrent requests, you need to know where the bottlenecks are by design — not by accident.

This knowledge is the foundation everything else is built on.


Core Concepts

Before we trace a request end-to-end, we need to establish the vocabulary. These are the building blocks.

WSGI: The Contract Between Server and Framework

WSGI (Web Server Gateway Interface, defined in PEP 3333) is the standard that allows Python web frameworks like Django to talk to web servers like Gunicorn, uWSGI, or Apache mod_wsgi.

The contract is simple:

  • The server calls your application as a callable with two arguments: environ (a dict of CGI-style environment variables) and start_response (a callback)
  • Your application processes the request and returns an iterable of byte strings as the response body

Django's WSGI entry point is in wsgi.py:

# project/wsgi.py
import os
from django.core.wsgi import get_wsgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production')

application = get_wsgi_application()
Enter fullscreen mode Exit fullscreen mode

This application callable is the gatekeeper. Everything that happens inside Django starts here.

ASGI: The Async Evolution

ASGI (Asynchronous Server Gateway Interface) is the spiritual successor to WSGI, introduced in Django 3.0. It extends the contract to support asynchronous code, WebSockets, long-polling, and server-sent events.

# project/asgi.py
import os
from django.core.asgi import get_asgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production')

application = get_asgi_application()
Enter fullscreen mode Exit fullscreen mode

The key difference: under WSGI, a worker thread is blocked for the entire duration of the request. Under ASGI with an async server like Uvicorn or Daphne, a single thread can handle thousands of concurrent connections by yielding control during I/O waits.

The MVT Pattern

Django uses the Model-View-Template (MVT) pattern. It maps to MVC like this:

MVC             Django MVT
─────────────   ──────────────────────────────
Model       →   Model     (models.py)
Controller  →   View      (views.py)
View        →   Template  (templates/*.html)
Enter fullscreen mode Exit fullscreen mode

Despite the naming confusion, the concept is identical. Models define data structure and database interaction. Views contain business logic and orchestration. Templates handle presentation.

The Middleware Onion

Middleware is the most misunderstood concept in Django. The mental model that makes it click is the onion:

Request →  [SecurityMiddleware]
       →  [SessionMiddleware]
       →  [CommonMiddleware]
       →  [CsrfViewMiddleware]
       →  [AuthenticationMiddleware]
       →  [MessageMiddleware]
       →  [XFrameOptionsMiddleware]
              ↓
           [URL Dispatcher]
              ↓
           [View Function]
              ↓
       ←  [XFrameOptionsMiddleware]
       ←  [MessageMiddleware]
       ←  [AuthenticationMiddleware]
       ←  [CsrfViewMiddleware]
       ←  [CommonMiddleware]
       ←  [SessionMiddleware]
       ←  [SecurityMiddleware]
← Response
Enter fullscreen mode Exit fullscreen mode

The request enters from the outside, travels inward through each middleware layer, hits the view at the center, and the response travels back through each middleware in reverse order. Each middleware can:

  • Inspect or modify the request before passing it on
  • Short-circuit the entire stack by returning a response early
  • Inspect or modify the response on the way back out
  • Handle exceptions thrown anywhere inside it

Architecture Design

Here is the complete architecture of a production Django deployment, from client to database and back:

CLIENT (Browser / Mobile / API Consumer)
    │
    │ HTTP/HTTPS Request
    ▼
┌─────────────────────────────────────────┐
│           NGINX (Reverse Proxy)          │
│  - SSL termination                       │
│  - Static file serving                   │
│  - Load balancing                        │
│  - Rate limiting                         │
└──────────────────┬──────────────────────┘
                   │ Proxied Request
                   ▼
┌─────────────────────────────────────────┐
│         GUNICORN (WSGI Server)           │
│  - Worker process management             │
│  - Concurrency model (sync workers)      │
│  - Request queuing                       │
└──────────────────┬──────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────┐
│          DJANGO APPLICATION              │
│                                          │
│  wsgi.py / asgi.py                       │
│       │                                  │
│       ▼                                  │
│  settings.py (loaded once at startup)    │
│       │                                  │
│       ▼                                  │
│  ┌─────────────────────────────────┐     │
│  │      MIDDLEWARE STACK           │     │
│  │  SecurityMiddleware             │     │
│  │  SessionMiddleware              │     │
│  │  CommonMiddleware               │     │
│  │  CsrfViewMiddleware             │     │
│  │  AuthenticationMiddleware       │     │
│  └──────────────┬──────────────────┘     │
│                 │                        │
│                 ▼                        │
│  ┌─────────────────────────────────┐     │
│  │       URL DISPATCHER            │     │
│  │  ROOT_URLCONF → urls.py         │     │
│  │  include() chaining             │     │
│  │  Named URL patterns             │     │
│  └──────────────┬──────────────────┘     │
│                 │                        │
│                 ▼                        │
│  ┌─────────────────────────────────┐     │
│  │       VIEW LAYER                │     │
│  │  FBV or CBV                     │     │
│  │  Calls Services/Selectors       │     │
│  │  Calls ORM                      │     │
│  │  Renders Templates              │     │
│  └──────────────┬──────────────────┘     │
│                 │                        │
│                 ▼                        │
│  ┌────────────────────────────────┐      │
│  │     Django ORM Layer           │      │
│  │  QuerySet  →  SQL              │      │
│  │  Model Managers                │      │
│  └──────────────┬─────────────────┘      │
│                 │                        │
└─────────────────┼────────────────────────┘
                  │ SQL Queries
                  ▼
┌─────────────────────────────────────────┐
│         PostgreSQL / MySQL               │
│         (Primary Database)              │
└─────────────────────────────────────────┘
                  │ + Cache Layer
                  ▼
┌─────────────────────────────────────────┐
│     Redis (Cache + Sessions + Celery)    │
└─────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Every production Django deployment follows this architecture. Let's now trace a single request through every layer of it.


Step-by-Step Implementation

Step 1: The Client Sends a Request

A user types https://myapp.com/api/orders/42/ into their browser. The browser resolves DNS, establishes a TLS connection, and sends:

GET /api/orders/42/ HTTP/1.1
Host: myapp.com
Authorization: Bearer eyJ0eXAiOiJKV1Q...
Accept: application/json
Enter fullscreen mode Exit fullscreen mode

Step 2: Nginx Receives and Proxies the Request

Nginx terminates TLS, inspects the request path, and proxies it to Gunicorn via a Unix socket or TCP port:

# /etc/nginx/sites-available/myapp
upstream django_app {
    server unix:/run/gunicorn/myapp.sock fail_timeout=0;
}

server {
    listen 443 ssl;
    server_name myapp.com;

    # Serve static files directly — never hit Django for these
    location /static/ {
        alias /var/www/myapp/static/;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    location /media/ {
        alias /var/www/myapp/media/;
    }

    # Everything else goes to Django
    location / {
        proxy_pass http://django_app;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
Enter fullscreen mode Exit fullscreen mode

Why Nginx in front of Gunicorn? Nginx is vastly more efficient at handling slow clients, serving static files, and managing connection queues. Gunicorn workers are precious synchronous processes — you don't want them blocked waiting for a slow client to download a response.

Step 3: Gunicorn Passes to Django's WSGI Handler

Gunicorn translates the raw HTTP request into a WSGI environ dict and calls Django's application callable. The WSGIHandler class in django.core.handlers.wsgi takes over.

What WSGIHandler does at startup (not per-request):

  1. Loads settings.py
  2. Instantiates all middleware classes in MIDDLEWARE order
  3. Builds the middleware chain as a series of nested callables

What it does per-request:

  1. Wraps the WSGI environ into an HttpRequest object
  2. Calls the outermost middleware with the request

Step 4: The Request Traverses the Middleware Stack

This is where Django's first real work happens. Each middleware in settings.MIDDLEWARE is called in order:

# config/settings/base.py
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",         # 1st inbound
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware", # 7th inbound
]
Enter fullscreen mode Exit fullscreen mode

Notable things each built-in middleware does:

Middleware Inbound (request) Outbound (response)
SecurityMiddleware Enforces HTTPS redirect, sets HSTS Adds security headers
SessionMiddleware Loads session from cookie/DB Saves modified session data
CommonMiddleware URL normalisation (trailing slashes) Sets Content-Length
CsrfViewMiddleware Validates CSRF token on unsafe methods Sets CSRF cookie
AuthenticationMiddleware Attaches request.user from session
MessageMiddleware Attaches message storage backend Saves messages to storage
XFrameOptionsMiddleware Sets X-Frame-Options header

Step 5: URL Resolution

After the middleware inbound pass, Django's URL resolver takes over. It reads ROOT_URLCONF from settings, loads your root urls.py, and walks the URL patterns until it finds a match for /api/orders/42/:

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

urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/", include("apps.api.urls")),
]

# apps/api/urls.py
from django.urls import path
from apps.orders.views import OrderDetailView

urlpatterns = [
    path("orders/<int:pk>/", OrderDetailView.as_view(), name="order-detail"),
]
Enter fullscreen mode Exit fullscreen mode

Django's URL resolver uses regex internally but the path() function provides a cleaner converter syntax. <int:pk> means: match a sequence of digits, convert to int, and pass as keyword argument pk to the view.

If no URL pattern matches, Django raises Http404. This is caught either by the 404 handler or by middleware's exception handling, and returns a 404 Not Found response.

Step 6: The View Is Called

The URL resolver returns a callable (the view) and calls it with (request, pk=42). The view does the application work:

# apps/orders/views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status

from apps.orders.selectors import get_order_for_user
from apps.orders.serializers import OrderDetailSerializer

class OrderDetailView(APIView):
    def get(self, request, pk: int):
        order = get_order_for_user(user=request.user, order_id=pk)
        serializer = OrderDetailSerializer(order)
        return Response(serializer.data)
Enter fullscreen mode Exit fullscreen mode

The view must return an HttpResponse (or a subclass, like DRF's Response). If it doesn't, Django raises an error.

Step 7: The ORM Queries the Database

The selector calls the ORM, which translates Python into SQL:

# apps/orders/selectors.py
from apps.orders.models import Order
from django.shortcuts import get_object_or_404

def get_order_for_user(user, order_id: int) -> Order:
    return get_object_or_404(
        Order.objects
        .select_related("user", "shipping_address")
        .prefetch_related("items__product"),
        pk=order_id,
        user=user,
    )
Enter fullscreen mode Exit fullscreen mode

Django's ORM translates this into roughly:

SELECT
    orders_order.*,
    auth_user.*,
    orders_address.*
FROM orders_order
INNER JOIN auth_user ON orders_order.user_id = auth_user.id
LEFT JOIN orders_address ON orders_order.shipping_address_id = orders_address.id
WHERE orders_order.id = 42
  AND orders_order.user_id = 7;
Enter fullscreen mode Exit fullscreen mode

Followed by a second query to prefetch order items:

SELECT orders_orderitem.*, catalog_product.*
FROM orders_orderitem
INNER JOIN catalog_product ON orders_orderitem.product_id = catalog_product.id
WHERE orders_orderitem.order_id IN (42);
Enter fullscreen mode Exit fullscreen mode

Two queries. No N+1.

Step 8: The Response Travels Back Through Middleware

The view returns an HttpResponse. Django passes it back through the middleware stack in reverse order. Each middleware can inspect or modify the response before it reaches the client.

SecurityMiddleware adds security headers. SessionMiddleware saves any session data changes. XFrameOptionsMiddleware sets X-Frame-Options: DENY.

Step 9: The WSGI Handler Returns to Gunicorn

WSGIHandler calls start_response with the status code and headers, then returns the response body as an iterable. Gunicorn sends it to Nginx. Nginx sends it to the client.

Total round trip: ~40ms on a well-optimized setup.


Code Examples

Custom Middleware: Request Timing

# common/middleware/timing.py
import time
import logging

logger = logging.getLogger(__name__)

class RequestTimingMiddleware:
    """
    Logs the duration of every request.
    Attach to MIDDLEWARE after SecurityMiddleware.
    """
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        start_time = time.perf_counter()

        response = self.get_response(request)  # Call the rest of the stack

        duration_ms = (time.perf_counter() - start_time) * 1000
        response["X-Request-Duration-Ms"] = str(round(duration_ms, 2))

        logger.info(
            "request_completed",
            extra={
                "method": request.method,
                "path": request.path,
                "status": response.status_code,
                "duration_ms": round(duration_ms, 2),
                "user_id": getattr(request.user, "id", None),
            },
        )
        return response
Enter fullscreen mode Exit fullscreen mode

Custom Middleware: JWT Authentication

# common/middleware/jwt_auth.py
from django.contrib.auth.models import AnonymousUser
from apps.users.services import UserAuthService

class JWTAuthMiddleware:
    """
    Attaches the authenticated user to the request from a Bearer token.
    Works alongside DRF's authentication classes for non-API routes.
    """
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        auth_header = request.META.get("HTTP_AUTHORIZATION", "")

        if auth_header.startswith("Bearer "):
            token = auth_header.split(" ", 1)[1]
            user = UserAuthService.get_user_from_token(token)
            request.user = user if user else AnonymousUser()

        return self.get_response(request)
Enter fullscreen mode Exit fullscreen mode

Custom URL Converter

# common/converters.py
class UUIDConverter:
    """
    Custom URL converter for UUID-based primary keys.
    Usage: path("resources/<uuid:pk>/", view)
    """
    regex = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"

    def to_python(self, value: str):
        import uuid
        return uuid.UUID(value)

    def to_url(self, value) -> str:
        return str(value)

# config/urls.py
from django.urls import register_converter, path
from common.converters import UUIDConverter

register_converter(UUIDConverter, "uuid")

urlpatterns = [
    path("resources/<uuid:pk>/", ResourceDetailView.as_view()),
]
Enter fullscreen mode Exit fullscreen mode

Class-Based View with Full HTTP Method Handling

# apps/orders/views.py
from django.http import HttpRequest, JsonResponse
from django.views import View
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from django.contrib.auth.decorators import login_required

from apps.orders.selectors import get_order_for_user, get_orders_for_user
from apps.orders.services import OrderService
from apps.orders.serializers import OrderSerializer


@method_decorator(login_required, name="dispatch")
class OrderView(View):
    """
    Handles listing and creating orders.
    GET  /api/orders/     → list orders for the authenticated user
    POST /api/orders/     → create a new order
    """

    @method_decorator(cache_page(60 * 5))  # cache 5 minutes
    def get(self, request: HttpRequest) -> JsonResponse:
        orders = get_orders_for_user(user_id=request.user.id)
        data = OrderSerializer(orders, many=True).data
        return JsonResponse({"results": data, "count": len(data)})

    def post(self, request: HttpRequest) -> JsonResponse:
        import json
        body = json.loads(request.body)
        order = OrderService.create_order(user=request.user, **body)
        return JsonResponse(
            {"id": order.id, "status": order.status},
            status=201
        )
Enter fullscreen mode Exit fullscreen mode

The Complete Model Layer

# apps/orders/models.py
from django.db import models
from django.contrib.auth import get_user_model

User = get_user_model()


class Order(models.Model):
    class Status(models.TextChoices):
        PENDING    = "pending",    "Pending"
        PROCESSING = "processing", "Processing"
        SHIPPED    = "shipped",    "Shipped"
        DELIVERED  = "delivered",  "Delivered"
        CANCELLED  = "cancelled",  "Cancelled"

    user             = models.ForeignKey(User, on_delete=models.CASCADE, related_name="orders")
    status           = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING)
    shipping_address = models.ForeignKey("ShippingAddress", on_delete=models.SET_NULL, null=True)
    total            = models.DecimalField(max_digits=10, decimal_places=2)
    created_at       = models.DateTimeField(auto_now_add=True)
    updated_at       = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["-created_at"]
        indexes = [
            models.Index(fields=["user", "status"]),
            models.Index(fields=["-created_at"]),
        ]

    def __str__(self):
        return f"Order #{self.pk}{self.user.email} ({self.status})"


class OrderItem(models.Model):
    order    = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="items")
    product  = models.ForeignKey("catalog.Product", on_delete=models.PROTECT)
    quantity = models.PositiveIntegerField()
    price    = models.DecimalField(max_digits=10, decimal_places=2)

    class Meta:
        constraints = [
            models.CheckConstraint(check=models.Q(quantity__gt=0), name="positive_quantity"),
        ]
Enter fullscreen mode Exit fullscreen mode

Signal-Based Post-Processing

# apps/orders/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from apps.orders.models import Order
from apps.orders.tasks import send_order_confirmation_email

@receiver(post_save, sender=Order)
def order_created_handler(sender, instance: Order, created: bool, **kwargs):
    """
    After an Order is created, dispatch a Celery task to send
    a confirmation email. This runs AFTER the transaction commits,
    keeping the request cycle fast.
    """
    if created:
        # Use transaction.on_commit to ensure this only fires
        # after the DB transaction is actually committed.
        from django.db import transaction
        transaction.on_commit(
            lambda: send_order_confirmation_email.delay(instance.id)
        )
Enter fullscreen mode Exit fullscreen mode

Performance Optimization

1. Eliminate N+1 Queries with select_related and prefetch_related

The N+1 problem is the most common performance killer in Django. It happens when you access a related object inside a loop without preloading it:

# BAD — generates N+1 queries
orders = Order.objects.all()
for order in orders:
    print(order.user.email)   # Each iteration hits the database
    for item in order.items.all():  # Another query per order
        print(item.product.name)    # Another query per item

# GOOD — 3 queries total, regardless of order count
orders = (
    Order.objects
    .select_related("user", "shipping_address")    # JOIN — for ForeignKey/OneToOne
    .prefetch_related("items__product")             # Separate batched query — for M2M/reverse FK
    .only("id", "status", "total", "created_at")   # Defer unneeded columns
)
Enter fullscreen mode Exit fullscreen mode

Use assertNumQueries in tests to enforce your query budget:

# apps/orders/tests/test_selectors.py
from django.test import TestCase
from apps.orders.selectors import get_orders_for_user

class TestOrderSelectors(TestCase):
    def test_order_list_query_count(self):
        # Ensure the selector never generates more than 3 queries,
        # regardless of how many orders or items exist.
        with self.assertNumQueries(3):
            list(get_orders_for_user(user_id=self.user.id))
Enter fullscreen mode Exit fullscreen mode

2. Use Django's Caching Framework

# apps/catalog/selectors.py
from django.core.cache import cache
from django.utils.functional import cached_property

CATEGORY_CACHE_KEY = "active_categories_v2"
CATEGORY_CACHE_TTL = 60 * 60  # 1 hour

def get_active_categories():
    """
    Categories rarely change. Cache aggressively.
    Invalidate the cache key in the admin save signal.
    """
    result = cache.get(CATEGORY_CACHE_KEY)
    if result is None:
        result = list(
            Category.objects
            .filter(is_active=True)
            .values("id", "name", "slug")
            .order_by("name")
        )
        cache.set(CATEGORY_CACHE_KEY, result, timeout=CATEGORY_CACHE_TTL)
    return result


# Per-view caching with cache_page decorator
from django.views.decorators.cache import cache_page

@cache_page(60 * 15)  # Cache for 15 minutes
def product_list(request):
    ...
Enter fullscreen mode Exit fullscreen mode

Settings for Redis-backed caching:

# config/settings/production.py
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": env("REDIS_URL"),
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
        },
        "KEY_PREFIX": "myapp",
        "TIMEOUT": 300,
    }
}

# Use Redis for sessions too — eliminates session DB queries
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"
Enter fullscreen mode Exit fullscreen mode

3. Database Indexing Strategy

Indexes are the single most impactful database optimization:

class Order(models.Model):
    # ...

    class Meta:
        indexes = [
            # Composite index for the most common query pattern:
            # "find all orders for this user with this status"
            models.Index(fields=["user", "status"], name="idx_order_user_status"),

            # Descending index for the default ordering
            models.Index(fields=["-created_at"], name="idx_order_created_at_desc"),
        ]

        # Partial index (PostgreSQL only): only index non-cancelled orders
        # This is a small index that serves the hottest query path
        constraints = [
            # Not an index, but worth noting for data integrity
            models.UniqueConstraint(
                fields=["user", "external_reference"],
                name="unique_user_order_reference"
            )
        ]
Enter fullscreen mode Exit fullscreen mode

For PostgreSQL-specific optimizations, run EXPLAIN ANALYZE on slow queries:

# In Django shell, inspect the SQL and query plan
from django.db import connection
from apps.orders.selectors import get_orders_for_user

qs = get_orders_for_user(user_id=1)

# See the generated SQL
print(qs.query)

# Run EXPLAIN ANALYZE
with connection.cursor() as cursor:
    cursor.execute(f"EXPLAIN ANALYZE {qs.query}")
    for row in cursor.fetchall():
        print(row[0])
Enter fullscreen mode Exit fullscreen mode

4. Gunicorn Configuration for Production

# gunicorn.conf.py
import multiprocessing

# Number of worker processes
# Rule of thumb: (2 × CPU cores) + 1
workers = multiprocessing.cpu_count() * 2 + 1

# Worker class — use sync for CPU-bound, gevent/uvicorn for I/O-bound
worker_class = "sync"

# Worker timeout — kill and restart if a worker doesn't respond
timeout = 30

# Connection queue size
backlog = 2048

# Keep-alive connections
keepalive = 5

# Logging
accesslog = "-"    # stdout
errorlog  = "-"    # stderr
loglevel  = "info"

# Graceful shutdown timeout
graceful_timeout = 30

# Preload application (loads Django once, then forks)
# Reduces memory usage via copy-on-write, but loses graceful reload
preload_app = True
Enter fullscreen mode Exit fullscreen mode

5. Database Connection Pooling

By default, Django creates a new database connection per request and closes it after. This is expensive. Use pgBouncer in production, or configure CONN_MAX_AGE:

# config/settings/production.py
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": env("DB_NAME"),
        "USER": env("DB_USER"),
        "PASSWORD": env("DB_PASSWORD"),
        "HOST": env("DB_HOST"),
        "PORT": env("DB_PORT", default="5432"),

        # Reuse database connections for up to 60 seconds
        # This is the single-line change that eliminates connection overhead
        "CONN_MAX_AGE": 60,

        "OPTIONS": {
            "connect_timeout": 5,
            "options": "-c default_transaction_isolation=read committed",
        },
    }
}
Enter fullscreen mode Exit fullscreen mode

Security Best Practices

1. Understand What Django Protects (and What It Doesn't)

Django's built-in security layer is substantial, but it has boundaries. Know where they are.

Automatically protected by Django:

  • SQL Injection → The ORM parameterises all queries. Never use .raw() with user input unless you also parameterise it.
  • CSRFCsrfViewMiddleware validates the CSRF token on all non-safe HTTP methods (POST, PUT, PATCH, DELETE).
  • XSS → The template engine auto-escapes HTML by default. Using {{ variable }} in templates is safe.
  • ClickjackingXFrameOptionsMiddleware sets X-Frame-Options: DENY.

What you must handle yourself:

  • Broken object-level authorization (BOLA/IDOR) → Always filter querysets by the authenticated user. Never trust a URL parameter without checking ownership.
  • Mass assignment → Always use serializer field whitelisting. Never pass request.data or request.POST directly to a model.
  • Sensitive data in logs → Your RequestTimingMiddleware should never log request bodies or authorization headers.

2. Security Settings Checklist

# config/settings/production.py

# HTTPS enforcement
SECURE_SSL_REDIRECT = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

# HSTS — tells browsers to only ever use HTTPS for your domain
SECURE_HSTS_SECONDS = 31536000         # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True

# Cookies
SESSION_COOKIE_SECURE = True           # Only send session cookie over HTTPS
SESSION_COOKIE_HTTPONLY = True         # JS cannot read session cookie
SESSION_COOKIE_SAMESITE = "Lax"        # CSRF protection for session cookie
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_HTTPONLY = True

# Content security
SECURE_CONTENT_TYPE_NOSNIFF = True     # X-Content-Type-Options: nosniff
X_FRAME_OPTIONS = "DENY"

# Never expose debug info in production
DEBUG = False
ALLOWED_HOSTS = ["myapp.com", "www.myapp.com"]
Enter fullscreen mode Exit fullscreen mode

3. Prevent IDOR (Insecure Direct Object Reference)

# BAD — any authenticated user can access any order by ID
def get_order(request, pk):
    order = get_object_or_404(Order, pk=pk)  # ← IDOR vulnerability
    ...

# GOOD — always scope to the authenticated user
def get_order(request, pk):
    order = get_object_or_404(Order, pk=pk, user=request.user)  # ← safe
    ...

# In selectors, always require user context:
def get_order_for_user(user, order_id: int) -> Order:
    return get_object_or_404(
        Order.objects.filter(user=user),
        pk=order_id
    )
Enter fullscreen mode Exit fullscreen mode

4. Rate Limiting Middleware

# common/middleware/rate_limit.py
import time
from django.core.cache import cache
from django.http import JsonResponse


class RateLimitMiddleware:
    """
    Simple sliding-window rate limiter using Redis.
    1000 requests per IP per hour for anonymous users.
    10000 requests per user per hour for authenticated users.
    """
    ANON_LIMIT  = 1000
    AUTH_LIMIT  = 10000
    WINDOW_SECS = 3600  # 1 hour

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        key, limit = self._get_key_and_limit(request)
        count = cache.get(key, 0)

        if count >= limit:
            return JsonResponse(
                {"error": "Rate limit exceeded. Try again later."},
                status=429,
                headers={"Retry-After": str(self.WINDOW_SECS)},
            )

        # Increment with TTL
        cache.set(key, count + 1, timeout=self.WINDOW_SECS)

        response = self.get_response(request)
        response["X-RateLimit-Remaining"] = str(limit - count - 1)
        return response

    def _get_key_and_limit(self, request):
        window = int(time.time() // self.WINDOW_SECS)
        if request.user.is_authenticated:
            return f"rl:user:{request.user.id}:{window}", self.AUTH_LIMIT
        ip = request.META.get("HTTP_X_FORWARDED_FOR", request.META["REMOTE_ADDR"])
        return f"rl:ip:{ip}:{window}", self.ANON_LIMIT
Enter fullscreen mode Exit fullscreen mode

Common Developer Mistakes

❌ Mistake 1: Querying the Database in Middleware

# BAD — runs a DB query on EVERY request
class TenantMiddleware:
    def __call__(self, request):
        hostname = request.get_host()
        request.tenant = Tenant.objects.get(domain=hostname)  # DB hit every time!
        return self.get_response(request)

# GOOD — cache the tenant lookup
class TenantMiddleware:
    def __call__(self, request):
        hostname = request.get_host()
        cache_key = f"tenant:{hostname}"
        tenant = cache.get(cache_key)
        if tenant is None:
            tenant = Tenant.objects.get(domain=hostname)
            cache.set(cache_key, tenant, timeout=300)
        request.tenant = tenant
        return self.get_response(request)
Enter fullscreen mode Exit fullscreen mode

❌ Mistake 2: Fat Views with Business Logic

# BAD — testing this requires an HTTP client
def create_order(request):
    product = Product.objects.get(id=request.POST["product_id"])
    if product.stock < int(request.POST["quantity"]):
        return JsonResponse({"error": "Out of stock"}, status=400)
    order = Order.objects.create(user=request.user, ...)
    product.stock -= int(request.POST["quantity"])
    product.save()
    send_mail("Order confirmed", ..., [request.user.email])
    return JsonResponse({"id": order.id})

# GOOD — views are thin HTTP adapters; logic is in services
def create_order(request):
    serializer = CreateOrderSerializer(data=request.POST)
    serializer.is_valid(raise_exception=True)
    order = OrderService.create_order(user=request.user, **serializer.validated_data)
    return JsonResponse({"id": order.id}, status=201)
Enter fullscreen mode Exit fullscreen mode

❌ Mistake 3: Forgetting transaction.on_commit

# BAD — Celery task runs BEFORE the DB transaction commits
# If the task runs before the commit, it won't find the Order row
@receiver(post_save, sender=Order)
def on_order_created(sender, instance, created, **kwargs):
    if created:
        send_confirmation_email.delay(instance.id)  # Race condition!

# GOOD — wait for the transaction to commit
@receiver(post_save, sender=Order)
def on_order_created(sender, instance, created, **kwargs):
    if created:
        transaction.on_commit(
            lambda: send_confirmation_email.delay(instance.id)
        )
Enter fullscreen mode Exit fullscreen mode

❌ Mistake 4: Using DEBUG = True Without Understanding the Cost

With DEBUG = True, Django stores the full details of every SQL query in memory for the duration of the process. In production, this is a memory leak that grows until the process is restarted. Always, always, always set DEBUG = False in production.

❌ Mistake 5: Ignoring CONN_MAX_AGE

# BAD — default Django behaviour: new DB connection per request
DATABASES = {"default": env.db("DATABASE_URL")}

# GOOD — reuse connections for up to 60 seconds
DATABASES = {
    "default": {
        **env.db("DATABASE_URL"),
        "CONN_MAX_AGE": 60,
    }
}
Enter fullscreen mode Exit fullscreen mode

Benchmarks show CONN_MAX_AGE=60 reduces p99 latency by 15–30% on high-traffic endpoints by eliminating connection establishment overhead.


Real Production Use Cases

Use Case 1: Multi-Tenant SaaS — Tenant Resolution in Middleware

A SaaS application where each customer has their own subdomain (customer-a.myapp.com, customer-b.myapp.com). The tenant must be resolved and attached to every request:

# common/middleware/tenant.py
from django.http import Http404
from apps.tenants.models import Tenant
from django.core.cache import cache


class TenantResolutionMiddleware:
    """
    Resolves the tenant from the request hostname and attaches it
    to request.tenant. All subsequent middleware and views can
    rely on request.tenant being set.
    """
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        hostname = request.get_host().split(":")[0]  # strip port

        cache_key = f"tenant_by_host:{hostname}"
        tenant = cache.get(cache_key)

        if tenant is None:
            try:
                tenant = Tenant.objects.get(domain=hostname, is_active=True)
                cache.set(cache_key, tenant, timeout=300)
            except Tenant.DoesNotExist:
                raise Http404(f"No active tenant for domain: {hostname}")

        request.tenant = tenant
        return self.get_response(request)
Enter fullscreen mode Exit fullscreen mode

Use Case 2: API Rate Limiting by Plan Tier

A REST API where different subscription plans get different rate limits. This is cleanest as middleware because it runs on every request and doesn't require view-level knowledge:

PLAN_RATE_LIMITS = {
    "free":       100,    # requests per hour
    "starter":    1_000,
    "pro":        10_000,
    "enterprise": 100_000,
}

class PlanRateLimitMiddleware:
    def __call__(self, request):
        if not request.user.is_authenticated:
            return self.get_response(request)

        plan  = request.user.subscription.plan
        limit = PLAN_RATE_LIMITS.get(plan, 100)
        key   = f"rate:{request.user.id}:{int(time.time() // 3600)}"
        count = cache.incr(key, default=0)

        if count == 1:
            cache.expire(key, 3600)

        if count > limit:
            return JsonResponse({"error": "Rate limit exceeded"}, status=429)

        return self.get_response(request)
Enter fullscreen mode Exit fullscreen mode

Use Case 3: ASGI for Real-Time Order Tracking

# project/asgi.py
import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
from apps.orders.routing import websocket_urlpatterns

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")

application = ProtocolTypeRouter({
    "http": get_asgi_application(),
    "websocket": AuthMiddlewareStack(
        URLRouter(websocket_urlpatterns)
    ),
})

# apps/orders/consumers.py
from channels.generic.websocket import AsyncJsonWebsocketConsumer

class OrderStatusConsumer(AsyncJsonWebsocketConsumer):
    async def connect(self):
        order_id = self.scope["url_route"]["kwargs"]["order_id"]
        self.group_name = f"order_{order_id}"
        await self.channel_layer.group_add(self.group_name, self.channel_name)
        await self.accept()

    async def order_status_update(self, event):
        await self.send_json({"status": event["status"]})

    async def disconnect(self, close_code):
        await self.channel_layer.group_discard(self.group_name, self.channel_name)
Enter fullscreen mode Exit fullscreen mode

Conclusion

The Django request/response cycle looks simple from the outside. URL goes in. HTML or JSON comes out. But between those two moments lies a precisely orchestrated sequence of events: WSGI/ASGI handling, settings loading, middleware traversal, URL resolution, view execution, ORM translation, database I/O, and response construction.

Every layer of that pipeline is a leverage point:

  • Middleware gives you global, composable hooks without modifying views
  • The URL dispatcher keeps routing declarative and inspectable
  • The ORM translates Python to SQL — but only if you understand when it hits the database
  • The response pipeline gives you control over every header and byte before the client sees it

The engineers who build fast, secure, maintainable Django applications are not the ones who know the most framework APIs. They're the ones who understand the mechanics well enough to know where things go wrong — and how to fix them before they do.

You now have that understanding. Go build something fast.


Further Reading


Written by a Python backend engineer building production Django systems. Topics covered: Django internals, WSGI, ASGI, middleware, URL dispatcher, ORM, query optimization, production deployment, security.

Top comments (0)