The Django MTV Request Cycle & ORM Laziness
Django calls its own architecture MTV: Model, Template, View.
That name causes more confusion than it resolves, because Django's "View" is the controller in the classic MVC sense, and Django itself - the URL resolver and framework internals - plays the role MVC assigns to the "view."
Underneath that naming quirk sits a real architecture: every request passes through a nested stack of middleware, gets routed to a view function or class, and that view typically talks to models whose querysets are lazy by design.
This page is the mental model behind both halves of that: how a request actually moves through Django, and why the ORM behaves the way it does once a view starts querying it.
Django Basics covers project and app setup hands-on; Models & the ORM and Migrations go deeper on the data layer this page introduces conceptually.
Summary
- A Django request flows through a nested middleware stack to a view, which coordinates lazily-evaluated model queries and templates, and the response flows back out through the same middleware in reverse.
- Insight: Most Django performance bugs (N+1 queries) and most middleware bugs (wrong ordering) come directly from misunderstanding these two mechanics, not from Django itself being unpredictable.
- Key Concepts: the MTV pattern, the middleware onion, the URL resolver, QuerySet laziness, migrations as tracked schema state.
- When to Use This Model: Deciding where middleware should sit in
MIDDLEWARE, debugging why a queryset ran more SQL than expected, or explaining whymakemigrationsandmigrateare two separate steps. - Limitations/Trade-offs: The ORM's laziness and the middleware onion both trade explicitness for convenience - they make simple code simple, but they hide exactly when a database round trip or a response mutation actually happens.
- Related Topics: the classic MVC pattern, Django REST Framework's serializer layer, WSGI/ASGI request dispatch, SQL query planning.
Foundations
Django's MTV naming is a source of near-permanent confusion for anyone coming from MVC frameworks: in Django, the Model is the same idea as MVC's model, but Django's Template is MVC's "view" (the rendering layer), and Django's View is MVC's "controller" (the logic that decides what to render).
Django itself - specifically the URL resolver - occupies the role MVC calls the "view" in the sense of coordinating everything, which is why some documentation calls Django's approach an MTV or "MVT" pattern rather than claiming a direct MVC equivalence.
A request's journey starts at the WSGI (or ASGI, in async-capable deployments) entry point, passes through every configured middleware in order, is matched against urls.py patterns to find a view, and the view's return value travels back out through the same middleware stack in reverse.
Middleware in Django is not a flat chain the way Express's is; it is a nested onion: each middleware wraps the next one, so a middleware can run code both before the view executes (as the request goes in) and after it returns (as the response comes back out).
Request -> [SecurityMiddleware -> [SessionMiddleware -> [CommonMiddleware -> View] ] ]
Response <- [SecurityMiddleware <- [SessionMiddleware <- [CommonMiddleware <- View] ] ]That nesting is why order in the MIDDLEWARE list matters in both directions: a middleware near the top of the list runs first on the way in, but last on the way out, wrapping everything beneath it.
Mechanics & Interactions
Once a view starts working with models, the behavior that surprises newcomers most is that a QuerySet does not run any SQL when it is defined.
Article.objects.filter(status="live") builds a query description, not a result; the actual SELECT only fires when something forces evaluation - iterating it, slicing it in a way that isn't a further filter, calling list() on it, checking its truthiness, or accessing len().
This laziness is what makes chained calls like .filter().exclude().order_by() work without three round trips: each call returns a new, still-unevaluated QuerySet, and only the final evaluation point touches the database.
Laziness has a well-known cost, though: accessing a related object's field inside a loop (book.author.name for each book in a queryset) triggers one query per iteration, because the related object is itself fetched lazily the first time it's touched - this is the classic N+1 query problem, and it exists specifically because laziness makes it easy to write code that looks like plain attribute access but silently issues a query.
select_related (for ForeignKey/OneToOne, via a SQL JOIN) and prefetch_related (for ManyToMany/reverse ForeignKey, via a second query plus in-Python joining) exist to collapse that N+1 pattern into one or two queries, but they must be requested explicitly - Django does not infer the access pattern ahead of time.
from django.db import models
class Author(models.Model):
name = models.CharField(max_length=120)
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(Author, on_delete=models.CASCADE)
# One query total, not one query per book:
Book.objects.select_related("author").filter(author__name__startswith="A")Migrations follow a related but distinct model: Django tracks the state of every model as of the last migration, and makemigrations generates a new migration file by diffing your current model code against that tracked state - it does not read the live database schema to decide what changed.
migrate then applies pending migration files against the actual database, in the dependency order recorded inside each migration, which is why migrations are files committed to version control rather than a runtime auto-sync step.
Advanced Considerations & Applications
Django's ORM was designed sync-first, and that assumption still shapes async views: Django added async def view support and ASGI deployment, but ORM query methods largely remain synchronous, so an async view that queries the database needs sync_to_async bridging (or one of the newer async ORM methods added in recent releases) rather than gaining native async I/O for free.
That means "my view is async def" does not automatically mean "my database access is non-blocking" - see Async Views & Channels for where that boundary actually sits.
The admin site is a direct payoff of the model layer's introspection: admin.site.register(Model) works because Django can read a model's fields, relationships, and validators to generate CRUD forms without any admin-specific code, which is also why the admin is not a good fit as a public-facing UI - it exposes exactly the shape of your models, not a curated interface.
Caching interacts with both halves of this model: middleware-level caching (UpdateCacheMiddleware/FetchFromCacheMiddleware) wraps whole responses using the same onion mechanic described above, while queryset-level caching only applies within a single evaluated QuerySet's result cache, not across requests - see Caching & Performance.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
| Django's MTV + lazy ORM | Batteries-included (admin, auth, ORM, forms); consistent conventions across a large codebase | Sync-first ORM even under async views; laziness hides N+1 costs until profiled | Content-heavy or CRUD-heavy apps that benefit from a shared, opinionated structure |
| FastAPI + a standalone async ORM | Native async I/O throughout; explicit, typed request/response boundary | No built-in admin, auth, or template layer - assembled from separate packages | API-first services where async concurrency and schema validation matter most |
| Flask + Flask-SQLAlchemy | Minimal, add only what's needed | Less structure by default; ORM laziness patterns still apply, with less tooling to catch them | Small services or teams wanting fine-grained control over the stack |
Common Misconceptions
- "MTV is just Django's name for MVC." The mapping is not one-to-one: Django's "Template" corresponds to MVC's "view," and Django's "View" corresponds to MVC's "controller," which is the opposite of what the shared word "view" suggests.
- "A QuerySet runs its query as soon as you write it." It runs no SQL until something forces evaluation - iteration,
list(), slicing without further filtering, or a boolean/length check - which is what makes safe method chaining possible in the first place. - "Migrations keep the database in sync automatically."
makemigrationsonly generates migration files by diffing tracked model state; someone (or a deploy step) still has to runmigratefor the database to actually change. - "Middleware order in
MIDDLEWAREbarely matters." Because middleware nests as an onion, order changes both when a piece of logic runs on the way in and when it runs on the way out - security- and session-related middleware in particular depend on strict ordering. - "Async views make Django's database access async." View functions can be
async def, but most ORM query execution is still synchronous under the hood and needs explicit bridging to run safely inside an async view.
FAQs
What does MTV stand for, and how does it differ from MVC?
Model-Template-View. Django's Template plays the role MVC calls "view" (rendering), and Django's View plays the role MVC calls "controller" (request-handling logic) - the shared word "view" refers to different layers in each pattern.
Does a Django QuerySet hit the database when it's created?
No. It builds a lazy query description; the SQL executes only when the QuerySet is evaluated by iteration, slicing (in a non-filtering way), list(), or a truthiness/length check.
Why does chaining `.filter().exclude().order_by()` not run three separate queries?
Each call returns a new, still-unevaluated QuerySet built from the previous one; only the point where something actually forces evaluation triggers a single combined SQL query.
What is the N+1 query problem, concretely, in Django?
Looping over a queryset and accessing a related field (like book.author.name) triggers one additional query per iteration, because the related object is fetched lazily the first time it's accessed - select_related/prefetch_related collapse this into one or two queries.
How do I choose between select_related and prefetch_related?
select_relatedforForeignKey/OneToOne- it uses a SQLJOIN, so it only works for single-valued relationsprefetch_relatedforManyToManyor reverseForeignKey- it runs a second query and joins the results in Python
Does running `makemigrations` change my database?
No. It only generates a migration file by comparing your current model code to Django's tracked model state. migrate is the separate step that actually applies changes to the database.
Why does middleware order in settings.py matter so much?
Because middleware nests like an onion: each entry wraps everything below it, running before-view code in list order and after-view code in reverse order, so moving a middleware changes both when it sees the request and when it sees the response.
How does the Django admin generate forms without admin-specific code?
It introspects each registered model's fields, types, and relationships at runtime to build list views and CRUD forms automatically - which is also why the admin's UI directly mirrors your model shape rather than a curated design.
Are Django's async views fully async, database included?
Not by default. View functions can be declared async def, but most ORM query execution remains synchronous, so database access inside an async view typically needs explicit sync-to-async bridging rather than gaining native non-blocking I/O automatically.
Where does queryset result caching apply?
Only within a single evaluated QuerySet instance - once evaluated, iterating it again reuses the cached result set, but a new QuerySet built from a fresh .filter() call does not share that cache and issues its own query.
Why is it called "MTV" instead of "MVC" if Django is clearly inspired by MVC?
Django's own documentation adopted MTV specifically to avoid implying a direct MVC equivalence, since Django itself (the framework and URL dispatch) fills the role MVC assigns to the "view," which would otherwise be confusing terminology overlap.
Does laziness mean querysets are always safe to pass around and reuse?
They're safe to build and compose, but each new filter/exclude/order_by call returns a distinct QuerySet object; reusing an already-evaluated QuerySet's cached results across different filtering needs will not reflect further chained changes.
Related
- Django Basics - projects, apps, URLs, and views hands-on
- Models & the ORM - fields, relationships, and queryset patterns in depth
- Migrations - how migration files are generated and applied
- Caching & Performance - middleware-level and queryset-level caching
- Async Views & Channels - the async/sync ORM boundary in practice
- Django REST Framework - a serializer layer built atop this same request cycle
Stack versions: This page was written for Django 5.2 on Python 3.14 (maintenance: 3.13).