KushalPy.
All Posts
Backend13 min readMay 10, 2025

Inside Django: How the Framework Actually Works Under the Hood

Most Django tutorials teach you what to type. This guide teaches you what happens when you type it — the request lifecycle, the ORM internals, middleware, signals, and why Django makes the architectural decisions it does.

DjangoPythonWeb FrameworkBackendArchitecture

Django has a reputation for being 'magic'. You define a model, run migrations, and suddenly have a database table. You write a URL pattern, a view function, and a template, and somehow an HTTP request becomes a rendered HTML page. This magic is not magic — it's carefully designed architecture. Understanding it makes you a significantly better Django developer, because you stop fighting the framework and start working with it.

Django's Philosophy: Batteries Included, Opinionated by Design

Django was created at a newspaper company (the Lawrence Journal-World) by developers who needed to ship web applications fast. Its philosophy is pragmatism over purity. It ships with an ORM, an admin panel, authentication, forms, and templating — everything you need to build a real web application without assembling a stack from scratch. The opinionated conventions it enforces (MTV architecture, specific project structure, explicit app boundaries) exist because consistency at scale saves enormous amounts of time.

ℹ️ Note

Django follows the MTV pattern: Model (data layer), Template (presentation layer), View (business logic layer). This differs from MVC only in naming — 'View' in Django is what 'Controller' is in MVC, and 'Template' is what 'View' is in MVC.

The Request Lifecycle: From Browser to Response

When a user makes an HTTP request to a Django application, the request travels through a carefully ordered pipeline before a response is returned. Understanding this pipeline is fundamental to debugging, performance optimisation, and security.

  1. 1WSGI/ASGI server receives the raw HTTP request (Gunicorn, uvicorn)
  2. 2Django's WSGIHandler or ASGIHandler converts it to a HttpRequest object
  3. 3Request middleware processes the request (authentication, CORS, etc.)
  4. 4URL dispatcher (URLconf) matches the path to a view function
  5. 5View middleware runs before the view is called
  6. 6The view function executes (queries DB, processes data, renders template)
  7. 7Response middleware processes the outgoing response
  8. 8WSGI server sends the HTTP response back to the client
python
# Django's entry point — wsgi.py
import django
from django.core.handlers.wsgi import WSGIHandler

# When a request arrives, it flows through:
# WSGIHandler.__call__(environ, start_response)
#   -> request = WSGIRequest(environ)
#   -> response = self.get_response(request)
#      -> middleware chain (process_request)
#      -> URL resolver: url_resolver.resolve(request.path_info)
#      -> view function called with (request, *args, **kwargs)
#      -> middleware chain (process_response)
#   -> return response

The URL Dispatcher: How Django Routes Requests

Django's URL dispatcher is a two-step process: first it loads the ROOT_URLCONF module (typically yourproject/urls.py), then it walks through the URL patterns in order until it finds a match. This is O(n) in the worst case, but patterns are compiled to regex once at startup and cached, making routing fast in practice.

python
# urls.py — the URL configuration
from django.urls import path, include
from django.contrib import admin

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

# blog/urls.py
from django.urls import path
from . import views

app_name = "blog"  # namespace for reverse URL lookup

urlpatterns = [
    path("", views.PostListView.as_view(), name="list"),
    path("<slug:slug>/", views.PostDetailView.as_view(), name="detail"),
    path("tag/<str:tag>/", views.TagPostsView.as_view(), name="tag"),
]

# In a template or view:
# reverse("blog:detail", kwargs={"slug": "django-internals"})

Views: Function-Based vs Class-Based

Django supports two view styles. Function-Based Views (FBVs) are simple Python functions. Class-Based Views (CBVs) are Python classes that provide built-in mixins for common patterns like list views, detail views, and form handling. Neither is universally superior — FBVs are more explicit and easier to understand; CBVs eliminate boilerplate for standard CRUD operations.

python
from django.shortcuts import render, get_object_or_404
from django.views.generic import ListView, DetailView
from django.contrib.auth.mixins import LoginRequiredMixin
from .models import Post

# --- Function-Based View ---
def post_detail(request, slug):
    post = get_object_or_404(Post, slug=slug, status="published")
    context = {
        "post": post,
        "related": Post.objects.filter(
            tags__in=post.tags.all()
        ).exclude(id=post.id)[:3],
    }
    return render(request, "blog/post_detail.html", context)

# --- Class-Based View (equivalent) ---
class PostDetailView(DetailView):
    model = Post
    template_name = "blog/post_detail.html"
    slug_field = "slug"
    slug_url_kwarg = "slug"
    queryset = Post.objects.filter(status="published")

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["related"] = Post.objects.filter(
            tags__in=self.object.tags.all()
        ).exclude(id=self.object.id)[:3]
        return context

The ORM: Django's Most Powerful and Most Misunderstood Layer

Django's ORM (Object-Relational Mapper) translates Python class definitions into database tables and Python method calls into SQL queries. It's one of the most complete ORMs in any language, but it has subtleties that trip up developers who treat it as a magic box.

QuerySets are lazy

The most important thing to understand about Django's ORM is that QuerySets do not hit the database until they are evaluated. This means you can chain filters, annotations, and orderings without causing multiple database queries. The query is only executed when you iterate, slice, call len(), or convert to a list.

python
# This does NOT hit the database
posts = Post.objects.filter(status="published")
posts = posts.order_by("-created_at")
posts = posts.select_related("author")   # JOIN on author table
posts = posts.prefetch_related("tags")   # separate query for M2M

# Database is hit HERE (evaluation):
for post in posts:                       # iteration
    print(post.title)

# Or here:
count = posts.count()                    # SELECT COUNT(*)
first = posts.first()                    # SELECT ... LIMIT 1
post_list = list(posts)                  # evaluate all

# N+1 problem (WRONG — triggers N queries):
posts = Post.objects.all()
for post in posts:
    print(post.author.name)   # separate query per post!

# Fixed with select_related:
posts = Post.objects.select_related("author").all()
for post in posts:
    print(post.author.name)   # single JOIN query

Model internals: how fields become columns

python
from django.db import models
from django.utils.text import slugify

class Post(models.Model):
    title = models.CharField(max_length=200)
    slug = models.SlugField(unique=True, blank=True)
    content = models.TextField()
    author = models.ForeignKey(
        "auth.User",
        on_delete=models.CASCADE,
        related_name="posts",
    )
    tags = models.ManyToManyField("Tag", blank=True)
    status = models.CharField(
        max_length=10,
        choices=[("draft", "Draft"), ("published", "Published")],
        default="draft",
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

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

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.title)
        super().save(*args, **kwargs)

    def __str__(self):
        return self.title

Middleware: The Interceptor Pipeline

Middleware is a series of hooks that process every request and response that flows through your application. It's where cross-cutting concerns live: authentication, CORS headers, security headers, session handling, and request logging. Django's middleware executes as a stack: on the way in (request), middleware runs top-to-bottom. On the way out (response), it runs bottom-to-top.

python
# Writing custom middleware
import time
import logging

logger = logging.getLogger(__name__)

class RequestTimingMiddleware:
    """Logs the time taken for every request."""

    def __init__(self, get_response):
        self.get_response = get_response
        # One-time setup on server startup

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

        # Code before the view
        response = self.get_response(request)
        # Code after the view

        duration_ms = (time.perf_counter() - start) * 1000
        logger.info(
            "%s %s %.2fms %d",
            request.method,
            request.path,
            duration_ms,
            response.status_code,
        )
        response["X-Request-Duration"] = f"{duration_ms:.2f}ms"
        return response

# settings.py
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "yourapp.middleware.RequestTimingMiddleware",  # add here
    "django.contrib.sessions.middleware.SessionMiddleware",
    # ...
]

Signals: Decoupled Event Handling

Django signals allow decoupled applications to get notified when certain actions occur elsewhere in the framework. The most common use cases are post_save (sending a welcome email after a user registers), pre_delete (cleaning up files when a record is deleted), and m2m_changed (updating caches when many-to-many relationships change).

python
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.contrib.auth.models import User
from .models import UserProfile
from .tasks import send_welcome_email

@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
    """Auto-create a UserProfile when a User is created."""
    if created:
        UserProfile.objects.create(user=instance)
        # Fire background task (Celery)
        send_welcome_email.delay(instance.email)

# Register signals in apps.py — don't use AppConfig.ready():
class UsersConfig(AppConfig):
    name = "users"

    def ready(self):
        import users.signals  # noqa — imports signal handlers

⚠️ Warning

Signals introduce implicit coupling. If you overuse them, your codebase becomes hard to trace — an action in one module causes unexpected side effects elsewhere. Use signals for truly decoupled concerns (like sending emails) and prefer direct function calls for business logic within the same app.

Django Admin: More Than a Debug Tool

Django's admin interface is often dismissed as a quick debug tool, but for internal tools and content management, it's production-ready with customisation. You can override list displays, add custom actions, inline related models, and apply custom filters with relatively little code.

python
from django.contrib import admin
from django.utils.html import format_html
from .models import Post

@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
    list_display = ["title", "author", "status", "status_badge", "created_at"]
    list_filter = ["status", "created_at", "author"]
    search_fields = ["title", "content", "author__username"]
    prepopulated_fields = {"slug": ("title",)}
    raw_id_fields = ["author"]          # better for large user tables
    date_hierarchy = "created_at"
    save_on_top = True

    def status_badge(self, obj):
        colour = "green" if obj.status == "published" else "orange"
        return format_html(
            '<span style="color: {};">{}</span>',
            colour,
            obj.get_status_display(),
        )
    status_badge.short_description = "Status"

    actions = ["publish_posts"]

    def publish_posts(self, request, queryset):
        updated = queryset.update(status="published")
        self.message_user(request, f"{updated} posts published.")
    publish_posts.short_description = "Publish selected posts"