The Django MTV Request Cycle & ORM Laziness
Django calls its own architecture MTV: Model, Template, View.
Search across all documentation pages
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.
MIDDLEWARE, debugging why a queryset ran more SQL than expected, or explaining why makemigrations and migrate are two separate steps.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.
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.
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 |
list(), slicing without further filtering, or a boolean/length check - which is what makes safe method chaining possible in the first place.makemigrations only generates migration files by diffing tracked model state; someone (or a deploy step) still has to run migrate for the database to actually change.MIDDLEWARE barely 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 def, but most ORM query execution is still synchronous under the hood and needs explicit bridging to run safely inside an async view.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.
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.
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.
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.
select_related for ForeignKey/OneToOne - it uses a SQL JOIN, so it only works for single-valued relationsprefetch_related for ManyToMany or reverse ForeignKey - it runs a second query and joins the results in PythonNo. 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.
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.
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.
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.
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.
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.
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.
Stack versions: This page was written for Django 5.2 on Python 3.14 (maintenance: 3.13).
Reviewed by Chris St. John·Last updated Jul 15, 2026