Django Under Hood #01: Django Request/Response lifecycle - What Actually Happens When a Request Hits Your Server

Django Under Hood #01: Django Request/Response lifecycle - What Actually Happens When a Request Hits Your Server

This is the first article in the “Django Under the Hood” series — 10 deep dives into Django’s internals, edge cases, and the mechanics that separate production-grade applications from tutorial code.

What happens in the 47 milliseconds between a request arriving at your server and your view function executing? What code runs? What objects are created? What decisions are made before you even see the request?

Most Django developers never look. The framework “just works.”

Until it doesn’t.

Understanding Django’s internals isn’t academic. It’s the difference between debugging for 10 minutes versus 10 hours. It’s knowing exactly where to hook into the framework. It’s writing middleware that doesn’t accidentally break everything.

Let’s trace a single HTTP request through Django’s internals — from raw bytes on the socket to your view’s return statement.

The Entry Point: WSGI vs ASGI

Your request doesn’t start in Django. It starts in your application server.

WSGI (Gunicorn, uWSGI)

# What Gunicorn calls
from config.wsgi import application

# config/wsgi.py
application = get_wsgi_application()

When a request arrives, Gunicorn calls application(environ, start_response). The environ dict contains everything — headers, path, query string, body — as raw data.

# What environ looks like
{
    'REQUEST_METHOD': 'POST',
    'PATH_INFO': '/api/users/',
    'QUERY_STRING': 'active=true',
    'CONTENT_TYPE': 'application/json',
    'CONTENT_LENGTH': '42',
    'HTTP_AUTHORIZATION': 'Bearer xxx',
    'HTTP_X_REQUEST_ID': 'abc-123',
    'wsgi.input': <socket file>,
    # ... 30+ more keys
}

ASGI (Uvicorn, Daphne)

# Async entry point
from config.asgi import application

# config/asgi.py  
application = get_asgi_application()

ASGI passes a scope dict plus receive and send callables:

# ASGI scope
{
    'type': 'http',
    'asgi': {'version': '3.0'},
    'http_version': '1.1',
    'method': 'POST',
    'path': '/api/users/',
    'query_string': b'active=true',
    'headers': [
        (b'content-type', b'application/json'),
        (b'authorization', b'Bearer xxx'),
    ],
}

The key difference: WSGI is synchronous. One request, one thread, blocked until complete. ASGI is asynchronous. One thread can handle many concurrent requests.

WSGIHandler: Where Django Takes Over

# django/core/handlers/wsgi.py
class WSGIHandler(base.BaseHandler):
    request_class = WSGIRequest

    def __call__(self, environ, start_response):
        # 1. Load middleware (once, on first request)
        set_script_prefix(get_script_name(environ))
        signals.request_started.send(sender=self.__class__, environ=environ)
        
        # 2. Create request object
        request = self.request_class(environ)
        
        # 3. Get response through middleware chain
        response = self.get_response(request)
        
        # ... send response back

Three things happen here:

  1. Signal fires: request_started — your first hook into the request
  2. Request object created: Raw environ becomes HttpRequest
  3. Middleware chain executes: The heart of Django’s request handling

HttpRequest: The Object You Think You Know

request = WSGIRequest(environ)

This single line does more than you’d expect:

# django/http/request.py
class HttpRequest:
    def __init__(self):
        self.GET = QueryDict()      # Not populated yet
        self.POST = QueryDict()     # Not populated yet
        self.COOKIES = {}
        self.META = {}
        self.FILES = MultiValueDict()
        self.path = ''
        self.method = None
        self.content_type = None
        self.content_params = None
        self._stream = None         # Raw body, not read yet
        self._read_started = False

Critical insight: request.body, request.POST, and request.FILES are lazy. They don't read the socket until you access them.

# This doesn't read the body
request = WSGIRequest(environ)

# This reads the entire body into memory
body = request.body

# After this, you can't read again
request._read_started = True

This is why middleware order matters. If middleware A reads request.body, and middleware B tries to read request.POST, you'll get an empty dict — the stream is already consumed.

Django 4.0+ Fix: request.body is now cached. Read it multiple times safely.

# Before Django 4.0: broken
body1 = request.body  # Works
body2 = request.body  # Empty!

# Django 4.0+: fixed
body1 = request.body  # Works
body2 = request.body  # Same content

The Middleware Chain: Not What You Think

You configure middleware as a list:

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'myapp.middleware.CustomMiddleware',
]

But Django doesn’t iterate through this list. It builds a chain of handlers at startup:

# django/core/handlers/base.py
def load_middleware(self, is_async=False):
    handler = self._get_response  # Your view
    
    # Build chain in REVERSE order
    for middleware_path in reversed(settings.MIDDLEWARE):
        middleware = import_string(middleware_path)
        handler = middleware(handler)  # Wraps previous handler
    
    self._middleware_chain = handler

Visualized:

Request → SecurityMiddleware → SessionMiddleware → ... → View
                                                          ↓
Response ← SecurityMiddleware ← SessionMiddleware ← ... ← View

Each middleware wraps the next. When you call get_response(request), you're not calling your view — you're calling the next middleware, which eventually calls your view.

This explains everything about middleware behavior:

  • Why order matters (outer middlewares see request first, response last)
  • Why exceptions propagate outward
  • Why a middleware can short-circuit the entire chain

The Five Middleware Hooks

class MyMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response
        # One-time setup (server start)
    
    def __call__(self, request):
        # 1. Before view (every request)
        self.process_request(request)
        
        response = self.get_response(request)
        
        # 2. After view (every response)
        self.process_response(request, response)
        
        return response
    
    def process_view(self, request, view_func, view_args, view_kwargs):
        # 3. After URL resolution, before view execution
        # Return None to continue, or HttpResponse to short-circuit
        pass
    
    def process_exception(self, request, exception):
        # 4. Only if view raises exception
        # Return None to propagate, or HttpResponse to handle
        pass
    
    def process_template_response(self, request, response):
        # 5. Only if response has render() method (TemplateResponse)
        # Must return a response object
        return response

Hook execution order for a normal request:

1. Middleware A: __call__ (before get_response)
2. Middleware B: __call__ (before get_response)
3. Middleware C: __call__ (before get_response)
4. URL Resolution
5. Middleware A: process_view
6. Middleware B: process_view
7. Middleware C: process_view
8. View executes
9. Middleware C: process_template_response (if applicable)
10. Middleware B: process_template_response (if applicable)
11. Middleware A: process_template_response (if applicable)
12. Middleware C: __call__ (after get_response)
13. Middleware B: __call__ (after get_response)
14. Middleware A: __call__ (after get_response)

Notice: process_view runs in forward order. process_template_response runs in reverse.

URL Resolution: The Router Nobody Calls a Router

# django/urls/resolvers.py
resolver = URLResolver(pattern, urlconf_name)
match = resolver.resolve(request.path_info)

Django’s URL resolver is a tree of URLPattern and URLResolver objects, built at startup from your urlpatterns.

# Your urls.py
urlpatterns = [
    path('api/', include('api.urls')),
    path('admin/', admin.site.urls),
]

# Becomes a tree:
# URLResolver('^api/')
#   └── URLPattern('^users/$', views.user_list)
#   └── URLPattern('^users/(?P<pk>\d+)/$', views.user_detail)
# URLResolver('^admin/')
#   └── ...

Resolution walks this tree depth-first until a pattern matches:

class ResolverMatch:
    def __init__(self, ...):
        self.func = view_function       # The view to call
        self.args = ()                  # Positional arguments
        self.kwargs = {'pk': '42'}      # Keyword arguments
        self.url_name = 'user-detail'   # Reverse URL name
        self.app_names = ['api']        # App namespaces
        self.namespaces = ['api', 'v1'] # Full namespace chain
        self.route = 'api/users/<pk>/'  # Matched route

Performance note: URL resolution is cached per-thread. The first request builds the tree; subsequent requests reuse it.

# django/urls/resolvers.py
@functools.lru_cache(maxsize=None)
def _get_cached_resolver():
    return URLResolver(...)

This is why URL changes require a server restart in production.

View Execution: The Part You Know

Finally, your view:

# django/core/handlers/base.py
def _get_response(self, request):
    # URL already resolved
    callback, callback_args, callback_kwargs = resolver_match
    
    # Apply view middleware
    for middleware in self._view_middleware:
        response = middleware(request, callback, callback_args, callback_kwargs)
        if response:
            return response  # Short-circuit
    
    # Call the view
    response = callback(request, *callback_args, **callback_kwargs)
    
    return response

But wait — what if your view is a class?

class UserView(View):
    def get(self, request, pk):
        ...

Class-based views have a secret: as_view() returns a function.

# django/views/generic/base.py
class View:
    @classmethod
    def as_view(cls, **initkwargs):
        def view(request, *args, **kwargs):
            self = cls(**initkwargs)  # New instance per request
            self.setup(request, *args, **kwargs)
            return self.dispatch(request, *args, **kwargs)
        return view
    
    def dispatch(self, request, *args, **kwargs):
        handler = getattr(self, request.method.lower(), self.http_method_not_allowed)
        return handler(request, *args, **kwargs)

Key insight: A new view instance is created for every request. Class attributes persist; instance attributes don’t.

class BrokenView(View):
    items = []  # SHARED across all requests!
    
    def get(self, request):
        self.items.append(request.user.id)  # Memory leak + race condition

Response: More Than a String

Your view returns HttpResponse. But what happens to it?

# django/http/response.py
class HttpResponse:
    streaming = False
    
    def __init__(self, content=b'', content_type=None, status=200):
        self._headers = {}  # Case-insensitive dict
        self._charset = settings.DEFAULT_CHARSET
        self.cookies = SimpleCookie()
        self._reason_phrase = None
        self.content = content  # Calls setter
    
    @property
    def content(self):
        return b''.join(self._container)
    
    @content.setter
    def content(self, value):
        # Handles str, bytes, iterables
        self._container = [self.make_bytes(value)]

StreamingHttpResponse is different:

class StreamingHttpResponse(HttpResponseBase):
    streaming = True
    
    def __init__(self, streaming_content=(), ...):
        self.streaming_content = streaming_content  # Generator
    
    @property
    def content(self):
        # Forces evaluation - defeats the purpose!
        raise AttributeError("...")

When you return a StreamingHttpResponse, Django never loads the full content into memory. It passes the generator directly to the WSGI server.

But middleware can break this:

class BrokenMiddleware:
    def __call__(self, request):
        response = self.get_response(request)
        
        # This breaks streaming!
        if 'error' in response.content.decode():  # Forces full read
            return HttpResponse('Error occurred')
        
        return response

Exception Handling: The Hidden Safety Net

What if your view raises an exception?

# django/core/handlers/base.py
def _get_response(self, request):
    try:
        response = view(request, *args, **kwargs)
    except Exception as exc:
        response = self.process_exception_by_middleware(exc, request)
        if response is None:
            raise  # No middleware handled it
    return response

Unhandled exceptions trigger Django’s exception handling:

# django/core/handlers/exception.py
def convert_exception_to_response(get_response):
    @wraps(get_response)
    def inner(request):
        try:
            response = get_response(request)
        except Exception as exc:
            response = response_for_exception(request, exc)
        return response
    return inner

Exception → Response mapping:

Press enter or click to view image in full size

The debug page reads your source files at runtime:

# django/views/debug.py
def get_traceback_frames(self):
    for frame in traceback.extract_tb(self.exc_traceback):
        filename = frame.filename
        lineno = frame.lineno
        
        # Actually reads your source file!
        with open(filename, 'rb') as f:
            source = f.read().decode('utf-8')

This is why the debug page shows your code with syntax highlighting — it’s reading it live.

The 47 Milliseconds Breakdown

For a typical request:

Press enter or click to view image in full size

The view and template are where your code runs. Everything else is framework overhead — usually under 10ms total.

Practical Applications

1. Request timing middleware that actually works:

import time

class TimingMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response
    
    def __call__(self, request):
        request._start_time = time.perf_counter()
        response = self.get_response(request)
        duration = time.perf_counter() - request._start_time
        response['X-Request-Duration'] = f'{duration:.4f}s'
        return response

2. Early termination for health checks (skip all middleware):

# Place first in MIDDLEWARE
class HealthCheckMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response
    
    def __call__(self, request):
        if request.path == '/health/':
            return HttpResponse('OK')  # Skip everything
        return self.get_response(request)

3. Request ID propagation:

import uuid

class RequestIDMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response
    
    def __call__(self, request):
        request.id = request.headers.get('X-Request-ID', str(uuid.uuid4()))
        response = self.get_response(request)
        response['X-Request-ID'] = request.id
        return response

What’s Next

This was the request lifecycle—the path from raw bytes to rendered response.

Next in the series: Django’s ORM Query Compiler—what happens between Model.objects.filter() and the SQL that hits your database. The query planner, lazy evaluation, and why some querysets explode in production.

Series: Django Under the Hood

  1. What Actually Happens When a Request Hits Your Server ← You are here
  2. The ORM Query Compiler (coming next)
  3. Connection Management and the Database Wrapper
  4. Signal Dispatch Internals
  5. Template Engine Compilation
  6. Form and Validation Pipeline
  7. Authentication Backend Chain
  8. Static Files and WhiteNoise Internals
  9. Migration System Deep Dive
  10. Test Client and Request Factory Mechanics

SUBSCRIBE FOR NEW ARTICLES

@
comments powered by Disqus