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.
- 1WSGI/ASGI server receives the raw HTTP request (Gunicorn, uvicorn)
- 2Django's WSGIHandler or ASGIHandler converts it to a HttpRequest object
- 3Request middleware processes the request (authentication, CORS, etc.)
- 4URL dispatcher (URLconf) matches the path to a view function
- 5View middleware runs before the view is called
- 6The view function executes (queries DB, processes data, renders template)
- 7Response middleware processes the outgoing response
- 8WSGI server sends the HTTP response back to the client
# 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 responseThe 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.
# 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.
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 contextThe 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.
# 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 queryModel internals: how fields become columns
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.titleMiddleware: 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.
# 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).
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.
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"