# Django From Zero

> Learn the batteries-included Python web framework: the project/app structure and MTV pattern, URLs and views, the ORM and migrations, the famous auto-admin, templates, forms and CSRF, the ORM in depth (and the N+1 trap), the built-in auth system, class-based views and Django REST Framework, testing, and production. The framework that ships with everything, explained.


---

# Django From Zero

Django's pitch is "the web framework for perfectionists with deadlines," and it earns it by shipping with
*everything*: an ORM, a database migration system, a templating engine, a forms library, a full
authentication system, and - its most famous trick - an automatic admin interface generated from your
data models. Where [FastAPI](/guides/fastapi-from-zero) hands you a sharp tool for APIs and lets you
assemble the rest, Django hands you a whole workshop with strong opinions about where each tool goes.
That "batteries-included, conventions everywhere" philosophy is the thing to understand first - once you
think in Django's structure, the framework stops feeling enormous and starts feeling guided.

We build the mental model first the whole way: the project/app layout, the **MTV** request flow (Model →
View → Template), the ORM that turns classes into tables, and the conventions that make the admin and
auth "just appear." By the end you'll build a real, database-backed, authenticated web app and understand
the structure holding it together.

> 📝 This teaches the **framework**. It assumes you know **Python** - classes, functions, decorators
> ([Python From Zero](/guides/python-from-zero)). Helpful background:
> [What a Database Is](/guides/what-a-database-is) and [What a Framework Even Is](/guides/what-a-framework-even-is).
> Django code needs a project + database to run, so examples here are shown with the commands to run them
> yourself rather than executed on the page.

## How to read this

Read in order - it builds one app (a small blog with posts and comments) and adds a Django subsystem per
phase. Phases carry difficulty badges.

## The phases

**Part 1 - The Django way (🟢 Basic)**
1. **[What Django Is & Your First Project](01-what-django-is.md)** 🟢 - batteries-included, the MTV pattern, projects vs apps, `manage.py`.
2. **[URLs & Views](02-urls-and-views.md)** 🟢 - the URLconf, views, and how a request becomes a response.
3. **[Models & the ORM](03-models-and-the-orm.md)** 🟢 - defining models, migrations, and querying without SQL.

**Part 2 - Batteries included (🟡 Intermediate)**
4. **[The Django Admin](04-the-django-admin.md)** 🟡 - the auto-generated back-office that's Django's killer feature.
5. **[Templates & the MTV Pattern](05-templates-and-mtv.md)** 🟡 - the template language, context, and inheritance.
6. **[Forms & Validation](06-forms-and-validation.md)** 🟡 - `Form`/`ModelForm`, validation, and CSRF protection.
7. **[The ORM, Deeper](07-the-orm-deeper.md)** 🔴 - lazy QuerySets, relationships, the N+1 trap, and aggregation.
8. **[Users, Auth & Sessions](08-users-auth-and-sessions.md)** 🔴 - the built-in auth system, login, permissions, and sessions.

**Part 3 - APIs, testing & production (🟡 → 🟢)**
9. **[Class-Based Views & Django REST Framework](09-class-based-views-and-drf.md)** 🟡 - CBVs, generic views, and building APIs with DRF.
10. **[Testing & Project Structure](10-testing-and-project-structure.md)** 🟡 - Django's test framework, app organization, and settings.
11. **[Production & Where to Go Next](11-where-to-go-next.md)** 🟢 - deployment, static files, the security checklist, and what to build.

> Django and FastAPI aren't rivals so much as different bets: Django for full web apps with an admin and
> server-rendered pages; FastAPI for lean, async APIs. Knowing both means picking the right one on purpose.


---

# What Django Is & Your First Project

You know [Python](/guides/python-from-zero). Now you want to build a *website* - not a bare JSON API,
but a real site with pages, a database behind them, user logins, and an admin screen to manage your
data. You could wire all of that together from small libraries, or reach for the framework that already
ships every one of those pieces in a single box: **Django**.

Django's whole personality comes from one bet: **most web apps need the same things, so the framework
should provide them and ask you to follow its conventions.** Database access, schema changes, an admin
panel, login, forms, HTML rendering - Django hands you all of it and a sensible way to arrange it. You
write less plumbing and more of *your* app. The price is learning Django's way of doing things - for a
full website, usually a bargain.

## Batteries included - what Django gives you for free

📝 **Django** - a high-level Python web framework that ships with, out of the box: an **ORM** (talk to
the database in Python instead of SQL), **migrations** (version-control your database schema), an
**admin** site (an auto-generated UI to manage your data), **auth** (users, passwords, permissions),
a **template** engine (HTML with placeholders), and a **forms** layer. "Batteries included" is the
official slogan, and it's literal.

The contrast with a micro-framework makes this concrete. [FastAPI](/guides/fastapi-from-zero) - the
sibling framework in this library - is lean and API-first: brilliant at turning your type hints into a
validated JSON API, but it deliberately stays out of your way on databases, admin screens, and HTML. You
bring those yourself, choosing each library. Django takes the opposite stance: it makes those choices
*for* you and bundles them, so a database-backed website with a login and an admin panel is a starting
point, not a shopping trip.

💡 **The trade, stated plainly.** Micro-frameworks give you fewer conventions to learn and total
freedom to assemble. Django gives you far more provided, in exchange for learning *its* conventions - 
where files go, what things are named, how the pieces connect. Neither is "better"; they fit different
jobs.

## The MTV pattern - Django's shape

Every Django app is organized around three roles. If you've heard of **MVC** (Model–View–Controller),
this is the same idea wearing different labels.

📝 **MTV** - **M**odel, **T**emplate, **V**iew.
- **Model** - your data and how it's stored (a `Post`, a `Comment`). This is the ORM layer.
- **View** - the logic for one request: it decides *what* to do, pulls the right data from the models,
  and picks a template. (Confusingly, this is what MVC calls the *controller*.)
- **Template** - the HTML with placeholders that turns data into a page. (This is what MVC calls the
  *view*.)

⚠️ **The naming trap.** Django's "view" is the controller; Django's "template" is the view. People
coming from other frameworks trip on this constantly. Don't fight it - just remember: in Django, a
**view is a Python function that handles a request**, and a **template renders HTML**.

Here's the full path a request takes through Django:

```mermaid
flowchart LR
  R[HTTP request] --> U[URLconf<br/>matches the path]
  U --> V[View<br/>request logic]
  V --> M[(Model<br/>database)]
  V --> T[Template<br/>HTML]
  M --> V
  T --> Resp[HTTP response]
```

*One idea:* a request comes in, the **URLconf** matches its path to a **view**, the view asks the
**models** for data and feeds it to a **template**, and the rendered HTML goes back as the response.
Every Django page you ever build flows along that arrow. We'll wire up the URLconf and views next phase.

## Project vs app - the split everyone confuses

This is the single most common point of confusion for Django beginners, so let's nail it down first.

📝 **Project** - the whole website. It holds your settings, the root URL configuration, and ties
everything together. There is **one** project. 📝 **App** - a self-contained feature module *inside*
the project: a blog, a user-accounts system, a payments section. A project has **many** apps, and a
well-built app can even be reused across projects.

⚠️ **Don't conflate them.** Beginners routinely cram everything into the project or, worse, make one
giant app. The mental model: the **project** is the building; **apps** are the rooms, each with a clear
purpose. Our blog will live in an app called `blog`, inside a project called `mysite`.

You scaffold the project first, then add the app. Django ships two commands for exactly this:

```bash
# 1. Install Django into a virtual environment first
python -m venv venv
source venv/bin/activate        # Windows: venv\Scripts\activate
pip install django

# 2. Create the project (the whole site)
django-admin startproject mysite
cd mysite

# 3. Create the blog app (one feature inside the site)
python manage.py startapp blog
```

*What just happened:* `django-admin startproject mysite` generated the project skeleton - settings,
configuration, and the `manage.py` script. Then `python manage.py startapp blog` created a fresh app
folder with the empty files a feature needs: a place for models, views, and tests. Two different
commands: `django-admin` bootstraps the project from nothing; `manage.py` (which the project just gave
you) runs everything *after* that.

After both commands, your directory looks like this:

```text
mysite/                  ← project root (the building)
├── manage.py            ← the project's command-line tool
├── mysite/              ← project config package (settings + root URLs)
│   ├── __init__.py
│   ├── settings.py      ← all configuration lives here
│   ├── urls.py          ← root URLconf (request → view routing)
│   ├── asgi.py
│   └── wsgi.py
└── blog/                ← the blog app (a room)
    ├── __init__.py
    ├── admin.py         ← register models with the admin site
    ├── apps.py
    ├── migrations/      ← schema-change history lives here
    ├── models.py        ← your Post and Comment will go here
    ├── tests.py
    └── views.py         ← request-handling logic
```

*What just happened:* notice the repeated name. The outer `mysite/` is the project *folder*; the inner
`mysite/` is the config *package* that holds `settings.py` and the root `urls.py`. Your `blog/` app
sits beside it as a sibling. Every model, view, and template you write for the blog lives in `blog/` - 
keeping the feature in one tidy place, exactly the point of the project/app split.

## `manage.py` and runserver - driving the project

📝 **`manage.py`** - the auto-generated command-line tool for *your* project. It's how you run the dev
server, apply migrations, create admin users, open a shell, and run tests. Where `django-admin` is the
global Django command, `manage.py` is your project's own CLI, pre-wired to its settings.

Before Django knows your blog app exists, you have to introduce them. Open `mysite/settings.py` and add
the app to `INSTALLED_APPS`:

```python
# mysite/settings.py
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "blog",                         # ← our app, now registered
]
```

*What just happened:* `INSTALLED_APPS` is the list of apps Django activates for this project. The first
six are Django's own built-in apps - the admin, auth, sessions, and so on, all "batteries" you got for
free. Adding `"blog"` tells Django to load our app: discover its models, include its migrations, and let
it participate in the admin. ⚠️ Forgetting this line is a classic beginner bug - your models exist but
Django acts as if the app isn't there.

Now bring the site to life. Two commands carry you through almost every dev session:

```bash
python manage.py migrate        # set up the database tables Django needs
python manage.py runserver      # start the development web server
```

```console
$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  ... (more) ...
  Applying sessions.0001_initial... OK

$ python manage.py runserver
Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).
Django version 5.x, using settings 'mysite.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CTRL-BREAK.
```

*What just happened:* `migrate` created the database tables Django's built-in apps need (SQLite by
default, so there's nothing to install). `runserver` then started a lightweight development server on
`http://127.0.0.1:8000/`. Open that URL and you'll see Django's friendly rocket-ship welcome page - 
proof the project is alive. ⚠️ `runserver` is for development *only*, not real production traffic. The
reloader also means you can edit code and the server picks it up without a manual restart.

## Where Django fits

You've now seen what makes Django *Django*: a big box of provided parts, arranged in the MTV pattern,
split across one project and many apps. So when do you reach for it?

💡 **The clear split.** Reach for **Django** when you're building a **full web application** - 
server-rendered pages, a database behind them, user accounts, and especially when that free admin
panel saves you from hand-building a back-office UI. Reach for [FastAPI](/guides/fastapi-from-zero)
when you're building an **API** for other programs (or a JavaScript frontend) to consume, and you want
lean, async, type-hint-driven endpoints. (Django *can* build APIs too, via Django REST Framework - but
if API-first is the whole job, FastAPI is the more natural fit.)

There's a deeper idea here worth naming. Django is a textbook example of a **framework** in the sense
covered in [what a framework even is](/guides/what-a-framework-even-is): the classic *"don't call us,
we'll call you"* relationship. You don't write a main loop that calls Django; Django runs the show and
calls *your* code - your views, your models - at the right moments. That's the trade for all those
batteries: you live inside Django's shape.

Next, we make the request flow concrete. You'll write your first **URLconf** and **view** so that
hitting a path in the browser runs your Python and returns a real page.

## Recap

1. **Django is batteries-included:** ORM, migrations, admin, auth, templates, and forms ship in one
   box - you assemble far less than with a micro-framework like FastAPI.
2. **MTV** is Django's shape: **Model** (data/ORM) → **View** (request logic, picks data) → **Template**
   (HTML). It's MVC renamed - Django's "view" is the controller, its "template" is the view.
3. A request flows **URLconf → View → (Model + Template) → response**; the URLconf matches the path to
   a view, which pulls data and renders a template.
4. A **project** is the whole site (settings, config); an **app** is one feature module inside it. One
   project, many apps - don't conflate them. `django-admin startproject` then `manage.py startapp`.
5. **`manage.py`** is your project's CLI: `migrate` sets up tables, `runserver` starts the dev server.
   Register every app in **`INSTALLED_APPS`** or Django ignores it.
6. **Django for full web apps** (pages + admin + auth), **FastAPI for APIs** - and Django is a clean
   example of the "framework calls you" relationship.

## Quick check

Three questions on the ideas that have to stick - what Django is, the MTV roles, and the project/app
split:

```quiz
[
  {
    "q": "What does 'batteries included' mean for Django compared with a micro-framework like FastAPI?",
    "choices": [
      "Django ships an ORM, migrations, admin, auth, and templates in one box, so you assemble far less yourself",
      "Django runs faster because it is compiled to machine code",
      "Django works without any database at all",
      "Django requires no configuration of any kind, ever"
    ],
    "answer": 0,
    "explain": "Django bundles the common web-app pieces - ORM, migrations, admin, auth, templates, forms - so you write less plumbing. The trade is learning Django's conventions; FastAPI stays lean and lets you bring those pieces yourself."
  },
  {
    "q": "In Django's MTV pattern, what is a 'view'?",
    "choices": [
      "A Python function that handles a request - it picks data and chooses a template (the 'controller' in MVC terms)",
      "The HTML file with placeholders that renders the page",
      "The database table definition",
      "The file that lists installed apps"
    ],
    "answer": 0,
    "explain": "In Django, the view is the request-handling logic - what MVC calls the controller. The HTML with placeholders is the template. This naming swap trips up people from other frameworks."
  },
  {
    "q": "What's the difference between a Django project and a Django app?",
    "choices": [
      "A project is the whole site (settings, config); an app is one feature module inside it - one project, many apps",
      "They're two words for the same thing",
      "An app contains many projects",
      "A project is for the database and an app is for the templates"
    ],
    "answer": 0,
    "explain": "The project is the building (settings, root URLs); apps are the rooms, each a self-contained feature like 'blog' or 'accounts'. You scaffold the project with django-admin startproject, then add apps with manage.py startapp."
  }
]
```


---

# URLs & Views

In Phase 1 you got a project running and met the **MTV** flow: a request comes in, a *view* handles it,
a *template* renders the result. This phase is about the first half of that journey - the part that turns
a URL someone typed into a specific chunk of your code, and that chunk into something the browser can show.

Here's the mental model to hold onto before any code: **a web request is just a string (a URL) arriving
at your server, and your job is to match that string to a function and have that function hand back a
response.** Django splits that into two clean jobs. *URL routing* answers "which function handles this
URL?" *Views* answer "what does that function actually do?" Once you see the request flow as `URL →
match → view → response`, Django's routing stops looking like magic and starts looking like a lookup
table with a function on the other end.

We'll build this around a blog. Our star is the `Post` - for now we'll just return simple text
responses, since templates (the pretty HTML part) come in Phase 5 and the actual `Post` data comes from
the ORM in Phase 3.

## The URLconf - Django's routing table

📝 **The URLconf** is just a Python file (`urls.py`) containing a list called `urlpatterns`. Each entry is
a `path(...)` call that says "if the URL looks like *this*, call *that* view." Django walks the list top to
bottom on every request and uses the **first** pattern that matches.

The clever part is that Django uses *two layers* of URLconf. The **project** has one `urls.py` (the front
door), and it `include()`s a separate `urls.py` from each **app**. That keeps routing modular: your blog
app owns all its own URLs, and the project just decides what prefix to mount them under. Move the app to
another project and its routes come with it.

```mermaid
flowchart LR
  A[Request: /blog/posts/5/] --> B[project urls.py]
  B -->|matches 'blog/'| C[blog app urls.py]
  C -->|matches 'posts/5/'| D[post_detail view]
  D --> E[HttpResponse]
```

Here's the **project** `urls.py` (created for you when you ran `startproject` - you add the `include` line):

```python
# myblog/urls.py - the project-level URLconf (the front door)
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path("admin/", admin.site.urls),
    path("blog/", include("blog.urls")),   # hand anything starting with blog/ to the blog app
]
```

*What just happened:* `urlpatterns` is the list Django checks for every incoming request. The `admin/`
line is what makes the admin site work (Phase 4). The line that matters here is
`path("blog/", include("blog.urls"))` - it says "for any URL beginning with `blog/`, strip that prefix and
let the `blog` app's own `urls.py` handle the rest." Django doesn't import the blog routes itself; it
*delegates*, and the project never needs to know the blog's internal URLs.

Now the **app** `urls.py`. ⚠️ Unlike the project file, this one does **not** exist by default - you create
it yourself inside the app folder:

```python
# blog/urls.py - the blog app's own URLconf
from django.urls import path
from . import views

urlpatterns = [
    path("posts/", views.post_list),              # /blog/posts/
    path("posts/<int:post_id>/", views.post_detail),  # /blog/posts/5/
]
```

*What just happened:* we imported the app's `views` module (the functions live there, next section) and
listed two routes. Because these are mounted under `blog/` by the project, the *full* URLs are
`/blog/posts/` and `/blog/posts/5/` - the app file only ever describes the part *after* its prefix. The
`<int:post_id>` bit is a captured parameter, unpacked shortly. New developers forget to create this file
constantly and then wonder why nothing routes; if you get a 404 on a URL you "added," check that the app
`urls.py` exists and that the project actually `include()`s it. It feels like extra ceremony for a
two-route blog, but it's what keeps a 40-app project from collapsing into one unreadable routing file.

## Function views - request in, response out

📝 **A view is a plain Python function that takes an `HttpRequest` as its first argument and returns an
`HttpResponse`.** That's the entire contract. Django builds the request object for you, calls your view,
and sends whatever you return back to the browser. No special base class, no registration - if it takes a
request and returns a response, it's a view.

Let's write the two views our `blog/urls.py` referred to. We'll keep the responses as bare text for now - 
real HTML and real `Post` data arrive in later phases.

```python
# blog/views.py
from django.http import HttpResponse

def post_list(request):
    return HttpResponse("All blog posts will be listed here.")

def post_detail(request, post_id):
    return HttpResponse(f"You asked for post #{post_id}.")
```

*What just happened:* both functions take `request` first - that's the `HttpRequest` Django hands every
view, carrying everything about the incoming call. `post_list` ignores it and returns a fixed message;
`post_detail` takes a second argument, `post_id`, and echoes it back. `HttpResponse("...")` wraps a
string into a proper HTTP response (status 200, with headers) that the browser can render. That round
trip - function called with a request, string wrapped in a response - *is* a working web page in Django.
Visit `/blog/posts/` after starting the dev server and you'll see the first message in your browser. That's
the whole `URL → view → response` loop running.

## URL parameters - capturing pieces of the path

A blog with a single post page is no blog. You need `/blog/posts/1/`, `/blog/posts/2/`, and so on, all
handled by *one* view that knows *which* post was asked for. That's what the angle-bracket syntax in the
URLconf does - it captures part of the URL and passes it to your view as an argument.

📝 `path("posts/<int:post_id>/", views.post_detail)` reads as: match `posts/`, then a run of digits, then a
slash. Capture those digits, convert them to an `int`, and pass them to the view under the name `post_id`.
The `int:` part is a **path converter** - it both restricts what matches (only digits) *and* controls the
Python type your view receives.

```python
# blog/urls.py
urlpatterns = [
    path("posts/", views.post_list),
    path("posts/<int:post_id>/", views.post_detail),
]

# blog/views.py
def post_detail(request, post_id):
    # post_id is already an int here - Django converted it
    return HttpResponse(f"Showing post #{post_id} (type: {type(post_id).__name__})")
```

*What just happened:* when a request for `/blog/posts/5/` comes in, Django matches the second pattern,
pulls `5` out of the URL, runs it through the `int` converter, and calls `post_detail(request, post_id=5)`.
Inside the view, `post_id` is the integer `5`, not the string `"5"` - the converter did the casting. Visit
`/blog/posts/5/` and you'll see `Showing post #5 (type: int)`. Try `/blog/posts/abc/` and you'll get a 404,
since `int:` refuses to match non-digits - the bad input never even reaches your view.

The common converters: `<int:x>` for whole numbers, `<str:x>` for a non-empty text segment (no slashes),
`<slug:x>` for slug strings like `my-first-post`, and `<uuid:x>` for UUIDs. The flow is always the same:
the URL pattern captures a typed value, and your view receives it as a named argument it can trust.

## The request and response objects

We've been treating `request` as a placeholder, but it's the most useful object in the view. 📝 **The
`HttpRequest` carries everything about the incoming call** - the HTTP method (`request.method`),
query-string data (`request.GET`), submitted form data (`request.POST`), the logged-in user
(`request.user`), headers, cookies, and more. On the way out, you return an `HttpResponse` (plain
text/HTML), a `JsonResponse` (for APIs), or - most often, once we have templates - `render(...)`.

Here's a view that actually reads from the request. We'll let visitors filter posts with a query string
like `/blog/posts/?tag=python`:

```python
# blog/views.py
from django.http import HttpResponse

def post_list(request):
    tag = request.GET.get("tag")          # ?tag=python  ->  "python"; missing -> None
    if tag:
        body = f"Posts tagged '{tag}' (method: {request.method})"
    else:
        body = f"All posts (method: {request.method})"
    return HttpResponse(body)
```

*What just happened:* `request.GET` is a dict-like object holding the query string (the part after `?`).
Using `.get("tag")` instead of `request.GET["tag"]` means a *missing* `tag` returns `None` rather than
crashing. `request.method` is the HTTP verb, `"GET"` for a normal page visit. Visit
`/blog/posts/?tag=python` and you'll see the filtered message; visit `/blog/posts/` and you'll see the
catch-all. The request object is how your view *listens*; the response is how it *answers*.

The other half of this is the not-found case. When someone asks for a post that doesn't exist, you owe
them a correct 404, not a 500 crash. Django ships a helper for exactly this - and it'll be your default
the moment we have a database in Phase 3:

```python
# blog/views.py  (sketch - Post arrives in Phase 3)
from django.shortcuts import get_object_or_404
from .models import Post

def post_detail(request, post_id):
    post = get_object_or_404(Post, id=post_id)   # found -> the Post; missing -> raises Http404
    return HttpResponse(f"Showing: {post.title}")
```

*What just happened:* `get_object_or_404` tries to fetch one `Post` with that `id`. If it exists, you get
the object back. If it doesn't, the helper raises `Http404`, and Django turns that into a proper 404 page
automatically - you never write the "if missing, build an error response" boilerplate yourself. ⚠️ Shown
as a preview; it won't run until `Post` exists as a model (Phase 3), but it's the pattern you'll reach for
in nearly every detail view you write.

## Named URLs & `reverse` - never hardcode a URL

There's one habit that quietly rots a codebase: writing URLs as literal strings all over your views and
templates. The day you decide `/blog/posts/5/` should become `/blog/articles/5/`, you have to hunt down
every place that string appears. Miss one, and you ship a broken link.

💡 Django's fix is to give each route a **name** and refer to it *by that name* instead of by its path.
Add `name=` to a `path(...)`, then build URLs with `reverse()` in Python or the `{% url %}` tag in
templates. Change the URL pattern later and every reference updates itself, because nothing hardcoded the
path in the first place.

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

urlpatterns = [
    path("posts/", views.post_list, name="post_list"),
    path("posts/<int:post_id>/", views.post_detail, name="post_detail"),
]
```

*What just happened:* We tagged each route with a `name`. The path strings (`"posts/"`,
`"posts/<int:post_id>/"`) are now an implementation detail; the rest of the codebase will refer to these
routes as `"post_list"` and `"post_detail"`, which are stable even if the paths change.

Now build a URL *from* a name instead of typing it:

```python
# blog/views.py
from django.urls import reverse
from django.http import HttpResponse

def post_list(request):
    # build the URL for post #5 by NAME, not by hardcoding "/blog/posts/5/"
    link = reverse("post_detail", args=[5])
    return HttpResponse(f"The URL for post 5 is: {link}")
```

*What just happened:* `reverse("post_detail", args=[5])` asks Django "what's the actual URL for the route
named `post_detail`, with `post_id=5`?" and gets back `/blog/posts/5/`. You never wrote that string. Edit
the pattern later to `articles/<int:post_id>/` and this exact call starts returning `/blog/articles/5/`
with zero other changes. ⚠️ The bug this prevents is real and common: hardcoded URLs scattered across
views and templates that silently break when a path changes. Name your routes from day one - it costs one
keyword and saves you a link-hunt later. (In templates you'll use `{% url "post_detail" 5 %}`, met in
Phase 5.)

💡 Step back and look at what you can now do: a URL arrives, the URLconf matches it (possibly across the
project-to-app `include` boundary), a view function runs with a trustworthy `request` and any captured
parameters, and it returns a response - referring to other URLs by name so nothing hardcodes a path. The
one thing still missing is *real data*: our views return made-up strings because there's no `Post` in a
database yet. That's the entire job of the next phase - the ORM, where `Post` becomes a real model backed
by a real table.

## Recap

1. 📝 **The URLconf** is `urlpatterns` - a list of `path(...)` entries in `urls.py` that Django checks top
   to bottom, using the first match to pick a view.
2. The **project** `urls.py` `include()`s each **app's** `urls.py`, keeping routing modular. The app file
   describes only the part of the URL after its mounted prefix - and you must create it yourself.
3. 📝 **A view** is a function taking an `HttpRequest` and returning an `HttpResponse`. That's the whole
   contract; no base class required.
4. **URL parameters** like `<int:post_id>` capture a typed segment from the path and pass it to the view as
   a named argument - and reject input of the wrong type with a 404 before your view runs.
5. 📝 The **request** carries method, `GET`/`POST` data, the user, and more; you return
   `HttpResponse`/`JsonResponse`/`render(...)`. `get_object_or_404` handles the missing-object case cleanly.
6. 💡 **Name your URLs** (`name=`) and build them with `reverse()`/`{% url %}` - never hardcode paths, so a
   URL can change without breaking every link to it.

You now own the request half of MTV: a URL becomes a view becomes a response. Next, those views need real
data to return, which means modeling `Post` as a database table - the ORM, in Phase 3.

## Quick check

Test yourself on the one idea that ties this phase together - how a request finds its view and gets a
response:

```quiz
[
  {
    "q": "What is the minimum contract for a Django function view?",
    "choices": [
      "It takes an HttpRequest as its first argument and returns an HttpResponse",
      "It must subclass django.views.View and define a get() method",
      "It must be registered in settings.py before Django will call it",
      "It returns a template name as a string and Django renders it automatically"
    ],
    "answer": 0,
    "explain": "A view is just a function that accepts the HttpRequest Django passes in and returns an HttpResponse. No base class, no registration - the URLconf points at it and Django calls it."
  },
  {
    "q": "In the pattern `path(\"posts/<int:post_id>/\", views.post_detail)`, what does the `int:` part do?",
    "choices": [
      "Restricts the match to digits and passes post_id to the view as a Python int",
      "Only documents the expected type; the view still receives a string",
      "Limits post_id to a maximum of one integer digit",
      "Tells Django to look up the post in the database before calling the view"
    ],
    "answer": 0,
    "explain": "`int:` is a path converter. It both restricts what matches (only digits, so non-numbers 404 before the view runs) and converts the captured value, so the view receives an actual int."
  },
  {
    "q": "Why use `name=` on a path plus `reverse()`/`{% url %}` instead of writing the URL string directly?",
    "choices": [
      "So you refer to routes by a stable name and the path can change without breaking every link",
      "Because hardcoded URL strings are not allowed in Django and will raise an error",
      "Because reverse() makes the page load faster than a literal URL",
      "Because named URLs are the only way to capture parameters from the path"
    ],
    "answer": 0,
    "explain": "Naming a route lets the rest of the code reference it by name. Change the pattern's path later and every reverse()/{% url %} call updates itself - no hunting for hardcoded strings that would otherwise silently break."
  }
]
```


---

# Models & the ORM

So far the blog has had no real data - Phase 2 mapped URLs to views and returned hard-coded responses. Now we give it a memory. The question this phase answers is the one every web app eventually has to: *where does the data live, and how does my Python code talk to it?*

Here's the mental model to carry through everything below. Your database thinks in **tables** - rows and columns and foreign keys, numbers pointing at numbers. Your Python code thinks in **objects** - a `Post` with a `.title` and a `.body`, a `Comment` that *belongs to* a post. Those are two different shapes for the same information, and something has to translate between them. In Django, that something ships in the box: the **ORM**.

If you've read [What an ORM Is](/guides/hibernate-and-jpa-from-zero) for Java, this is the exact same idea - Hibernate for Java, Django's ORM for Python. And if "table," "row," and "foreign key" are fuzzy, [What a Database Actually Is](/guides/what-a-database-is) is the prerequisite mental model.

## Models = tables

📝 **A model is a Python class that maps to a database table.** You write a class that subclasses `models.Model`; Django treats that class as a table, each instance as a row, and each class attribute as a column. You never hand-write `CREATE TABLE` - you describe the *shape* in Python and Django builds the SQL.

The big relief for Python developers coming from other ecosystems: there's no separate ORM library to install and wire up. Java reaches for Hibernate; Node reaches for Prisma or TypeORM; Python web apps often reach for SQLAlchemy. Django's ORM is *built in* - one less decision, one less dependency.

Here are the blog's two models. They go in your app's `models.py`:

```python
from django.db import models

class Post(models.Model):
    title = models.CharField(max_length=200)
    body = models.TextField()
    created = models.DateTimeField(auto_now_add=True)

class Comment(models.Model):
    post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="comments")
    author = models.CharField(max_length=80)
    body = models.TextField()
    created = models.DateTimeField(auto_now_add=True)
```

*What just happened:* you described two tables in Python. `Post` becomes a `post` table with columns `id` (Django adds an auto-incrementing primary key for free), `title`, `body`, and `created`. Each **field** is a class attribute whose *type* decides the column type: `CharField` is a short string (it needs `max_length` so the database knows the column width), `TextField` is unbounded long text, and `DateTimeField` stores a timestamp - `auto_now_add=True` means "stamp it once, when the row is first created." The `Comment.post` field is a `ForeignKey` pointing at `Post`: the "this comment *belongs to* that post" relationship, stored as a `post_id` column holding a number. More on `on_delete` and `related_name` shortly.

💡 The model is just a class. It doesn't touch the database when Python imports it - it's a *description*. Turning that description into an actual table is the next step, and it's deliberately a separate, explicit action.

## Migrations: version control for your schema

You've written a class that *describes* a table. The database doesn't have that table yet. **Migrations** are how the description becomes reality - and how it stays in sync every time you change a model later.

📝 **A migration is a file that records a change to your database schema.** You don't write it by hand. You run `makemigrations`, Django compares your models to the last known state, and it generates a Python file describing the difference ("create a `post` table with these columns"). Then `migrate` runs those files against the actual database.

It's a two-step rhythm, and the names tell you exactly what each does:

```bash
python manage.py makemigrations
python manage.py migrate
```

*What just happened:* `makemigrations` looked at your `models.py`, saw two brand-new models, and wrote a migration file (something like `blog/migrations/0001_initial.py`) capturing "create these two tables." Nothing has hit the database yet - this step only *plans* the change. Then `migrate` took that plan and executed it, running the actual `CREATE TABLE` SQL. Here's what the console shows:

```console
$ python manage.py makemigrations
Migrations for 'blog':
  blog/migrations/0001_initial.py
    + Create model Post
    + Create model Comment

$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, blog, contenttypes, sessions
Running migrations:
  Applying blog.0001_initial... OK
```

*What just happened:* the first command reported the migration file it created and what's in it. The second applied it - and notice it also applied migrations for `admin`, `auth`, and friends. Those are Django's own built-in apps (you'll meet the admin next phase); they ship migrations too, and `migrate` brings the whole database up to date in one pass.

💡 **Migrations are version control for your database schema.** The migration files are committed to git alongside your code, so a teammate who pulls your branch runs `migrate` and gets the *exact same* tables - no "works on my machine" schema drift. They're reviewable in a pull request, repeatable on every environment, and record the full history of how your schema got where it is.

⚠️ **Every model change needs both steps.** Add a field, rename one, change `max_length` - the moment you touch `models.py`, the database is out of sync until you run `makemigrations` *and then* `migrate`. Forgetting `makemigrations` means the change is never even recorded; forgetting `migrate` means it's recorded but never applied. The classic confusing error - a column the database swears doesn't exist, even though it's right there in your model - is almost always a migration you forgot to apply.

## The ORM: querying in Python

The tables exist. Now the payoff - reading and writing data without writing SQL. The single best way to learn this is the **Django shell**, an interactive Python prompt with your whole project loaded:

```bash
python manage.py shell
```

Every model has an attribute called `objects` - its **manager** - and that's your entry point for talking to the table. Let's create some posts and read them back:

```python
>>> from blog.models import Post

>>> Post.objects.create(title="Hello world", body="My first post.")
<Post: Post object (1)>

>>> Post.objects.create(title="Django is great", body="The admin alone sells it.")
<Post: Post object (2)>

>>> Post.objects.all()
<QuerySet [<Post: Post object (1)>, <Post: Post object (2)>]>

>>> Post.objects.get(id=1)
<Post: Post object (1)>

>>> Post.objects.filter(title__contains="Django")
<QuerySet [<Post: Post object (2)>]>

>>> Post.objects.order_by("-created")
<QuerySet [<Post: Post object (2)>, <Post: Post object (1)>]>
```

*What just happened:* you ran five different database operations and never wrote a word of SQL. `objects.create(...)` inserted a new row and handed back the saved `Post` object. `objects.all()` fetched every row. `objects.get(id=1)` fetched exactly one row by its primary key (it raises an error if zero or more than one match - `get` is for "I expect exactly one"). `objects.filter(title__contains="Django")` returned every post whose title contains "Django" - that `__contains` is a *lookup*, the double-underscore syntax Django uses for "WHERE this column does that." `order_by("-created")` sorted newest-first (the leading `-` means descending). `<QuerySet [...]>` is just Django's name for "a collection of rows from a query."

💡 **You write Python; the ORM writes SQL.** `Post.objects.filter(title__contains="Django")` generated roughly:

```sql
SELECT id, title, body, created
FROM blog_post
WHERE title LIKE '%Django%';
```

*What just happened:* your Python lookup `title__contains="Django"` became a SQL `LIKE` clause, against the `blog_post` table (Django names tables `<app>_<model>` by default). This is the deal an ORM offers: you stay in Python, it produces and runs the SQL. The convenience is real - and like any ORM, it can quietly generate *wasteful* SQL if you're not paying attention. We meet that trap (the famous N+1 problem) head-on in [Phase 7](07-the-orm-deeper.md).

## Relationships: following the foreign key

The `ForeignKey` on `Comment` is what makes this a *relational* database and not two unrelated tables. It gives you navigation in **both directions** - and Django generates a tidy Python accessor for each.

Let's attach a comment to a post and then walk the relationship from both ends:

```python
>>> from blog.models import Post, Comment

>>> post = Post.objects.get(id=1)

>>> Comment.objects.create(post=post, author="Sam", body="Loved this!")
<Comment: Comment object (1)>

>>> # Forward: from a comment to its post
>>> comment = Comment.objects.get(id=1)
>>> comment.post
<Post: Post object (1)>
>>> comment.post.title
'Hello world'

>>> # Reverse: from a post to all its comments
>>> post.comments.all()
<QuerySet [<Comment: Comment object (1)>]>
```

*What just happened:* you created a comment by handing it a whole `Post` object (`post=post`) - Django stores the post's id in the `post_id` column for you. Then you walked the link two ways. **Forward** (the direction the `ForeignKey` points): `comment.post` follows the foreign key from the comment back to its single owning post, chainable straight on to `.post.title`. **Reverse** (against the arrow): `post.comments.all()` finds every comment whose `post_id` matches this post. That `comments` name is exactly the `related_name="comments"` we set on the field - without it, Django would default the reverse accessor to `post.comment_set.all()`.

And `on_delete=models.CASCADE`? Django answering a question the database insists on: *if this post is deleted, what happens to its comments?* `CASCADE` means "delete them too" - a post's comments shouldn't outlive the post. (Other options exist, like `PROTECT` to forbid the deletion, but `CASCADE` is the sensible default here.)

We're keeping relationship queries deliberately shallow here. The deeper material - how QuerySets are *lazy*, why looping over `post.comments` can secretly fire a query per row, and how to fix it - is all in [Phase 7](07-the-orm-deeper.md).

## `__str__`, Meta, and field options

Three finishing touches turn rough models into ones that are pleasant to work with - and that the rest of Django can present nicely.

First, `__str__`. Notice every object above printed as the unhelpful `<Post: Post object (1)>`. Add a `__str__` method and that changes everywhere:

```python
class Post(models.Model):
    title = models.CharField(max_length=200)
    body = models.TextField()
    created = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["-created"]

    def __str__(self):
        return self.title

class Comment(models.Model):
    post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="comments")
    author = models.CharField(max_length=80)
    body = models.TextField()
    created = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return f"{self.author} on {self.post.title}"
```

*What just happened:* `__str__` defines how an object turns into a readable string - now a post prints as `Hello world` instead of `Post object (1)`, in the shell, in error messages, and (crucially) in the admin you'll build next phase. The `class Meta` block holds model-level settings; `ordering = ["-created"]` makes *every* query for posts come back newest-first by default, so you don't have to remember `.order_by()` each time.

Now the field option that trips up nearly everyone: **`null` versus `blank`**. They sound identical and are not.

⚠️ **`null` is about the database; `blank` is about validation.**
- `null=True` lets the *database column* store `NULL` (no value at all). A schema decision.
- `blank=True` lets a *form* accept an empty value without complaining. A validation decision.

They operate in completely different layers. For a text field you'd usually leave both off (required) or set `blank=True` *without* `null=True` - Django stores "empty text" as an empty string `""`, not `NULL`, so adding `null=True` to a `CharField`/`TextField` just creates two different ways to say "empty." Reach for `null=True` mainly on non-text fields (numbers, dates, foreign keys) that genuinely have "no value." A field with `default=...` supplies a value when none is given; `unique=True` tells the database to reject duplicates.

💡 **The model is the single source of truth.** That one class definition drives *three* things at once: the **database schema** (via migrations, this phase), the **admin interface** ([Phase 4](04-the-django-admin.md) - which reads your fields and `__str__` to build a back-office for free), and **forms** ([Phase 6](06-forms-and-validation.md) - where `blank`, `max_length`, and field types become validation rules). Define your data well in `models.py` and Django propagates it everywhere.

## Recap

1. **A model is a Python class that maps to a table** - subclass `models.Model`, and each field attribute (`CharField`, `TextField`, `DateTimeField`, `ForeignKey`) becomes a column. Django's ORM is built in; no SQLAlchemy or separate library required.
2. **Migrations turn model changes into schema changes**: `makemigrations` writes a migration file describing the diff, `migrate` applies it to the database. They're version control for your schema - committed, reviewable, repeatable.
3. ⚠️ **Every model edit needs both `makemigrations` and `migrate`** - skip either and your code and database fall out of sync.
4. **The ORM lets you query in Python**: `objects.create/all/get/filter/order_by` via each model's `objects` manager. You write Python; Django writes and runs the SQL underneath.
5. **A `ForeignKey` gives two-way navigation**: forward with `comment.post`, reverse with `post.comments.all()` (the name set by `related_name`). `on_delete` decides what happens to children when the parent is deleted.
6. **`__str__`, `Meta`, and field options polish the model**: `__str__` for readable objects, `Meta.ordering` for default sort, and field options where `null` is a *database* concern and `blank` is a *forms* concern - different layers, not synonyms. The model is the single source of truth feeding the schema, the admin, and forms.

## Quick check

Three questions on the ideas that have to stick before the admin in Phase 4:

```quiz
[
  {
    "q": "You add a new field to your Post model. What must you do for the database to actually have that column?",
    "choices": [
      "Run `makemigrations` to record the change, then `migrate` to apply it to the database",
      "Nothing - Django updates the database automatically when it imports the model",
      "Hand-write an ALTER TABLE statement and run it in the SQL shell",
      "Only run `migrate`; `makemigrations` is just for brand-new projects"
    ],
    "answer": 0,
    "explain": "Touching models.py puts your code ahead of the schema. `makemigrations` generates a migration file describing the diff; `migrate` runs it against the database. You need both - forgetting either leaves code and database out of sync."
  },
  {
    "q": "Given `post = models.ForeignKey(Post, related_name=\"comments\", on_delete=models.CASCADE)` on Comment, how do you get all comments for a given post object?",
    "choices": [
      "post.comments.all()",
      "post.comment.all()",
      "Comment.objects.post(post)",
      "post.foreignkey('Comment')"
    ],
    "answer": 0,
    "explain": "The reverse accessor's name comes from related_name, so it's post.comments.all(). Without related_name, Django would default it to post.comment_set.all(). Forward navigation (comment to its post) is just comment.post."
  },
  {
    "q": "What is the difference between `null=True` and `blank=True` on a model field?",
    "choices": [
      "`null=True` lets the database column store NULL (a schema concern); `blank=True` lets a form accept an empty value (a validation concern)",
      "They are synonyms - both make the field optional in exactly the same way",
      "`null=True` is for text fields and `blank=True` is for number fields",
      "`blank=True` deletes the row when the field is empty; `null=True` keeps it"
    ],
    "answer": 0,
    "explain": "They operate in different layers. null is about whether the database column can hold NULL; blank is about whether a form will accept an empty value. They're independent, and for text fields you usually use blank without null to avoid two different ways of saying 'empty'."
  }
]
```


---

# The Django Admin

Here's the trick that made people fall in love with Django in the first place. You spent Phase 3 defining
two models - `Post` and `Comment` - describing your blog's data as Python classes. Django already turned
those into database tables for you. Now it's about to do something that feels almost unfair: it reads those
same model definitions and hands you a *complete, working web application* for managing that data. A real
back-office, with login, list pages, search boxes, filters, edit forms, delete buttons - and you write
essentially none of it.

> 📝 **The mental model:** the admin is a *reflection* of your models. It doesn't have its own idea of what
> your data looks like - it asks your models. Each field becomes a form input. Each model becomes a list
> page. Change the model, and the admin changes with it. You're not building the admin, you're *describing
> how you want Django to render the one it already built.*

This is the single feature that sells Django to teams. "We need an internal tool so the content team can
publish posts" is normally a week of CRUD-screen drudgery. In Django it's about four lines of code.

## The auto-admin: a back-office for free

Think about what a content manager actually needs to do with a blog: see all the posts, find a specific
one, create a new one, fix a typo, unpublish something, delete spam comments. That's the classic
**CRUD** loop - Create, Read, Update, Delete - wrapped in a usable interface.

> 💡 Writing those screens by hand is the most repetitive work in web development. Django noticed that the
> information needed to build them - field names, their types, which fields are required - *already lives
> in your models*, so it reads your models (from Phase 3) and builds the whole interface automatically.

You get all of this with zero screen-building on your part:

- A **list page** for each model, showing every row
- **Create** and **edit** forms, with the right input type per field (a date picker for `DateField`, a
  dropdown for foreign keys, a checkbox for `BooleanField`)
- **Delete** with a confirmation step
- **Search**, **filters**, and **pagination** once you ask for them
- A **login screen** and a permission system, so only trusted staff get in

The admin isn't a toy demo, either - real teams run their entire content operation through it for years.

## Enabling it

Good news: the admin is already switched on. When you ran `startproject` back in Phase 1, Django included
the admin app in `INSTALLED_APPS` and wired its URLs. The admin lives at `/admin/` right now. You just
need two things: a user who's allowed in, and a line telling the admin which models to show.

First, create a **superuser** - an account with full admin access:

```bash
python manage.py createsuperuser
```

```console
Username: nika
Email address: nika@example.com
Password:
Password (again):
Superuser created successfully.
```

*What just happened:* Django walked you through creating a privileged account and saved it to the `User`
table (from the auth app's migrations). Start the server (`python manage.py runserver`), visit
`http://127.0.0.1:8000/admin/`, and you can log in - though the dashboard is nearly empty, since you
haven't told the admin about your models yet.

Now open `blog/admin.py` (Django created this empty file for your app) and register your models:

```python
from django.contrib import admin

from .models import Post, Comment

admin.site.register(Post)
admin.site.register(Comment)
```

*What just happened:* `admin.site.register(Post)` says "add `Post` to the admin." Refresh `/admin/` and
you'll see a **Blog** section with **Posts** and **Comments**, each clickable. You can now create posts,
edit them, delete them, and manage comments - a full CRUD interface, for those two lines. Nobody wrote a
single form or template.

> 💡 Remember the `__str__` method you added to your models in Phase 3? *This* is where it pays off - the
> admin's list page shows each row using `__str__`. Without it, every post shows up as the useless `Post
> object (1)`. That wasn't busywork in Phase 3, it was setting up for today.

## Customizing with `ModelAdmin`

The default admin is functional but plain - a list of posts shown only by title, with no way to search or
filter. You shape it with a **`ModelAdmin`** class: a small configuration object that tells the admin how
to present *one* model. You attach it with the `@admin.register` decorator (a tidier replacement for the
`admin.site.register` call above).

```python
from django.contrib import admin

from .models import Post, Comment

@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
    list_display = ("title", "author", "published", "created_at")
    list_filter = ("published", "created_at")
    search_fields = ("title", "body")
    ordering = ("-created_at",)
    prepopulated_fields = {"slug": ("title",)}
```

*What just happened:* each attribute reshapes the admin for `Post`:

- **`list_display`** - which columns show on the list page. Instead of one title column, you now see
  title, author, published-state, and date side by side.
- **`list_filter`** - adds a sidebar of filters. A content manager can click "show only published" or
  filter by date with one click.
- **`search_fields`** - adds a search box at the top that searches across the named fields.
- **`ordering`** - the default sort. `"-created_at"` means newest first.
- **`prepopulated_fields`** - as you type a post's title, Django auto-fills the `slug` field with a
  URL-friendly version. (Assumes your `Post` has a `slug` field; drop the line if it doesn't.)

> 💡 Notice the pattern: you didn't write any UI. You wrote *configuration* - a handful of tuples naming
> fields - and Django translated it into search boxes, filter sidebars, and sortable columns.

## Inlines: editing related objects together

There's one rough edge so far. Comments belong to posts (that's the foreign key from Phase 3), but in the
admin they live on a totally separate page. To read a post and moderate its comments, you'd bounce between
two screens. What you actually want is to see - and edit - a post's comments *right there on the post's
edit page.*

That's an **inline**. You define a small inline class for the related model, then attach it to the parent's
`ModelAdmin`:

```python
from django.contrib import admin

from .models import Post, Comment

class CommentInline(admin.TabularInline):
    model = Comment
    extra = 1

@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
    list_display = ("title", "author", "published", "created_at")
    list_filter = ("published", "created_at")
    search_fields = ("title", "body")
    ordering = ("-created_at",)
    inlines = [CommentInline]
```

*What just happened:* `CommentInline` tells the admin "Comments are related to Post - render them inside
the Post form." Now when you open a post to edit it, its comments appear as editable rows beneath the
post's fields, and `extra = 1` leaves one blank row so you can add a new comment without leaving the page.
Django knew *how* to connect them because of the `ForeignKey` from `Comment` to `Post` - you just told it
where to display the relationship.

> 💡 `TabularInline` lays the related rows out as a compact table (great for short records like comments).
> Its sibling **`StackedInline`** stacks each related object as a full form block - better when the related
> model has many fields and a table would be too cramped.

## What the admin is for (and what it isn't)

The admin feels so powerful that it's tempting to reach for it everywhere. So let's be clear about its job,
because misusing it is a genuine security mistake.

> 💡 The admin is for **trusted internal staff** - your team, your content editors, your moderators. It's an
> *internal back-office and content-management tool*, and at that job it's a massive productivity win. Spin
> one up and your colleagues can manage data on day one.

> ⚠️ The admin is **not** your public, user-facing interface, and it is **not** a public API. Don't point
> your blog's readers at `/admin/` to write comments, and don't let untrusted users near it. It exposes raw
> database editing with delete buttons everywhere - in the wrong hands that's a disaster. In production,
> lock it down: serve it over HTTPS, give staff strong unique passwords, and consider moving it off the
> obvious `/admin/` URL or restricting it by IP.

What your *public* visitors see - the actual blog pages with their own design - is a separate thing you
build deliberately. That's the next two phases: templates render the public pages (Phase 5), and forms let
visitors safely submit comments (Phase 6).

> 💡 Step back and notice what just happened across Phases 3 and 4. You defined `Post` and `Comment` *once*,
> as Python classes. From that single definition Django gave you the database schema (Phase 3), and now a
> complete admin interface (Phase 4) - and next it'll generate forms too (Phase 6). Define the model once,
> get the rest for free.

## Recap

- Django generates a **full CRUD back-office** - list, search, filter, create, edit, delete - automatically
  from your models. It's the framework's signature feature.
- The admin is already enabled. Create access with `python manage.py createsuperuser`, then **register**
  each model with `admin.site.register(Model)` (or the `@admin.register` decorator) in `admin.py`.
- A model's `__str__` controls how its rows appear in the admin - which is why you wrote good `__str__`
  methods in Phase 3.
- A **`ModelAdmin`** class customizes one model's admin via `list_display`, `list_filter`, `search_fields`,
  `ordering`, and `prepopulated_fields` - configuration, not UI code.
- **Inlines** (`TabularInline` / `StackedInline`) let you edit related objects, like a post's comments, on
  the parent's edit page.
- The admin is for **trusted staff only** - an internal tool, never a public UI or API. Lock it down in
  production.

## Quick check

```quiz
[
  {
    "q": "Where does the Django admin get the information it needs to build its forms and list pages?",
    "choices": ["From a separate config file you write by hand", "From your model definitions", "From the database's raw column metadata at runtime"],
    "answer": 1,
    "explain": "The admin reflects your models - each field becomes a form input and each model becomes a list page. Change the model and the admin changes with it."
  },
  {
    "q": "What does a ModelAdmin's `list_display` attribute control?",
    "choices": ["Which fields are required when creating a record", "Which columns appear on the model's list page in the admin", "Which users are allowed to see the model"],
    "answer": 1,
    "explain": "`list_display` is a tuple of field names shown as columns on the list page, so you see more than just the `__str__` value."
  },
  {
    "q": "Which statement about the Django admin is correct?",
    "choices": ["It's meant to be your public, user-facing interface", "It's an internal back-office for trusted staff and should be locked down in production", "It replaces the need to ever write templates or forms"],
    "answer": 1,
    "explain": "The admin is a powerful internal tool for trusted staff. It is not a public UI or API - expose it carefully, and build public pages with templates and forms instead."
  }
]
```


---

# Templates & the MTV Pattern

Back in Phase 2 your views already started rendering templates instead of returning hand-typed HTML strings. This phase is where we slow down and look at the **T** in MTV properly - because that letter is doing more work than it looks like.

Here's the mental model to hold onto before any code: **a view's job is to gather data and hand it off; a template's job is to turn that data into HTML.** The view talks to the Model (your `Post` objects), bundles up what it found, and passes it to a Template that knows how to lay it out. The view never builds HTML; the template never touches the database. Keeping those jobs separate is why the same `Post` list can be rendered as a web page today and (Phase 9) as JSON tomorrow without rewriting your data logic.

📝 **MTV is Django's name for the same idea most people call MVC.** Model = your data (the ORM). Template = the presentation (HTML). View = the glue in the middle that decides *which* data goes to *which* template. The flow for one request is short and always the same: **request → URLconf picks a view → view queries the Model → view calls `render()` with a template + data → HTML goes back to the browser.**

## Where templates fit

A view renders a template with `render()`. You give it three things: the request, the path to a template, and a dict of data:

```python
# blog/views.py
from django.shortcuts import render
from .models import Post

def post_list(request):
    posts = Post.objects.all()
    return render(request, "blog/post_list.html", {"posts": posts})
```

*What just happened:* the view fetched every `Post` from the database (Model), then handed that list to `render()` along with a template path and a dict. `render()` finds `blog/post_list.html`, runs it with `posts` available inside, and returns a finished `HttpResponse` full of HTML. The view itself contains zero HTML - it only decides *what* to show, not *how* it looks.

📝 That template path - `"blog/post_list.html"` - lives in a `templates/` folder inside your app: `blog/templates/blog/post_list.html`. The doubled `blog/` is deliberate: Django searches *all* apps' `templates/` folders as one merged pile, so the inner `blog/` namespaces your files and stops your `post_list.html` from colliding with some other app's `post_list.html`.

## The Django Template Language

A template is mostly plain HTML with three special markers sprinkled in. That's the entire language, and there are only three shapes to learn:

- `{{ variable }}` - **output** a value. `{{ post.title }}` prints the title.
- `{% tag %}` - **logic**: loops, conditionals, and helpers like `{% for %}`, `{% if %}`, `{% url %}`.
- `{{ value|filter }}` - **transform** a value on its way out: `{{ post.body|truncatewords:30 }}`.

Here's `post_list.html` looping over the posts the view passed in:

```html
<h1>The Blog</h1>

{% for post in posts %}
  <article>
    <h2><a href="{% url 'post_detail' post.id %}">{{ post.title }}</a></h2>
    <p class="meta">{{ post.published_at|date:"M j, Y" }}</p>
    <p>{{ post.body|truncatewords:30 }}</p>
  </article>
{% empty %}
  <p>No posts yet. {{ empty_message|default:"Check back soon." }}</p>
{% endfor %}
```

*What just happened:* `{% for post in posts %}` walks the list. For each post, `{{ post.title }}` prints the title and `{% url 'post_detail' post.id %}` builds the link by *name* (the named routes from Phase 2's URLconf) instead of hardcoding a path - so if the URL pattern ever changes, this link follows it automatically. The filters earn their keep too: `|date:"M j, Y"` formats a datetime into `Jun 22, 2026`, and `|truncatewords:30` clips the body to 30 words. The `{% empty %}` branch runs only when `posts` is empty, and `|default:` supplies a fallback if `empty_message` is missing or falsy.

⚠️ The Django Template Language is **deliberately limited** - you cannot call arbitrary Python, do math, or run a database query from inside a template. That's a feature, not a missing one: real logic belongs in the **view**, where it's testable and visible. If you find yourself fighting the template to compute something, that's the template telling you the work should have happened in the view first.

## Context: what the template can see

That dict you pass to `render()` has a name: the **context**. It's the *entire* world the template can see. If a name isn't in the context, the template does not have it at all - there's no reaching back into the view or the database for more.

```python
# blog/views.py
def post_list(request):
    posts = Post.objects.all()
    context = {
        "posts": posts,
        "show_drafts": request.user.is_staff,
    }
    return render(request, "blog/post_list.html", context)
```

*What just happened:* the view built a context with two keys and handed it over. Inside the template, `{{ posts }}` and `{{ show_drafts }}` are now available - and *nothing else from the view is*. Rename the key and the template's `{{ posts }}` goes blank. That tight boundary is what makes templates predictable: to know what a template can use, you only have to read the context, not the whole view.

💡 You can drive logic off context values: `{% if show_drafts %}...{% endif %}` shows a block only to staff. The decision (`request.user.is_staff`) was made in the view; the template just reacts to the boolean it was given. View decides, template displays.

## Template inheritance: write the layout once

Every page on your site shares chrome - the same `<head>`, nav bar, and footer. Copy-pasting that into every template is how you end up updating the nav in eleven files and missing one. Django's answer is **template inheritance**: a `base.html` defines the skeleton with `{% block %}` holes, and child templates fill the holes.

```html
<!-- blog/templates/blog/base.html -->
<!DOCTYPE html>
<html>
<head>
  <title>{% block title %}The Blog{% endblock %}</title>
</head>
<body>
  <nav><a href="/">Home</a></nav>

  <main>
    {% block content %}{% endblock %}
  </main>

  <footer>Built with Django</footer>
</body>
</html>
```

```html
<!-- blog/templates/blog/post_list.html -->
{% extends "blog/base.html" %}

{% block title %}All Posts - The Blog{% endblock %}

{% block content %}
  <h1>The Blog</h1>
  {% for post in posts %}
    <h2>{{ post.title }}</h2>
  {% endfor %}
{% endblock %}
```

*What just happened:* `base.html` lays out the page once and marks two spots - `{% block title %}` and `{% block content %}` - as overridable. The child template's `{% extends "blog/base.html" %}` says "start from that skeleton," then its own `{% block %}` tags pour content into the matching holes. The child never repeats the `<nav>`, the `<footer>`, or the `<head>`. Change the footer in `base.html` and every page that extends it updates at once - the **DRY** win for server-rendered HTML.

## Auto-escaping: the XSS shield you didn't ask for

Now the part that quietly protects you. By default, **Django templates auto-escape every variable they output.** If a `{{ post.body }}` contains `<script>alert('xss')</script>`, Django doesn't render a live script tag - it converts the angle brackets to `&lt;script&gt;` so the browser prints the text harmlessly instead of executing it.

```console
Stored post body:  Nice post! <script>steal()</script>
Rendered to page:  Nice post! &lt;script&gt;steal()&lt;/script&gt;
```

*What just happened:* a malicious comment body went *into* the template, but auto-escaping defanged it on the way *out*. The user sees the literal text; the browser never runs the script. This is your default defense against **cross-site scripting (XSS)** - the attack where someone smuggles markup through user input to run code in another visitor's browser. Same family of trust-the-input mistake as SQL injection; for the full picture, read [SQL Injection & XSS](/guides/sql-injection-and-xss). In Django templates, the safe behavior is the one you get for free.

⚠️ The escape hatch is `|safe` (or `mark_safe()` in Python), which tells Django "trust this, render it raw" - turning the shield **off** for that value. Only reach for it on content *you* generated or have sanitized, never on anything a user typed. `{{ comment.body|safe }}` on a user-submitted comment is exactly how an XSS hole gets created.

💡 You'll also start seeing `{% csrf_token %}` the moment you add a form - it drops a hidden token into the HTML that proves a form submission really came from your own page, and earns its full explanation next phase.

💡 Templates are the **V**iew the user actually sees - the rendered surface of your app. So far the data has flowed one direction: database → view → template → browser. Next we reverse it: **forms** are how data flows back *in*, from the user to your app, and that's exactly where `{% csrf_token %}` and validation come in.

## Recap

- **MTV** splits work cleanly: the **Model** holds data, the **View** gathers it and decides what to show, the **Template** turns it into HTML. The view never builds HTML; the template never queries the database.
- A view renders with `render(request, "blog/post_list.html", {"posts": posts})` - request, template path, and a **context** dict of data.
- The **template language** has three shapes: `{{ variable }}` to output, `{% tag %}` for logic (`{% for %}`, `{% if %}`, `{% url %}`), and `{{ value|filter }}` to transform. It's deliberately limited - real logic stays in the view.
- The **context** is the template's entire world. Only the names you put in the dict are visible inside.
- **Template inheritance** (`{% block %}` in `base.html`, `{% extends %}` in children) gives you shared layout with zero duplication - the DRY win for server-rendered HTML.
- Django **auto-escapes** variables by default, blocking XSS for free. `|safe`/`mark_safe` turns that off - only use it on content you trust, never on user input.

## Quick check

```quiz
[
  {
    "q": "In Django's MTV pattern, whose job is it to query the database and decide which data to send to the template?",
    "choices": ["The Template", "The View", "The URLconf"],
    "answer": 1,
    "explain": "The View gathers data from the Model and hands it to the Template via render(). The template only displays what it's given."
  },
  {
    "q": "A template tries to use {{ author }}, but the view's context dict only contains {\"posts\": posts}. What happens?",
    "choices": ["Django reaches back into the view to find author", "author is empty - only names in the context dict are visible", "It raises a hard error and the page 500s"],
    "answer": 1,
    "explain": "The context is the template's entire world. A name not in the dict renders as empty; the template can't reach outside it."
  },
  {
    "q": "A comment body contains <script>steal()</script>. You render it with {{ comment.body }}. What does the visitor's browser do?",
    "choices": ["Runs the script - XSS succeeds", "Prints the text harmlessly because Django auto-escapes it", "Strips the tag silently and shows nothing"],
    "answer": 1,
    "explain": "Auto-escaping converts the angle brackets to entities, so the browser prints the literal text instead of executing it. Adding |safe would disable this and reopen the XSS hole."
  }
]
```


---

# Forms & Validation

Phase 5 ended on a one-way street: database → view → template → browser. Data flowed *out*. This phase reverses the arrow. A reader finishes your blog post, wants to leave a comment, types it into a box, and hits submit. That comment has to travel *back in* - and along the way it needs to be parsed, checked, and either saved or bounced back with errors. That whole round trip is what Django forms are for.

Here's the mental model to carry through everything below: **a form is a translator that sits between messy HTTP and clean Python.** A browser sends form submissions as a flat bag of strings - `title=Hello&body=Nice+post`. Your `Comment` model wants real, validated Python values. The form stands in the middle: it renders the HTML inputs going *out*, then on the way *in* it parses those raw strings, validates them, and hands you back clean Python values (or a tidy list of errors to show the user). You declare *what* you want; the form does the tedious, error-prone middle work.

📝 Without a form, you'd be doing all of that by hand: writing the `<input>` tags, reading `request.POST["body"]`, checking it isn't blank, checking it isn't 5000 characters, converting types, and rebuilding the page with error messages - on every single form, forever. The `forms` framework is Django saying "you've described the shape of your data already; let me handle the plumbing."

## Why Django forms

A **`Form` class** is where you declare the fields you expect. It looks a lot like a model, on purpose - each attribute is a field with a type and some rules:

```python
# blog/forms.py
from django import forms

class CommentForm(forms.Form):
    author = forms.CharField(max_length=80)
    body = forms.CharField(widget=forms.Textarea)
    email = forms.EmailField(required=False)
```

*What just happened:* you declared a form with three fields. Each one carries both a *type* and *validation rules* baked in. `CharField(max_length=80)` will render a text input and later reject anything over 80 characters. `EmailField` renders a text input but checks the value actually looks like an email. `required=False` makes `email` optional - by default every field is required. You wrote zero HTML and zero validation logic: the field types *are* the spec, and Django reads that spec to both build the inputs and check the answers.

💡 The `widget=` argument controls *how* a field is rendered without changing *what* it accepts. `CharField` normally renders a single-line `<input>`; `widget=forms.Textarea` swaps that for a multi-line `<textarea>` - same data, different box.

## `ModelForm` - forms from models

The plain `Form` above works, but look closely and you'll spot a problem: those fields mirror your `Comment` model from Phase 3. You'd be writing `author`, `body`, `email` *twice* - once in `models.py`, once in `forms.py` - and keeping them in sync by hand forever. That's exactly the duplication Django hates.

📝 **`ModelForm` builds a form straight from a model.** You point it at the model, list the fields you want, and Django reads the model's field definitions to generate the form - types, max lengths, and all. The model goes back to being the single source of truth it was always meant to be.

```python
# blog/forms.py
from django import forms
from .models import Comment

class CommentForm(forms.ModelForm):
    class Meta:
        model = Comment
        fields = ["author", "body", "email"]
```

*What just happened:* the inner `class Meta` tells the `ModelForm` two things - which `model` to mirror (`Comment`) and which `fields` to include. Django inspects the `Comment` model and generates a matching form field for each name in the list: the model's `CharField(max_length=80)` becomes a form `CharField(max_length=80)` automatically. You didn't redeclare a single field.

💡 The real payoff comes at save time: a `ModelForm` knows how to turn its cleaned data into a model instance. After validation you call `form.save()` and it **creates the `Comment` object for you** and writes it to the database - no manual `Comment(author=..., body=...)` construction. (Listing fields explicitly beats the shortcut `fields = "__all__"` - that quietly exposes *every* model field to user input, which is how you accidentally let someone set `is_approved=True` on their own comment.)

## The view pattern (GET vs POST)

A form needs a view to drive it, and Django has one canonical shape for that view. The same URL does double duty depending on the HTTP method: a **GET** request means "show me the form," and a **POST** request means "here's my filled-in form, process it." One view, two jobs, branching on `request.method`.

```python
# blog/views.py
from django.shortcuts import render, redirect, get_object_or_404
from .models import Post
from .forms import CommentForm

def add_comment(request, post_id):
    post = get_object_or_404(Post, id=post_id)

    if request.method == "POST":
        form = CommentForm(request.POST)        # bind the submitted data
        if form.is_valid():
            comment = form.save(commit=False)   # build, don't save yet
            comment.post = post                 # attach it to this Post
            comment.save()                      # now write to the DB
            return redirect("post_detail", post_id=post.id)
    else:
        form = CommentForm()                    # GET: an empty, blank form

    return render(request, "blog/add_comment.html", {"form": form, "post": post})
```

*What just happened:* on a **GET**, the `else` branch runs and builds an empty `CommentForm()` - a blank form to render. On a **POST**, you create a *bound* form by passing `request.POST` (the submitted data) into `CommentForm(request.POST)`, then ask `form.is_valid()`. If it's valid, `form.save(commit=False)` builds the `Comment` object *without* hitting the database yet - that pause lets you attach the parent `post` (which the form never asked the user for) before the real `comment.save()`. Then you **redirect**. If validation *fails*, `is_valid()` is `False`, the `if` is skipped, and execution falls through to the same `render()` at the bottom - but now `form` carries the user's input *and* the error messages, so the page re-renders with both.

⚠️ **Always redirect after a successful POST** - that's the Post/Redirect/Get pattern, not optional politeness. Render a page directly after saving instead of redirecting, and the browser still has the POST "loaded," so a refresh (or back button) re-submits it - posting the same comment however many times the reader hits F5. Redirecting sends the browser to a fresh GET, so a refresh just reloads a harmless page.

## Validation

The line `if form.is_valid():` is doing a lot of quiet work, so let's open it up. Calling **`is_valid()`** runs every field's checks - required-ness, max lengths, type conversion (a date string becomes a real `date`, an `EmailField` confirms the `@`). It returns `True` or `False`, and as a side effect it populates two things: **`form.cleaned_data`** (a dict of the validated, type-converted Python values) on success, and **`form.errors`** (per-field error messages) on failure.

📝 The rule of thumb: **never read `request.POST` for real values - read `form.cleaned_data`.** `request.POST["body"]` gives you the raw submitted string, untouched and unvalidated. `form.cleaned_data["body"]` gives you the value *after* it survived validation. The form is the translator; `cleaned_data` is its output.

For rules a field type can't express on its own, you write a **`clean_<field>()`** method for one field, or a **`clean()`** method for rules that span several fields:

```python
# blog/forms.py
from django import forms
from .models import Comment

BANNED = {"spam", "buy-now", "free-money"}

class CommentForm(forms.ModelForm):
    class Meta:
        model = Comment
        fields = ["author", "body", "email"]

    def clean_body(self):
        body = self.cleaned_data["body"]
        if any(word in body.lower() for word in BANNED):
            raise forms.ValidationError("That comment looks like spam.")
        return body
```

*What just happened:* Django automatically calls `clean_body()` during `is_valid()`, after the built-in field checks have already passed (so `self.cleaned_data["body"]` is guaranteed present). You inspect the value; if it smells like spam you `raise forms.ValidationError(...)` with a human message; otherwise you **return the value** - and that return is mandatory, because whatever `clean_body` returns becomes the final `cleaned_data["body"]`. When you raise instead, validation fails, `is_valid()` flips to `False`, and your message lands in `form.errors["body"]` automatically. For cross-field rules you'd override `clean()` instead, where the *whole* `cleaned_data` dict is available at once.

💡 You almost never have to wire error messages into the template by hand. Because failed validation routes everything into `form.errors`, and rendering the form prints those errors next to the fields they belong to, bad input bounces back to the user annotated, with their other answers preserved.

## CSRF protection

There's one last piece, and Django will *refuse to process your POST without it*. If you build the template form and leave this out, you'll hit a `403 Forbidden` - so let's understand why before it bites you.

📝 **CSRF stands for Cross-Site Request Forgery:** an attack where a malicious page tricks your *already-logged-in* browser into firing a request at your site - submitting a form, changing a password - riding on the cookies you already have. The browser happily attaches your session, so the server can't tell the forged request from a real one.

⚠️ Django's defense is the **`{% csrf_token %}` tag**. It drops a hidden, per-session secret token into your form's HTML, and Django checks that the token comes back on every POST. An attacker's page on another domain can forge the *request* but can't read your token (the browser's same-origin rules stop it), so the forged POST arrives without a valid token and Django rejects it - same family of trust-the-input problem as the injection bugs in [SQL Injection & XSS](/guides/sql-injection-and-xss).

Here's the template that renders the form, token included:

```html
<!-- blog/templates/blog/add_comment.html -->
{% extends "blog/base.html" %}

{% block content %}
  <h2>Comment on "{{ post.title }}"</h2>

  <form method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">Post comment</button>
  </form>
{% endblock %}
```

*What just happened:* `{% csrf_token %}` renders the hidden token input that Django will verify on submit - leave it out and the POST is rejected. `{{ form.as_p }}` renders every field as a paragraph, *including its label, its input, and any error messages* from `form.errors` - so a bounced-back invalid form shows its complaints with zero extra markup from you.

💡 Step back and look at the whole chain you've built across this guide: you defined a `Comment` **model** once (Phase 3), and from that one definition you got your database **schema** (Phase 3), a working **admin** interface (Phase 4), auto-escaped **templates** (Phase 5), and now a **validated form** - nearly for free. Model → form → template is Django's central bargain.

## Recap

- A **`Form`** class declares the fields you expect; Django uses that one declaration to render the HTML inputs going out and to parse + validate the submitted strings coming in. A form is a translator between messy HTTP and clean Python.
- A **`ModelForm`** builds itself from a model via `class Meta: model = ...; fields = [...]`, so the model stays the single source of truth - and `form.save()` creates the object for you.
- The canonical view branches on method: **GET** shows an empty form; **POST** binds `request.POST`, and `if form.is_valid():` saves and **redirects** (Post/Redirect/Get) - else it re-renders with errors.
- **`is_valid()`** runs the checks and fills **`cleaned_data`** (validated values - read these, never raw `request.POST`) or **`form.errors`**. Add `clean_<field>()` for one field or `clean()` for cross-field rules; `raise forms.ValidationError(...)` to reject.
- **`{% csrf_token %}`** is mandatory in every POST form - it proves the submission came from your own page and blocks Cross-Site Request Forgery. Without it, Django returns `403`.
- The model → form → template chain means one good model definition gives you schema, admin, *and* a validated form with very little extra code.

## Quick check

```quiz
[
  {
    "q": "After a successful POST that saves a new comment, why does the view return redirect(...) instead of render(...)?",
    "choices": ["redirect is faster than render", "It follows Post/Redirect/Get so a browser refresh won't re-submit the form", "render can't be used after form.save()"],
    "answer": 1,
    "explain": "Rendering directly after a POST leaves the POST 'loaded' in the browser, so a refresh re-submits and creates duplicate comments. Redirecting sends the browser to a fresh GET, making refresh harmless."
  },
  {
    "q": "Inside a custom clean_body() method, where do you read the field's value and what must the method do on success?",
    "choices": ["Read request.POST['body'] and return None", "Read self.cleaned_data['body'] and return the value", "Read form.errors['body'] and raise it"],
    "answer": 1,
    "explain": "clean_<field>() reads the validated value from self.cleaned_data and must return it - that return becomes the final cleaned value. Raising forms.ValidationError instead marks the field invalid."
  },
  {
    "q": "You submit a POST form but forgot {% csrf_token %} in the template. What happens?",
    "choices": ["The form saves normally; the token is optional", "Django returns 403 Forbidden because the CSRF check fails", "The browser strips the form before sending"],
    "answer": 1,
    "explain": "Django requires a valid CSRF token on every POST to block Cross-Site Request Forgery. Without {% csrf_token %} the token is missing, the check fails, and you get a 403."
  }
]
```


---

# The ORM, Deeper

Phase 3 gave you the ORM's friendly face: `Post.objects.filter(...)`, `post.comments.all()`, query in Python and never touch SQL. That face is accurate, but it's only half the story. The other half is what happens *underneath* - when the SQL actually runs, how many queries you fire without realizing, and why the same blog loop that's instant on your laptop crawls in production.

Here's the mental model to carry through this whole phase. **A QuerySet is not data - it's a recipe for a query.** It describes *what you would fetch if someone asked*, and it sits there, costing nothing, until something forces it to run. That single fact explains chaining, the surprising moments when the database suddenly lights up, and the most expensive beginner mistake in any ORM: the N+1 problem.

If you've read [Lazy vs Eager Fetching & the N+1 Problem](/guides/hibernate-and-jpa-from-zero) for Java, you already know the punchline - N+1 is not a Django bug or a Hibernate bug, it's a trap *every* ORM sets the same way. This phase is the Python telling of that same story.

## QuerySets are lazy

📝 **A QuerySet doesn't touch the database when you build it. It runs only when you *consume* it** - iterate it in a `for` loop, slice it, call `list()` on it, or print it in the shell. Until then, you're holding a description, not rows.

That means you can stack `.filter()`, `.exclude()`, and `.order_by()` as much as you like, and Django builds *one* query out of the whole chain, deferring the actual database hit to the moment you read the results:

```python
>>> from blog.models import Post

>>> qs = Post.objects.filter(body__icontains="django")   # no query yet
>>> qs = qs.exclude(title__startswith="Draft")           # still no query
>>> qs = qs.order_by("-created")                          # STILL no query

>>> for post in qs:        # ← the database is hit RIGHT HERE
...     print(post.title)
Django is great
Hello world
```

*What just happened:* the first three lines look like they're doing work, but not one of them talked to the database. Each call returns a *new* QuerySet that remembers "filter by this, then exclude that, then sort." Django only assembles and runs the SQL when the `for` loop asks for the first row, and because the whole chain collapses into a single query, those three operations cost exactly one round trip - not three. The chaining is free; the *consumption* is what costs.

Here's the single query that chain produces:

```sql
SELECT id, title, body, created
FROM blog_post
WHERE body LIKE '%django%'
  AND NOT (title LIKE 'Draft%')
ORDER BY created DESC;
```

*What just happened:* `filter` became a `WHERE`, `exclude` became `AND NOT (...)`, and `order_by("-created")` became `ORDER BY created DESC` (the leading `-` is descending). Three Python method calls, one SQL statement. The ORM folded your recipe into a single trip to the database.

⚠️ **This laziness is a gift and a landmine.** The gift: you can pass QuerySets around, layer filters in different functions, and pay for exactly one query at the end. The landmine: because the query is invisible until consumed, it's genuinely easy to *accidentally* trigger many of them without noticing - precisely how the N+1 problem sneaks in. Hold onto "consuming a QuerySet runs a query"; in a few sections it's going to bite.

💡 One consequence worth knowing now: each time you consume a *fresh* QuerySet, it re-runs the query. `list(qs)` twice is two trips. If you need the results more than once, evaluate it once (`posts = list(qs)`) and reuse the list. Django caches results *within* a single QuerySet object once evaluated, but a brand-new `.filter(...)` chain is a brand-new query.

## Filtering with real power

The `__contains` lookup from Phase 3 was a taste. Django's **field lookups** are a small language for "WHERE this column does that," and three tools cover almost everything you'll reach for.

**Field lookups** are the double-underscore suffixes on a field name:

```python
>>> Post.objects.filter(title__icontains="django")     # case-insensitive contains
>>> Post.objects.filter(created__year=2026)            # rows created in 2026
>>> Post.objects.filter(created__gte="2026-01-01")     # created on or after that date
```

*What just happened:* `title__icontains` is case-insensitive `LIKE`; `created__year=2026` digs the year out of a date column; `created__gte` is `>=`. The pattern is always `field__lookup=value`. There are dozens - `__lt`, `__lte`, `__gt`, `__in`, `__isnull`, `__startswith` - but they all read the same way. That `created__gte` becomes:

```sql
SELECT id, title, body, created FROM blog_post WHERE created >= '2026-01-01';
```

But plain `.filter()` arguments are always joined with `AND`. The moment you need **OR**, or anything that doesn't fit "column = value," you reach for a **`Q` object**:

```python
>>> from django.db.models import Q

>>> Post.objects.filter(Q(title__icontains="django") | Q(body__icontains="django"))
```

*What just happened:* `Q` wraps a condition into something you can combine with `|` (OR) and `&` (AND), and negate with `~`. This finds posts mentioning "django" in *either* the title *or* the body - impossible with plain keyword arguments, which only AND together.

```sql
SELECT id, title, body, created FROM blog_post
WHERE title LIKE '%django%' OR body LIKE '%django%';
```

The third tool is the **`F` expression**, for when the value you're comparing against (or assigning) is *another column*, not a constant:

```python
>>> from django.db.models import F

>>> # atomic update: bump every post's view_count by 1, in the database
>>> Post.objects.update(view_count=F("view_count") + 1)
```

*What just happened:* `F("view_count")` means "the current value of this column, in the database, right now." So `view_count=F("view_count") + 1` becomes a single `UPDATE blog_post SET view_count = view_count + 1` - the increment happens *inside* the database, atomically, in one statement. ⚠️ The naive alternative - read the value into Python, add one, save it back - has a race: two requests both read `5`, both write `6`, and you've lost an increment. `F` sidesteps that by never bringing the number into Python. (`F` also works in filters: `Comment.objects.filter(created__gt=F("post__created"))` finds comments made after their post existed.)

## Spanning relationships in queries

Phase 3 walked the foreign key in Python (`comment.post`, `post.comments.all()`). The ORM lets you walk it *inside a filter* too, with the same double-underscore syntax - `field__relatedfield`:

```python
>>> # comments on a specific post, found without first fetching the post
>>> Comment.objects.filter(post__title="Hello world")

>>> # posts that have at least one comment by "Sam"
>>> Post.objects.filter(comments__author="Sam").distinct()
```

*What just happened:* `post__title` reaches *across* the `ForeignKey` from `Comment` to `Post` and filters on the post's title - Django turns that into a SQL `JOIN`. The second query goes the *reverse* direction: from `Post`, through the `comments` relation, to each comment's author, finding posts Sam commented on. (`.distinct()` because a post with three Sam-comments would otherwise appear three times.) Here's the first one's SQL:

```sql
SELECT c.id, c.author, c.body, c.created, c.post_id
FROM blog_comment c
INNER JOIN blog_post p ON c.post_id = p.id
WHERE p.title = 'Hello world';
```

*What just happened:* one query, one join, the filter applied on the joined table. Keep that join in mind, because the next section is about what happens when you *don't* let Django write it and walk the relationship in a Python loop instead.

## The N+1 problem (the main event)

This is the one. The performance bug that reads like completely normal code, passes every test on your three-row dev database, and then falls over the day production has real data. Watch closely.

You want to list every post with how many comments it has. The obvious loop:

```python
posts = Post.objects.all()                 # query #1: load the posts

for post in posts:
    print(post.title, post.comments.count())   # ← a NEW query every iteration
```

*What just happened:* line one runs **one** query to load all the posts. Then, each time the loop calls `post.comments.count()`, that's a *fresh* QuerySet on the reverse relation - and remember, **consuming a QuerySet runs a query.** So every single post fires its own `SELECT`. Here's the SQL flood with, say, 100 posts:

```sql
SELECT id, title, body, created FROM blog_post;                          -- the "1"

SELECT COUNT(*) FROM blog_comment WHERE post_id = 1;                     -- the "N" begins...
SELECT COUNT(*) FROM blog_comment WHERE post_id = 2;
SELECT COUNT(*) FROM blog_comment WHERE post_id = 3;
-- ...one more query for every single post...
SELECT COUNT(*) FROM blog_comment WHERE post_id = 99;
SELECT COUNT(*) FROM blog_comment WHERE post_id = 100;
```

*What just happened:* **1 query for the posts, then N more - one per post - for the comments.** That's `1 + N` queries. 100 posts = **101 queries**. A thousand posts = 1001. Each is a separate round trip: network hop, parse, plan, execute, return. Individually quick; multiplied by N, a stampede. This is the **N+1 problem**.

⚠️ The cruelty is that it's *invisible in the code*. The loop reads like a normal loop. It's instant with three posts in your test DB. Then it meets 5,000 posts in production and the page times out - nobody changed a line. The query count grows with your *data*, not your *code*, so it slides through code review and tests you didn't write. The exact same trap exists in Hibernate, SQLAlchemy, ActiveRecord, every ORM - see [the Java telling](/guides/hibernate-and-jpa-from-zero). You only catch it by *watching the query count*.

Django gives you two cures, and which one you use depends on the *direction* of the relationship.

**`select_related` - for forward `ForeignKey` / `OneToOne` (a JOIN).** Use it when you're going *to* the "one" side - `comment.post`:

```python
# BAD: 1 query for comments + 1 per comment for its post = N+1
for comment in Comment.objects.all():
    print(comment.post.title)        # comment.post hits the DB each loop

# GOOD: one query, the post JOINed in
for comment in Comment.objects.select_related("post"):
    print(comment.post.title)        # post already loaded - no extra query
```

*What just happened:* `select_related("post")` tells Django to `JOIN` the `post` table into the *same* query, so each comment arrives with its post already attached. The flood of per-comment `SELECT`s collapses into one statement:

```sql
SELECT c.id, c.author, c.body, c.post_id,
       p.id, p.title, p.body, p.created
FROM blog_comment c
INNER JOIN blog_post p ON c.post_id = p.id;
```

`select_related` works for following a foreign key *forward* (many-to-one) or a one-to-one, because a JOIN can pull the single related row in cleanly.

**`prefetch_related` - for reverse / many relations (a second query, joined in Python).** Use it when you're going *to* the "many" side - `post.comments.all()`:

```python
# BAD: 1 query for posts + 1 per post for its comments = N+1
for post in Post.objects.all():
    print(post.title, [c.author for c in post.comments.all()])

# GOOD: 2 queries total, no matter how many posts
for post in Post.objects.prefetch_related("comments"):
    print(post.title, [c.author for c in post.comments.all()])
```

*What just happened:* `prefetch_related("comments")` can't use a JOIN (joining a one-to-many would multiply rows wastefully), so it runs **one** query for the posts, then **one** more query that grabs *all* the comments for *all* those posts in a single `IN`, and stitches them onto the right posts in Python:

```sql
SELECT id, title, body, created FROM blog_post;
SELECT id, author, body, post_id FROM blog_comment WHERE post_id IN (1, 2, 3, ..., 100);
```

*What just happened:* **two queries, total - regardless of whether you have 100 posts or 100,000.** The `post.comments.all()` inside the loop no longer hits the database; it reads from the prefetched cache Django filled. 101 queries became 2.

💡 The rule that sticks: **`select_related` for the "one" side (it JOINs), `prefetch_related` for the "many" side (it does a second `IN` query).** Reach for whichever matches the direction you're traversing - covered further in [Why Is My Query Slow?](/guides/why-is-my-query-slow).

## Aggregation, annotation, and seeing the SQL

You've been counting comments the slow way. The ORM can push that counting *into the database*, where it belongs.

For a **single summary number** across the whole table, use `aggregate`:

```python
>>> from django.db.models import Count, Avg

>>> Post.objects.count()                        # how many posts? -> 100
>>> Comment.objects.aggregate(total=Count("id"))   # -> {'total': 540}
```

*What just happened:* `.count()` is `SELECT COUNT(*)` - one number, one query, far cheaper than `len(Post.objects.all())` (which pulls every row into Python just to count them). `aggregate(...)` collapses a whole QuerySet into a dict of computed values - `Count`, `Avg`, `Sum`, `Min`, `Max` - all computed by the database, not in Python.

For a **per-row** computed value - "how many comments does *each* post have" - use `annotate`. This is the proper, one-query fix for the N+1 counting loop from earlier:

```python
>>> from django.db.models import Count

>>> posts = Post.objects.annotate(num_comments=Count("comments"))
>>> for post in posts:
...     print(post.title, post.num_comments)   # no extra query - it's already on the row
```

*What just happened:* `annotate(num_comments=Count("comments"))` attaches a *new, computed attribute* to every post in the QuerySet, calculated by the database with a `GROUP BY`. Each `post.num_comments` is already there. The whole listing is **one** query:

```sql
SELECT p.id, p.title, p.body, p.created, COUNT(c.id) AS num_comments
FROM blog_post p
LEFT OUTER JOIN blog_comment c ON c.post_id = p.id
GROUP BY p.id;
```

*What just happened:* the database did all the counting in a single `GROUP BY` and handed back posts with their counts baked in - same result as the 101-query loop we started with, 1 query instead of 101. `aggregate` is the table-wide total; `annotate` is the per-row value.

💡 **The #1 Django performance skill is counting your queries.** N+1 doesn't announce itself, so make the SQL visible while you develop:

```python
>>> # see the exact SQL a QuerySet will run, without running it:
>>> print(Post.objects.filter(title__icontains="django").query)
SELECT "blog_post"."id", "blog_post"."title", ... WHERE ... LIKE %django%
```

*What just happened:* `str(queryset.query)` shows you the SQL Django *would* emit - invaluable for "wait, why is this slow." Beyond that, install **django-debug-toolbar** (it shows a per-request query count and flags duplicates right in the browser), or turn on SQL logging in settings. If one page action fires dozens of near-identical `SELECT`s, you've found an N+1.

💡 The plain summary of this whole phase: **the ORM is wonderfully convenient, and that convenience is exactly what hides the cost.** Every QuerySet is real SQL underneath. You don't have to write that SQL - but you do have to know it's there, and count it.

## Recap

1. **QuerySets are lazy.** Building and chaining `.filter().exclude().order_by()` costs nothing; the database is hit only when you *consume* the QuerySet (iterate, slice, `list()`). The whole chain collapses into one query.
2. **Filtering has real power**: field lookups (`title__icontains`, `created__year`, `__gte`) for "column does that," `Q` objects for OR/complex/negated conditions, and `F` expressions for column-to-column comparisons and atomic in-database updates.
3. **You can span relationships inside a filter** with `field__relatedfield` (`Comment.objects.filter(post__title=...)`) - Django writes the JOIN for you.
4. ⚠️ **The N+1 problem**: a loop that touches a related object per row (`post.comments...`) fires 1 query for the parents + N for the children = `1 + N`. 100 posts → 101 queries. It's invisible in code and scales with your data, not your logic - the same trap in [every ORM](/guides/hibernate-and-jpa-from-zero).
5. **The fixes**: `select_related` for forward FK / one-to-one (does a JOIN), `prefetch_related` for reverse / many relations (a second `IN` query joined in Python). One side JOINs, the many side prefetches.
6. **Aggregation & seeing the SQL**: `.count()` and `.aggregate(Count/Avg/Sum)` for table-wide totals, `.annotate(Count("comments"))` for per-row computed values. 💡 The #1 perf skill is *counting queries* - use `str(qs.query)`, django-debug-toolbar, or SQL logging.

## Quick check

Three questions on the ideas that separate "the ORM works" from "the ORM is fast":

```quiz
[
  {
    "q": "You write `qs = Post.objects.filter(body__icontains=\"django\").exclude(title__startswith=\"Draft\").order_by(\"-created\")` and then do nothing else. How many database queries have run so far?",
    "choices": [
      "Zero - a QuerySet is lazy; nothing hits the database until you consume it (iterate, slice, or list() it)",
      "Three - one for each of filter, exclude, and order_by",
      "One - the query runs immediately when you call filter",
      "Two - filter and exclude each run, but order_by is free"
    ],
    "answer": 0,
    "explain": "QuerySets are lazy. Chaining filter/exclude/order_by just builds up a recipe; each call returns a new QuerySet without touching the database. The single combined query runs only when you consume it - iterate it in a loop, slice it, or call list(). Building the chain costs nothing."
  },
  {
    "q": "You loop over `Post.objects.all()` and inside the loop access `post.comments.all()` to list each post's comments. With 200 posts, roughly how many queries run, and what fixes it?",
    "choices": [
      "201 (1 + N) - it's the N+1 problem; fix it with prefetch_related(\"comments\"), which loads all comments in one extra IN query",
      "1 - Django automatically loads all related comments with the posts",
      "201, and the fix is select_related(\"comments\"), which JOINs the comments in",
      "2 always - Django caches the reverse relation by default"
    ],
    "answer": 0,
    "explain": "Accessing post.comments.all() per iteration consumes a fresh QuerySet each time = 1 query for posts + 200 for comments = 201 (N+1). Because comments is a reverse/many relation, the fix is prefetch_related (a second IN query joined in Python), not select_related (which uses a JOIN and is for forward FK / one-to-one)."
  },
  {
    "q": "You want every post listed with its comment count, in as few queries as possible. Which approach does it?",
    "choices": [
      "Post.objects.annotate(num_comments=Count(\"comments\")) - the database computes a per-row count with GROUP BY in a single query",
      "Loop over posts and call post.comments.count() on each - Django optimizes this to one query",
      "Post.objects.aggregate(Count(\"comments\")) - returns the count for each post",
      "Post.objects.count() - counts comments per post automatically"
    ],
    "answer": 0,
    "explain": "annotate adds a per-row computed value (here, a Count with GROUP BY) so each post arrives with num_comments already attached - one query for the whole listing. aggregate returns a single table-wide summary dict, not a per-post value, and the per-post .count() loop is the N+1 you're trying to avoid."
  }
]
```


---

# Users, Auth & Sessions

By now your blog has posts, an admin, templates, and forms. There's one thing every real blog needs that
we've been quietly ignoring: knowing *who* is on the other end. Anonymous visitors should be able to read
posts, but only logged-in users should create them or leave comments. That's the entire job of this phase.

Here's the mental model to hold before any code, because it's the one thing that makes the rest fall into
place. Login is really **two separate questions, asked at two separate moments.** First, *who are you?* - 
that's **authentication** (authN): proving identity, usually with a username and password. Second, *are you
allowed to do this?* - that's **authorization** (authZ): checking permissions once we already know who you
are. They sound like one idea ("logging in") but they're not, and Django gives you distinct tools for each.
If that split feels fuzzy, [/guides/auth-vs-authz](/guides/auth-vs-authz) untangles it properly.

The good news: you write almost none of this yourself. Django ships a complete auth system, and a big
reason teams reach for Django at all is that "users can sign up and log in" goes from a multi-week project
to an afternoon.

## The built-in auth system

📝 **`django.contrib.auth` is a full authentication system that comes turned on in every new project.** It
gives you a `User` model (a real database table for accounts), password hashing done correctly, login and
logout views, a permissions framework, and the session machinery that remembers a logged-in user across
requests. You don't install it or build it - it's already in `INSTALLED_APPS` when `startproject` runs, and
its tables were created the first time you ran `migrate` back in Phase 1.

Take the password part seriously, because it's the piece beginners most often get wrong by rolling their
own. ⚠️ **Django never stores a password as plain text.** When a user signs up, Django runs the password
through a one-way hash (PBKDF2 by default, with a per-user salt and many iterations) and stores only the
hash. At login it hashes the entered password and compares hashes - the original is never recoverable, even
by you. See [/guides/how-passwords-are-stored](/guides/how-passwords-are-stored) for why that's exactly how
passwords *should* be stored. Let Django's auth system own passwords. Don't touch the hash, don't write your
own.

So the two halves map cleanly onto Django pieces: **authN** is the `User` model + login views + password
hashing; **authZ** is the permissions and the `@login_required`/permission checks we'll get to. Same login,
two jobs.

## The User model & `request.user`

📝 **The `User` model (`django.contrib.auth.models.User`) is just a Django model like your `Post`** - a
table with fields. The ones you'll use constantly: `username`, `password` (the hash, never the plain text),
`email`, plus the flags `is_active`, `is_staff` (can reach the admin), and `is_superuser` (can do
everything). It's an ORM model, so everything you learned in Phases 3 and 7 applies - you can query it,
filter it, relate your `Post` to it with a `ForeignKey`.

The part that ties auth into your views is one attribute. 📝 **Every request carries `request.user`** - 
Django's auth middleware attaches it before your view runs. If someone is logged in, it's their `User`
object. If nobody is logged in, it's a special `AnonymousUser` instead of `None`, so you never have to
null-check it. Both kinds answer `.is_authenticated`: `True` for a real logged-in user, `False` for
`AnonymousUser`.

```python
# blog/views.py
from django.http import HttpResponse

def whoami(request):
    if request.user.is_authenticated:
        return HttpResponse(f"You are logged in as {request.user.username}.")
    return HttpResponse("You are browsing anonymously.")
```

*What just happened:* we read `request.user` without ever fetching it ourselves - the auth middleware put
it there. `is_authenticated` is the clean way to branch: `True` for a logged-in `User`, `False` for the
`AnonymousUser` anonymous visitors get. We never wrote `if request.user is None` - because it's never
`None`, `request.user.username` and `request.user.is_authenticated` are always safe to read. This check is
the building block under everything else in this phase.

## Login & logout

You could write a login view by hand, but you shouldn't - Django ships them. 📝 **`django.contrib.auth`
includes ready-made views (`LoginView`, `LogoutView`) and a URLconf you can wire in with one line.** Under
those views sit three functions you'll occasionally call directly: `authenticate(username, password)`
checks credentials and returns the `User` (or `None`), `login(request, user)` starts a session for them,
and `logout(request)` ends it.

Wire the built-in auth URLs into your project:

```python
# myblog/urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path("admin/", admin.site.urls),
    path("blog/", include("blog.urls")),
    path("accounts/", include("django.contrib.auth.urls")),  # login/, logout/, password reset, etc.
]
```

*What just happened:* that last line mounts Django's entire auth URLconf under `/accounts/`. You instantly
get `/accounts/login/`, `/accounts/logout/`, and the password-reset flow - routes and views you didn't
write. `LoginView` expects a template at `registration/login.html`, so you supply the look and feel while
Django handles credential-checking and session-starting.

Here's that minimal login template - a plain form posting back to the same login URL:

```html
<!-- templates/registration/login.html -->
<h1>Log in</h1>
<form method="post">
  {% csrf_token %}
  {{ form.as_p }}
  <button type="submit">Log in</button>
</form>
```

*What just happened:* `LoginView` hands the template a ready-built `form` (username + password fields),
which `{{ form.as_p }}` renders - the same `Form` machinery from Phase 6. `{% csrf_token %}` is mandatory
on any Django POST form; without it the submission is rejected. When the user submits valid credentials,
`LoginView` calls `authenticate` and `login` for you, starts the session, and redirects them onward. You
wrote markup; Django did the auth.

⚠️ **One decision to make on day one, not later: whether you need a custom user model.** If you'll ever
want to log in by email instead of username, or add fields to the account itself, set `AUTH_USER_MODEL` to
your own model *before your first migration*. Swapping the user model on a project that already has data
pointing at the default `User` is genuinely painful. The default `User` is fine for many blogs; just make
the call deliberately at the start rather than discovering the constraint after launch.

## Authorization: protecting views

Now the second question - *are you allowed?* This is where we lock down post creation and commenting so
only logged-in users can reach them.

📝 **`@login_required` is a decorator that gates a view behind being logged in.** Slap it on a function
view and Django checks `request.user.is_authenticated` before the view runs; anonymous visitors get
bounced to the login page (carrying a `?next=` so they return after logging in), and only authenticated
users get through.

```python
# blog/views.py
from django.contrib.auth.decorators import login_required
from django.shortcuts import render, redirect
from .forms import PostForm

@login_required
def post_create(request):
    if request.method == "POST":
        form = PostForm(request.POST)
        if form.is_valid():
            post = form.save(commit=False)
            post.author = request.user      # tie the new post to whoever is logged in
            post.save()
            return redirect("post_detail", post_id=post.id)
    else:
        form = PostForm()
    return render(request, "blog/post_form.html", {"form": form})
```

*What just happened:* the decorator runs *before* the view body. An anonymous visitor never reaches the
form - they're redirected to `/accounts/login/?next=/blog/posts/new/`, and after logging in they land back
on the create page. Inside the view we trust `request.user` is a real logged-in `User`, so
`post.author = request.user` safely stamps the post with its creator. This is authN (the decorator) and a
small piece of authZ (only logged-in users create) working together. The same pattern protects a
comment-submission view.

For class-based views (Phase 9) the equivalent is the `LoginRequiredMixin` - same effect, mixed into the
class instead of decorating a function.

Sometimes "logged in" isn't enough; you need "logged in *and* allowed to do this specific thing." That's
**permissions**. Django auto-creates add/change/delete/view permissions for every model, you can group them
(a "Editors" group), and you check them with `user.has_perm("blog.add_post")` or the
`@permission_required("blog.add_post")` decorator. The coarse flags `is_staff` and `is_superuser` are the
blunt instruments above that. Use the lightest tool the job needs: `@login_required` for "any user,"
permissions for "users with this capability," `is_staff` for "site admins."

Authorization shows up in templates too - you often want to *show* the "New post" link only to people who
can use it:

```html
{% if user.is_authenticated %}
  <a href="{% url 'post_create' %}">Write a new post</a>
  {% if perms.blog.add_post %}<span>(you can publish)</span>{% endif %}
{% else %}
  <a href="{% url 'login' %}">Log in to write</a>
{% endif %}
```

*What just happened:* Django's template context makes `user` and `perms` available everywhere (via a
context processor that's on by default). `{% if user.is_authenticated %}` hides the create link from
anonymous visitors, and `{% if perms.blog.add_post %}` checks a specific permission inline. ⚠️ Hiding a
link is UX, **not** security - a determined visitor can still type the URL. The real protection is the
`@login_required`/`@permission_required` on the *view*. Always do both, never rely on the template alone.

## Sessions under it all

Step back and ask the question this whole phase glosses over: HTTP forgets you between requests, so how
does Django know, on request #47, that you logged in back on request #3?

📝 **The answer is sessions, and it's the same mechanism you saw in the servlet world** - Django just wraps
it. On a successful `login()`, Django creates a **session** stored server-side (in your database by
default), generates a hard-to-guess session id, and sends it to the browser as a `sessionid` cookie. On
every later request the browser returns that cookie automatically; Django's middleware reads the id, looks
up the session, finds the stored user id, and *reconstructs* `request.user` for you - it's not magic, it's
a cookie-keyed lookup. If sessions as a concept are still hazy, the bare mechanism is laid out in
[/guides/the-servlet-api](/guides/the-servlet-api).

```mermaid
sequenceDiagram
  participant B as Browser
  participant D as Django
  B->>D: POST /accounts/login/ (username + password)
  D->>D: authenticate() then login(): create session, store user id
  D-->>B: Set-Cookie: sessionid=...
  B->>D: GET /blog/posts/new/ (Cookie: sessionid=...)
  D->>D: middleware: look up session, rebuild request.user
  D-->>B: page (request.user is the logged-in User)
```

A few things to keep straight about session security, because the cookie is the keys to the account:

- ⚠️ **Serve over HTTPS** and set `SESSION_COOKIE_SECURE = True` so the session cookie never travels over
  plain HTTP where it could be sniffed. Django also marks it `HttpOnly` by default, keeping JavaScript from
  reading it.
- ⚠️ **Set a sensible session expiry** so an abandoned login on a shared computer doesn't stay valid
  forever. Django regenerates the session id on login, which blunts session-fixation attacks.
- ⚠️ **Let Django hash passwords** - never store or compare plain text. We said it earlier; it's worth
  repeating because it's the mistake with the worst blast radius.

💡 Look at what you got for the price of a few lines of wiring: real user accounts, correctly hashed
passwords, login/logout, per-view and per-permission access control, and a battle-tested session layer
underneath. That's a complete, secure auth system in roughly an afternoon - precisely the kind of
"batteries included" that makes teams pick Django. Next phase we move from function views to class-based
views and Django REST Framework, where `LoginRequiredMixin` and these same auth ideas reappear with
API-shaped ergonomics.

## Recap

1. 📝 **`django.contrib.auth` is a full auth system that's already on** - a `User` model, correct password
   hashing (PBKDF2), login/logout views, permissions, and sessions, all without you building them.
2. **AuthN vs authZ are two jobs:** authentication proves *who you are* (login + password hashing);
   authorization decides *what you're allowed to do* (`@login_required`, permissions, `is_staff`).
3. 📝 **`request.user` is on every request** - a logged-in `User` or an `AnonymousUser` (never `None`).
   Branch on `request.user.is_authenticated`.
4. **Wire login with `include("django.contrib.auth.urls")`** and a `registration/login.html` template;
   protect views with `@login_required` (or `LoginRequiredMixin` for CBVs) and permissions for finer
   control. Template checks like `{% if user.is_authenticated %}` are UX, not security.
5. 📝 **Sessions are the mechanism underneath:** `login()` stores a server-side session and sets a
   `sessionid` cookie; middleware reads it each request to rebuild `request.user`. ⚠️ Use HTTPS, sensible
   expiry, and let Django hash passwords - never store plain text.

## Quick check

Test yourself on the ideas that have to stick - the authN/authZ split and how Django remembers a login:

```quiz
[
  {
    "q": "What does the @login_required decorator do to a function view?",
    "choices": [
      "Redirects anonymous visitors to the login page and only runs the view for authenticated users",
      "Hashes the user's password before the view runs",
      "Checks a specific permission like blog.add_post before allowing access",
      "Hides links to the view in templates for logged-out users"
    ],
    "answer": 0,
    "explain": "@login_required checks request.user.is_authenticated before the view body. Anonymous visitors are redirected to login (with ?next=); only logged-in users reach the view. Specific capabilities are permissions; hiding links is a template/UX concern, not view protection."
  },
  {
    "q": "After a user logs in, how does Django know on a later request that they're still logged in?",
    "choices": [
      "It stores a server-side session and sends a sessionid cookie; middleware reads the cookie each request to rebuild request.user",
      "It keeps the username in a global variable on the server",
      "It puts the plaintext password in a cookie and re-checks it every request",
      "HTTP keeps the connection open, so the server never forgets the user"
    ],
    "answer": 0,
    "explain": "login() creates a server-side session and sets a sessionid cookie. The browser returns the cookie automatically; Django's middleware looks up the session by id and reconstructs request.user. The cookie carries only the opaque id, never the password."
  },
  {
    "q": "Why is hiding a 'New post' link with {% if user.is_authenticated %} not enough to protect post creation?",
    "choices": [
      "Hiding the link is only UX - a visitor can still type the URL, so the view itself needs @login_required",
      "Template tags can't read request.user, so the check never runs",
      "Anonymous users can't render templates at all",
      "It is enough; no view-level check is needed once the link is hidden"
    ],
    "answer": 0,
    "explain": "A hidden link just removes the UI affordance; the URL still exists and can be requested directly. Real protection lives on the view (@login_required / @permission_required). Do both: template checks for a clear UI, view decorators for actual security."
  }
]
```


---

# Class-Based Views & Django REST Framework

Back in [Phase 2](02-urls-and-views.md) you learned the whole contract for a view: a function that takes
an `HttpRequest` and returns an `HttpResponse`. That's still true, and it's still a perfectly good way to
write views. But once you've written your tenth "fetch all the posts, render a list" view, you start to
notice you're typing the same shape over and over. Django has two answers to that repetition, and this
phase is about both.

Here's the mental model to carry in before any code. **A view's job is always the same - turn a request
into a response - but most views fall into a handful of standard shapes: list these objects, show one
object, create one, edit one, delete one.** Function views make you write each shape by hand every time.
*Class-based views* let you describe a view as a class and inherit the boilerplate. *Generic views* go
further and hand you the whole shape pre-built. And when the response you want is JSON for another program
instead of HTML for a browser, *Django REST Framework* gives you the same leverage for APIs.

We'll keep building the blog around our `Post` model.

## Class-based views - a view as a class

📝 **A class-based view (CBV) is a view written as a Python class instead of a function.** Django still
calls it for each request, but instead of one function body that has to figure out the HTTP method itself,
you write one *method per HTTP verb*: a `get()` method for `GET` requests, a `post()` method for `POST`,
and so on. Django looks at the incoming request's method and routes to the matching method for you.

The point isn't "classes are nicer than functions." The point is *inheritance*: once a common pattern lives
in a base class, every view that needs that pattern can inherit it instead of repeating it.

Here's a plain function view - the kind you already know:

```python
# blog/views.py
from django.http import HttpResponse

def post_list(request):
    posts = Post.objects.all()
    body = "<br>".join(p.title for p in posts)
    return HttpResponse(body)
```

*What just happened:* a normal function view - fetch all posts, build a tiny HTML string, return it. If you
wanted to also handle `POST` here, you'd write `if request.method == "POST":` branches inside the one
function.

Now the same thing as a class-based view:

```python
# blog/views.py
from django.http import HttpResponse
from django.views import View

class PostListView(View):
    def get(self, request):
        posts = Post.objects.all()
        body = "<br>".join(p.title for p in posts)
        return HttpResponse(body)
```

*What just happened:* we subclassed Django's base `View` and put the GET logic in a `get()` method. There's
no `if request.method ==` branching anymore - Django inspects the method and calls `get()` for a GET
request (and would call `post()` for a POST, if we'd written one). The wiring in `urls.py` changes
slightly, since the URLconf needs a callable, and a class isn't one until you call `.as_view()` on it:

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

urlpatterns = [
    path("posts/", views.PostListView.as_view(), name="post_list"),
]
```

*What just happened:* `PostListView.as_view()` returns a function that Django can call like any view - it
builds an instance of your class per request and dispatches to the right method. From the URLconf's
perspective it's still just "a callable that takes a request," exactly like a function view.

⚠️ Straight talk, because the hype around CBVs oversells them: a class-based view that just defines one
`get()` method is *more* code than the function version, not less. CBVs pay off only when there's a
pattern worth inheriting, and they have a real cost - when logic lives in a parent class you didn't write,
reading a CBV can mean chasing methods up an inheritance chain. For genuinely custom, one-off logic, a
function view is usually clearer. Don't convert working function views to classes just because you can.

## Generic views - the patterns, pre-built

The CBV above is still doing its own ORM query and its own response-building. But "list all objects of a
model" is such a universal pattern that Django already wrote it for you. 📝 **Generic views are Django's
ready-made class-based views for the common cases** - `ListView`, `DetailView`, `CreateView`, `UpdateView`,
and `DeleteView`. You point one at a model, tell it which template to use, and you get a working
list/detail/create/edit/delete page with almost no code of your own.

Here's a list page and a detail page for `Post`, in their entirety:

```python
# blog/views.py
from django.views.generic import ListView, DetailView
from .models import Post

class PostListView(ListView):
    model = Post
    template_name = "blog/post_list.html"
    context_object_name = "posts"

class PostDetailView(DetailView):
    model = Post
    template_name = "blog/post_detail.html"
    context_object_name = "post"
```

*What just happened:* `ListView` already knows the whole shape - run `Post.objects.all()`, render a
template, and pass the results in. You only had to declare *which* model (`model = Post`), *which*
template, and what name the objects get inside that template (`context_object_name`). `DetailView` does
the same for a single object: it reads the `pk` (or slug) captured from the URL, fetches that one `Post`,
and 404s automatically if it doesn't exist - the `get_object_or_404` logic you wrote by hand in Phase 2 is
baked in. The URLconf uses `.as_view()` just like before:

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

urlpatterns = [
    path("posts/", views.PostListView.as_view(), name="post_list"),
    path("posts/<int:pk>/", views.PostDetailView.as_view(), name="post_detail"),
]
```

*What just happened:* `DetailView` expects the URL to capture the primary key as `pk` (the default name it
looks for), so the route is `posts/<int:pk>/`. With those two patterns and the two short classes above,
you have a fully working list-and-detail blog - including pagination support and not-found handling - 
without writing a single query or `render()` call.

💡 This is where CBVs earn their keep: the generic views collapse the repetitive CRUD scaffolding down to
a few declarative lines. `CreateView`, `UpdateView`, and `DeleteView` extend the same idea to forms - they
build the form from your model, validate submitted data, save it, and redirect, all from a handful of
attributes.

## When to use which

So you have three tools - function views, hand-written CBVs, and generic CBVs - and the natural question is
which one to reach for. The answer is refreshingly simple, and it's *not* "always use the newest one."

💡 **Use a function view when the logic is custom or one-off.** If a view does something unusual - a weird
multi-step flow, an odd combination of queries, logic that doesn't map cleanly onto "list/detail/create" - 
a plain function is the most readable thing you can write, everything right there in one body, top to
bottom, no inheritance to chase.

💡 **Use a generic CBV when the view is standard CRUD.** A plain list page, a plain detail page, a basic
create/edit/delete form over a model - exactly what `ListView` and friends exist for, and writing them as
functions is just re-typing what Django already gave you.

⚠️ The trap to avoid is cargo-culting: converting *every* view to a class because tutorials use them, or
forcing genuinely custom logic into a generic view by overriding six methods until it bends to your will.
At that point the generic view is fighting you, and a function view would have been clearer. Mix both
freely in the same project - pick per view based on how standard the work is, not on dogma.

## Django REST Framework - for building APIs

Everything so far renders **HTML** for a browser. But plenty of the time you're not building a web page at
all - you're building an **API**: endpoints that return **JSON** for a mobile app, a JavaScript front end,
or another service to consume. (If "API," "endpoint," and "JSON over HTTP" aren't second nature yet, read
[REST APIs explained](/guides/rest-apis-explained) alongside this section.)

Plain Django *can* return JSON with `JsonResponse`, but the moment you need validation, authentication,
permissions, and consistent error formats, you'd be rebuilding a lot of machinery. 📝 **Django REST
Framework (DRF) is the de-facto library for building JSON APIs on top of Django.** A separate package you
install (`pip install djangorestframework`) and add to `INSTALLED_APPS`, bringing four big pieces:

- **Serializers** - convert model instances to JSON and validate incoming JSON back into model data. This
  is the same role [Pydantic plays in FastAPI](/guides/fastapi-from-zero): the single place that defines
  what your API accepts and emits.
- **`APIView` / `ViewSet`** - DRF's request handlers, analogous to Django's views but speaking JSON and
  HTTP verbs natively.
- **Routers** - generate the URL patterns for a `ViewSet` automatically, so you don't hand-wire every route.
- **Auth, permissions, and the browsable API** - pluggable authentication and permission classes, plus a
  rendered HTML interface for exploring your API in a browser during development.

Let's expose `Post` as a JSON API. First, a serializer that says what a `Post` looks like over the wire:

```python
# blog/serializers.py
from rest_framework import serializers
from .models import Post

class PostSerializer(serializers.ModelSerializer):
    class Meta:
        model = Post
        fields = ["id", "title", "body", "created_at"]
```

*What just happened:* `ModelSerializer` is to APIs what `ModelForm` was to HTML forms in
[Phase 6](06-forms-and-validation.md) - it inspects the model and builds the field definitions for you.
You only listed which fields to expose. This one class now does both directions: turning a `Post` object
into `{"id": ..., "title": ..., ...}` JSON on the way out, *and* validating incoming JSON into clean data
on the way in - the single source of truth for the shape of your API.

Now a `ViewSet` that wires the serializer to CRUD operations, plus a router to build the URLs:

```python
# blog/views.py
from rest_framework import viewsets
from .models import Post
from .serializers import PostSerializer

class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
```

```python
# blog/urls.py
from rest_framework.routers import DefaultRouter
from .views import PostViewSet

router = DefaultRouter()
router.register(r"posts", PostViewSet, basename="post")

urlpatterns = router.urls
```

*What just happened:* `ModelViewSet` is the API cousin of the generic CRUD views - from just a `queryset`
and a `serializer_class`, it gives you the full set of endpoints: list all posts, retrieve one, create,
update, delete, each on the correct HTTP verb. `DefaultRouter` then *generates* the URL patterns for that
ViewSet automatically - you didn't write a `path()` per endpoint. A `GET` to `/posts/` now returns
something like:

```json
[
  {
    "id": 1,
    "title": "Hello, world",
    "body": "My first post.",
    "created_at": "2026-06-22T10:30:00Z"
  },
  {
    "id": 2,
    "title": "On generic views",
    "body": "They save real boilerplate.",
    "created_at": "2026-06-22T11:00:00Z"
  }
]
```

*What just happened:* DRF ran the queryset, passed each `Post` through `PostSerializer`, and rendered the
result as a JSON array - a complete, working REST endpoint from a serializer, a ViewSet, and a router. A
`POST` to the same URL with a JSON body runs that body *through* the serializer's validation and saves a
new `Post` if it's valid, or returns a structured error response if it isn't. Visit `/posts/` in a browser
during development and DRF even renders its browsable API: a clickable HTML view of the same endpoint.

## DRF vs FastAPI - the plain comparison

You may have noticed this looks a lot like [FastAPI](/guides/fastapi-from-zero), and that's a fair
observation - both turn typed/declared schemas into validated JSON endpoints. So which do you reach for? The
plain answer is that it's rarely a head-to-head fight; it's about *what else you need*.

💡 **Reach for DRF when you're already in Django.** If your project has the Django ORM, the admin, the auth
system, migrations, and a body of existing models - and now you also need a JSON API over that same data - 
DRF lets you reuse *all of it*. Your serializers wrap models you already have; your API endpoints sit
inside the project that already runs your site. This is an extremely common situation (a Django site that
grows a mobile app or a JS front end), which is why "Django + DRF" is one of the most in-demand stacks in
job listings.

💡 **Reach for FastAPI when you want a lean, API-first service** and you *don't* need the rest of Django.
If you're building a standalone JSON service - no server-rendered pages, no Django admin, maybe heavy
async I/O - FastAPI gives you validation and auto-generated docs with far less framework around it.

The decision, boiled down: **pick by whether you want the whole Django stack.** If the API is one feature
of a larger Django application, DRF is the natural fit. If the API *is* the whole application and you'd
otherwise be ignoring most of Django, FastAPI is the leaner choice.

## Recap

1. 📝 A **class-based view** is a view written as a class with one method per HTTP verb (`get()`, `post()`),
   wired into `urls.py` with `.as_view()`. The payoff is inheriting common patterns instead of repeating
   them.
2. ⚠️ CBVs aren't automatically better - a one-method CBV is more code than the function version, and logic
   in a parent class is harder to trace. Function views stay clearer for custom, one-off logic.
3. 📝 **Generic views** (`ListView`, `DetailView`, `CreateView`, `UpdateView`, `DeleteView`) are Django's
   pre-built CBVs for standard CRUD - declare a model and template and get a working page in a few lines.
4. 💡 Choose by the work: **function views for custom logic, generic CBVs for standard CRUD.** Don't
   cargo-cult classes onto everything.
5. 📝 **Django REST Framework** builds JSON APIs on Django: `ModelSerializer` (validation + JSON, like
   Pydantic's role), `ViewSet`/`ModelViewSet`, routers that generate URLs, plus auth, permissions, and a
   browsable API.
6. 💡 **DRF vs FastAPI:** use DRF when the API is part of a Django app and you want to reuse the ORM, admin,
   and auth; use FastAPI when you want a lean, standalone API service without the rest of Django.

You can now serve both halves of the modern web from Django: HTML pages, concisely, via generic views - and
JSON APIs, validated and routed, via DRF. The last phase ties the whole guide together: testing your views
and models, and structuring a Django project so it stays maintainable as it grows.

## Quick check

Three questions on the ideas that have to stick - what CBVs and generic views actually buy you, and where
DRF fits.

```quiz
[
  {
    "q": "What is the defining structural difference between a class-based view and a function view?",
    "choices": [
      "A CBV is a class with one method per HTTP verb (get, post), routed by Django based on the request method",
      "A CBV runs faster because Django compiles it ahead of time",
      "A CBV can return JSON while a function view can only return HTML",
      "A CBV does not need to be referenced in urls.py"
    ],
    "answer": 0,
    "explain": "A CBV organizes view logic as methods named after HTTP verbs (get, post, ...). Django inspects the request method and dispatches to the matching method. It's wired up with .as_view() in urls.py."
  },
  {
    "q": "You need a plain page that lists every Post and a page that shows one Post. What's the most appropriate choice?",
    "choices": [
      "Generic views: ListView and DetailView, each given a model and template",
      "A single function view with many if/else branches on request.method",
      "Django REST Framework, since any list of objects is an API",
      "Hand-written CBVs that re-implement the query and render() yourself"
    ],
    "answer": 0,
    "explain": "List-all and show-one are exactly the standard CRUD shapes ListView and DetailView were built for. You declare the model and template and get the query, rendering, and 404 handling for free - no reason to hand-write it or pull in DRF."
  },
  {
    "q": "When does Django REST Framework make more sense than FastAPI for building a JSON API?",
    "choices": [
      "When the API is part of a Django project and you want to reuse the existing ORM, admin, and auth",
      "Whenever the API must be asynchronous and handle heavy I/O",
      "When you want the absolute minimum framework around a standalone service",
      "Whenever you need automatically generated interactive documentation"
    ],
    "answer": 0,
    "explain": "DRF shines when you're already in Django and want your API to ride on the models, admin, and auth you already have. FastAPI is the leaner pick for a standalone, API-first service that wouldn't use the rest of Django."
  }
]
```


---

# Testing & Project Structure

You've built a working blog: models, an admin, forms, class-based views, an API. It runs. But "it runs when I click around" and "it keeps working after I change something" are two very different guarantees - and the gap between them is where production bugs live. This phase is about closing that gap, and about arranging your code so it stays workable as it grows.

Here's the mental model to hold first. A test is just code that runs *your* code and checks the answer - that's the whole idea, and [Your First Unit Test](/guides/your-first-unit-test) walks through the universal Arrange-Act-Assert shape if it's new to you. What makes testing a *Django* skill is everything Django does *around* your test so you don't have to: it spins up a throwaway database, loads your whole project, and gives you a fake browser that calls your views without a running server. You write the "check the answer" part; Django handles the messy setup.

## Django's test framework

📝 **`django.test.TestCase` is `unittest.TestCase` with a database safety net bolted on.** It's the ordinary Python testing base class you already know (methods named `test_*`, `self.assertEqual`, the works), but Django wraps each test in machinery that makes touching the database painless and *safe*.

Two things happen automatically, and they're the reason Django testing feels different from testing plain functions:

- **A separate test database.** When you run the test suite, Django creates a brand-new database (named `test_<yourdb>`), runs your migrations into it, and points your code at it for the duration. Your real development data is never touched. When the run finishes, the test database is destroyed.
- **A transaction around every test, rolled back at the end.** Each `test_*` method runs inside a database transaction that Django rolls back the moment the method returns. So a `Post` you create in one test does not exist in the next - every test starts from the same clean slate, no manual cleanup required.

Tests live in your app's `tests.py` (or a `tests/` package once there are many). Here's a real one against the `Post` model from [Phase 3](03-models-and-the-orm.md):

```python
from django.test import TestCase
from blog.models import Post

class PostModelTests(TestCase):
    def test_post_is_created_with_its_fields(self):
        post = Post.objects.create(title="Hello world", body="My first post.")
        self.assertEqual(post.title, "Hello world")
        self.assertIsNotNone(post.created)

    def test_str_returns_the_title(self):
        post = Post.objects.create(title="Readable", body="...")
        self.assertEqual(str(post), "Readable")
```

*What just happened:* two tests, each one Arrange-Act-Assert. The first creates a `Post` and checks its fields landed correctly (including that `created` got auto-stamped). The second pins down the `__str__` behavior you added in Phase 3 - a tiny test, but it locks in the contract that a post prints as its title, so a future refactor can't silently break the admin. Both tests called `Post.objects.create`, which hit a real database - the test database Django built and threw away for you. Run the whole suite with one command:

```bash
python manage.py test
```

```console
$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 0.012s

OK
Destroying test database for alias 'default'...
```

*What just happened:* the output bookends tell the whole story - `Creating test database` at the top, `Destroying test database` at the bottom, your two tests (the two dots) passing in between. You never created or cleaned a database yourself; Django did it around your tests. 💡 Because the test DB is automatic and disposable, testing database code in Django is genuinely *easy and safe* - no "test data polluting my real DB" worry to talk yourself out of writing the test.

## The test `Client`

Model tests check your data. But most of a web app's behavior lives in *views* - and you want to test those without booting a server, opening a browser, or making real HTTP calls. That's exactly what the test `Client` is for.

📝 **`self.client` is a fake browser that calls your views in-process.** Every `TestCase` hands you a `self.client` with methods like `.get()` and `.post()`. They take a URL, run it through your full URL routing and view logic *inside the test process* (no network, no live server), and hand back the response object - so you can assert on its status code, its rendered content, and where it redirected.

```python
from django.test import TestCase
from django.urls import reverse
from blog.models import Post

class PostViewTests(TestCase):
    def test_post_list_shows_published_posts(self):
        Post.objects.create(title="On the homepage", body="...")

        response = self.client.get(reverse("post_list"))

        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "On the homepage")

    def test_creating_a_post_requires_login(self):
        response = self.client.get(reverse("post_create"))

        # A login-required view bounces anonymous users to the login page.
        self.assertEqual(response.status_code, 302)
        self.assertIn("/login", response.url)
```

*What just happened:* two view tests, no server in sight. The first creates a post, then `self.client.get(...)` calls the list view and returns the response; `assertContains` checks the status is 200 *and* the post's title actually appears in the rendered HTML - testing routing, the view, and the template together. The second confirms a protected view (the `LoginRequiredMixin` create view from [Phase 9](09-class-based-views-and-drf.md)) redirects an anonymous visitor: status `302` and a `Location` pointing at the login page. `reverse("post_list")` instead of a hard-coded `"/posts/"` looks the URL up by name, so the test survives a URL-path change.

💡 Where does this sit on the testing pyramid? A model test that hits one method is a unit test; a `Client` test that exercises URL → view → template → database in one shot is closer to an **integration test**. [Unit, Integration, E2E](/guides/unit-integration-e2e) lays out the trade-offs (speed and isolation vs. realism).

## Test data & fixtures

Most tests need some data to exist first. Django gives you a few ways to arrange it, ordered from simplest to most efficient.

The plainest is `setUp` - a method that runs *before every test method*, so each test gets its own fresh objects:

```python
class CommentTests(TestCase):
    def setUp(self):
        self.post = Post.objects.create(title="Discussed", body="...")

    def test_comment_attaches_to_its_post(self):
        comment = self.post.comments.create(author="Sam", body="Nice!")
        self.assertEqual(comment.post, self.post)

    def test_a_post_starts_with_no_comments(self):
        self.assertEqual(self.post.comments.count(), 0)
```

*What just happened:* `setUp` created a `Post` before each test, and both tests reached it via `self.post`. Because the per-test transaction rolls back between them, the comment created in the first test is gone before the second runs - why `test_a_post_starts_with_no_comments` reliably sees zero. As your needs grow, wrap creation in small **factory-style** helpers (a `make_post(**overrides)` function, or the popular `factory_boy` library) so a test needing "a post with a comment" reads as one line instead of five.

⚠️ `setUp` runs again for *every single test*, which gets slow when the setup is heavy. The fix is **`setUpTestData`** - a classmethod that builds shared, read-only data **once for the whole test class**:

```python
class PostListTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.posts = [Post.objects.create(title=f"Post {i}", body="...") for i in range(3)]

    def test_all_three_show_up(self):
        response = self.client.get(reverse("post_list"))
        self.assertEqual(response.content.count(b"Post"), 3)
```

*What just happened:* `setUpTestData` created three posts a single time, and Django makes them visible (via a savepoint) to each test in the class while still rolling back any changes a test makes. For data you only *read*, this is markedly faster than recreating it in `setUp` per test. (Django also supports loading data from JSON/YAML **fixture** files via `fixtures = [...]`, but inline creation or factories are usually clearer.)

💡 Many Django teams run their tests with **`pytest-django`** instead of `manage.py test`. It keeps Django's test-database machinery but swaps in pytest's nicer style: plain `assert` statements, function-style tests, and powerful fixtures. The concepts carry over unchanged - only the spelling of the runner differs.

## Project structure that scales

A blog with one `tests.py` is fine. A real product grows features - accounts, payments, notifications - and the question becomes *where does all this code go?* Django's answer is one it's had since day one: organize by **app**.

📝 **An app is a self-contained Django feature: its own models, views, URLs, templates, and tests.** Your *project* is the thin outer shell - it owns settings and the root URL config and ties the apps together. Each app is a focused unit you can reason about (and test) on its own. A blog that's outgrown its single app might look like this:

```text
myblog/                  ← the project (repo root)
├── manage.py
├── myblog/              ← project package: the "shell"
│   ├── settings.py      ← configuration (or a settings/ package - see below)
│   ├── urls.py          ← root URL config: includes each app's urls
│   └── wsgi.py
├── blog/                ← app: posts & comments
│   ├── models.py
│   ├── views.py
│   ├── urls.py
│   └── tests.py
├── accounts/            ← app: signup, login, profiles
│   ├── models.py
│   ├── views.py
│   └── tests.py
└── api/                 ← app: the DRF endpoints
    ├── serializers.py
    ├── views.py
    └── tests.py
```

*What just happened:* the project package (`myblog/`) holds only the cross-cutting wiring - settings and the root `urls.py` that `include()`s each app's URLs. Everything feature-specific lives in its own app folder: `blog` knows about posts and comments, `accounts` knows about users, `api` knows about serializers. Each app carries its *own* `tests.py`, so a feature's code and its tests sit together. 💡 Apps are deliberately designed to be self-contained - a well-isolated app (think Django's own `auth`, or `django-allauth`) can be **reused across entirely different projects** by listing it in `INSTALLED_APPS`.

## Settings management

This is the one that ends careers if you get it wrong, so read it twice. Your `settings.py` holds all your configuration - and configuration includes **secrets**.

⚠️ **Never commit secrets to version control.** `settings.py` ships with a `SECRET_KEY` (Django uses it to sign sessions and password-reset tokens), and you'll add database passwords, API keys, and email credentials. The instant any land in a git commit, treat them as compromised - git history is forever, and public repos are scraped for keys within *minutes*. The fix is to read secrets from the **environment** at runtime instead of hard-coding them:

```python
import os

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = os.environ.get("DJANGO_DEBUG", "false").lower() == "true"
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.environ["DB_NAME"],
        "USER": os.environ["DB_USER"],
        "PASSWORD": os.environ["DB_PASSWORD"],
        "HOST": os.environ.get("DB_HOST", "localhost"),
    }
}
```

*What just happened:* not one secret value appears in the file - `SECRET_KEY`, the DB password, and friends are all pulled from environment variables. The committed code is now safe to push anywhere; the actual secrets live outside the repo (in your shell, a gitignored `.env` file, or your host's secret store). The popular `django-environ` package smooths this over (typed parsing, `.env` loading, a one-line `DATABASE_URL`), and the broader patterns are covered in [Secrets Management](/guides/secrets-management).

The other half of settings hygiene is that **dev and prod need different config**. Two common approaches:

- **Split settings into a package** - `settings/base.py` with everything shared, then `settings/dev.py` and `settings/prod.py` that each `from .base import *` and override what differs (database, allowed hosts, debug). You select one with `DJANGO_SETTINGS_MODULE=myblog.settings.prod`.
- **One settings file driven entirely by env vars** - a single `settings.py` whose every environment-specific value comes from the environment (as above). Same file everywhere; the *environment* differs.

⚠️ Whichever you pick, **`DEBUG` must be `False` in production.** With `DEBUG=True`, Django returns a full traceback page - complete with your source code, local variables, and settings - to anyone who triggers an error. That's a gift to an attacker, and turning it off is non-negotiable before you go live (why the example above defaults `DEBUG` to `false`).

💡 Step back and the shape of a healthy Django project is clear: **focused apps** so code stays reasoned-about and testable, an **automatic test database** so you actually write the tests, and **env-driven settings** so the same code runs safely from your laptop to production.

## Recap

1. **`django.test.TestCase` extends `unittest.TestCase`** and adds a safety net: it creates a *separate test database*, wraps each test in a transaction it *rolls back* afterward, and resets state - so DB tests are easy and safe and never touch your real data.
2. **The test `Client` (`self.client`) is an in-process fake browser**: `.get()`/`.post()` run a URL through your full routing and view logic with no server, returning a response you can assert on - status codes, content (`assertContains`), and login-required redirects (`302`).
3. **Arrange test data with `setUp` (per test), factory helpers, or `setUpTestData` (once per class)** for shared read-only data; `pytest-django` is a popular alternative runner that keeps the test DB machinery but uses plain `assert`.
4. **Structure by app**: each app (`blog`, `accounts`, `api`) is a self-contained feature with its own models/views/urls/tests; the project package holds only settings and root URLs. Well-isolated apps are even reusable across projects.
5. ⚠️ **Settings hold config *and* secrets - never commit `SECRET_KEY` or DB passwords.** Read them from the environment (env vars / `django-environ`) and split config per environment (base/dev/prod or env-driven).
6. ⚠️ **`DEBUG=True` must be off in production** - it leaks tracebacks, source, and settings to anyone who hits an error. A testable, well-structured Django app = focused apps + an automatic test DB + env-driven settings.

## Quick check

Three questions on the ideas that matter most before you ship in Phase 11:

```quiz
[
  {
    "q": "What does Django's TestCase do with the database when you run your tests?",
    "choices": [
      "Creates a separate test database, wraps each test in a transaction, and rolls it back after each test - leaving your real data untouched",
      "Runs the tests directly against your development database and deletes any rows the tests created at the end",
      "Refuses to let tests touch the database at all; you must mock every query",
      "Makes a one-time copy of your production database and runs tests against the copy"
    ],
    "answer": 0,
    "explain": "TestCase builds a throwaway test database (test_<yourdb>), runs each test method inside a transaction, and rolls that transaction back when the method returns. Each test starts clean and your real data is never affected - which is why DB tests in Django are safe and easy."
  },
  {
    "q": "You want to test that GET /posts/ returns 200 and shows a post's title, without starting a server. What do you use?",
    "choices": [
      "self.client.get(...) - the test Client calls the view in-process and returns the response to assert on",
      "requests.get('http://localhost:8000/posts/') after manually launching runserver in another terminal",
      "Selenium driving a real Chrome browser against a live deployment",
      "Reading views.py as a string and checking it mentions the word 'Post'"
    ],
    "answer": 0,
    "explain": "The test Client (self.client) runs the URL through your full routing and view logic inside the test process - no network, no live server. You then assert on the returned response's status_code and content (e.g. assertContains)."
  },
  {
    "q": "Which is a genuine production risk in Django settings?",
    "choices": [
      "Leaving DEBUG=True in production, because Django then serves full tracebacks with source code and settings to anyone who triggers an error",
      "Splitting settings into base/dev/prod files, because Django can only read a single settings.py",
      "Reading the SECRET_KEY from an environment variable, because Django requires it to be hard-coded",
      "Putting each feature in its own app, because apps cannot be reused across projects"
    ],
    "answer": 0,
    "explain": "DEBUG=True must be off in production: the debug error page exposes tracebacks, local variables, source, and settings - a serious information leak. Splitting settings, reading secrets from the environment, and one-app-per-feature are all good practices, not risks."
  }
]
```


---

# Production & Where to Go Next

Stop and look at the pile of things you can do now. Lay out a Django project and its apps, route a URL to a view, model your data and migrate it into real tables, query that data through the ORM without writing SQL, manage it all through the famous auto-generated admin, render it with templates, accept and validate user input through forms with CSRF protection baked in, dodge the N+1 trap, log people in with the built-in auth system, and stand up a clean API on top with class-based views and Django REST Framework. And you can prove the whole thing works with Django's test framework.

That's not a toy. That's a real, database-backed, authenticated web application - and the reason it's *yours* is that you understand the **MTV structure** holding it together. Every "magic" thing Django did - the admin appearing, auth "just working," migrations writing themselves - fell out of one idea: your **models drive everything**, and conventions fill in the rest.

So this last phase isn't another subsystem. It's getting the thing onto the internet, the one checklist that bites everyone, a clear-eyed map of the ecosystem, and a clear answer to "what do I build now?"

## Deploying it - gunicorn, nginx, and static files

📝 All this time you've run `python manage.py runserver`. Here's the plain truth nobody says loudly enough on day one: **`runserver` is a development server only.** It's single-process, not hardened, and not built for real traffic. The Django docs themselves tell you never to use it in production. So the first move is swapping it out.

In production you run Django through a real **WSGI server** like **gunicorn** (or **uvicorn** if you're using Django's async support), with multiple worker processes so requests run in parallel:

```bash
# Your Django project ships a wsgi.py - point gunicorn at it:
gunicorn myproject.wsgi:application --workers 4 --bind 0.0.0.0:8000
```

⚠️ You don't expose gunicorn straight to the world. In front of it goes a **reverse proxy** like **nginx**, which handles TLS, buffers slow clients, serves files efficiently, and forwards the rest to your workers.

That brings us to the thing that trips up *every* first Django deploy: **static files**. In development, `runserver` quietly serves your CSS, JS, and images for you. In production it does not - Django is not a file server. Gather every static file into one place and let something else serve it:

```bash
# Collect CSS/JS/images from every app into STATIC_ROOT:
python manage.py collectstatic
```

Then serve that folder one of two common ways: **WhiteNoise** (a small package that lets gunicorn serve static files directly - perfect for simple deploys) or a proper **CDN / object storage** for bigger sites. And put your data on a **managed Postgres** instance, not the SQLite file you've been developing against.

The cleanest way to ship all of this as one unit is **Docker**, so the same image runs on your laptop and your server. Two other guides cover this territory end to end: [Ship Your Side Project](/guides/ship-your-side-project) walks the full deployment path, and [Docker Without the Magic](/guides/docker-without-the-magic) makes the container part stop feeling like incantations.

## The deploy security checklist

⚠️ This is the section that bites everyone exactly once. Django's defaults are tuned for *development convenience*, and a few of them are dangerous on the open internet. Walk this list every single time before you go live:

- **`DEBUG = False`.** With `DEBUG = True`, Django shows a full stack trace - including settings and snippets of your code - to anyone who triggers an error. Leaving it on in production is the classic Django security leak.
- **Set `ALLOWED_HOSTS`.** List the real domain(s) your site answers on. Django refuses requests with a `Host` header that isn't on the list. (When `DEBUG` is `False`, an empty `ALLOWED_HOSTS` means your site serves *nothing*.)
- **Load `SECRET_KEY` from the environment.** Never commit it to your repo. Read it from an env var so the real key lives only on the server.
- **Turn on the HTTPS settings.** `SECURE_SSL_REDIRECT`, `SESSION_COOKIE_SECURE`, and `CSRF_COOKIE_SECURE` push everything onto HTTPS and stop cookies from leaking over plain HTTP.
- **Run the built-in auditor:** `python manage.py check --deploy`. Django inspects your settings and warns you about exactly these issues. It's the cheapest safety net you have - run it before every deploy.

💡 You don't have to *memorize* this list. You have to remember that `check --deploy` exists and run it. Django will tell you what you missed.

## The ecosystem - where to go from here

Django is the trunk; the ecosystem is a set of well-maintained branches you bolt on when you need them. Each one is "add a capability by adding a package," the same move you already made with DRF:

```mermaid
flowchart TD
  D[Your Django app] --> DRF[DRF - REST APIs]
  D --> CEL[Celery - background jobs]
  D --> CH[Channels - WebSockets / async]
  D --> CA[Redis cache - speed]
  D --> AA[django-allauth - social login]
  D --> WG[Wagtail - full CMS]
```

- **DRF** - building APIs (you met it in Phase 9).
- **Celery** - heavy, retryable, or scheduled **background tasks**, fronted by a broker like Redis.
- **Channels** - **WebSockets** and async, for live updates and real-time features.
- **caching** - drop **Redis** in front of expensive views and queries when you need speed.
- **django-allauth** - **social login** (Google, GitHub, etc.) without hand-rolling OAuth.
- **wagtail** - a polished **CMS** built on Django when you need editable content pages.

💡 The pattern never changes: find the well-maintained package, add it to your project, follow its setup. You don't rebuild these by hand - that's the whole point of batteries-included.

## A clear-eyed framework map - and what to build

Django is a sharp tool for a particular shape of problem. Knowing when *not* to reach for it is part of knowing it well:

- **Django** when you want a **full web app** - an admin, auth, server-rendered pages, a mature ORM, a thousand conventions - or a solid API on top via DRF. If you'd otherwise rebuild half of Django by hand, use Django.
- **[FastAPI](/guides/fastapi-from-zero)** when you want a **lean, async API** and you're happy to assemble the rest yourself.
- **Flask** when the job is genuinely **tiny** - a small service or a quick prototype where Django's machinery is more than you need.

As for what to build: take the **blog** you've grown across this whole guide and carry it all the way home. Add real authentication, lean on the admin to manage content, expose a **DRF API** over the posts, write a **test suite** that covers the important paths - then **deploy it** with `DEBUG = False`, `collectstatic` run, and `check --deploy` clean.

When you want the canonical reference, the **official Django documentation and tutorial** are genuinely excellent - thorough, example-driven, and maintained by the people who build the framework.

And remember the through-line: batteries-included was never magic. The admin, the auth, the migrations, the "it just appears" - every one of those came from the **model-driven conventions** you now understand from the inside. Go finish the blog, ship it, and send someone the link. You're ready.

## Recap

1. **You can build and ship a real Django app** - database-backed, admin-managed, authenticated, tested, with a DRF API - and you understand the MTV structure underneath every piece.
2. **`runserver` is dev-only.** In production, run Django through gunicorn (or uvicorn for async) with multiple workers, behind a reverse proxy like nginx, ideally packaged in Docker.
3. **Serve static files properly.** Django doesn't serve static files in production - run `collectstatic` and hand them to WhiteNoise or a CDN, and use a managed Postgres database.
4. **Run the security checklist every deploy:** `DEBUG = False`, set `ALLOWED_HOSTS`, `SECRET_KEY` from env, HTTPS + secure-cookie settings, and `python manage.py check --deploy`.
5. **Grow with the ecosystem** - DRF for APIs, Celery for background jobs, Channels for WebSockets, Redis for caching, django-allauth for social login, Wagtail for a CMS. Add a capability by adding a package.
6. **Pick the right tool and finish one thing** - Django for full apps, FastAPI for lean async APIs, Flask for tiny services. Carry the blog to a deployed, authenticated, tested app. The magic was the model-driven conventions all along.

## Quick check

Test yourself on the decisions that matter most as you leave this guide:

```quiz
[
  {
    "q": "Why shouldn't you use `python manage.py runserver` in production?",
    "choices": [
      "It's a single-process development server, not hardened or built for real traffic - use a WSGI server like gunicorn behind nginx",
      "It only works on Linux",
      "It disables the Django admin",
      "It was removed in recent Django versions"
    ],
    "answer": 0,
    "explain": "runserver is for development only. In production you run Django through gunicorn (or uvicorn for async) with multiple workers, behind a reverse proxy like nginx."
  },
  {
    "q": "Your deployed site loads but all the CSS and images are missing (404s). What's the most likely cause?",
    "choices": [
      "DEBUG is set to True",
      "You didn't run `collectstatic` and configure something (WhiteNoise or a CDN) to serve static files - Django doesn't serve them in production",
      "ALLOWED_HOSTS is empty",
      "You forgot to run migrations"
    ],
    "answer": 1,
    "explain": "runserver quietly serves static files in development; production does not. You must run collectstatic and serve STATIC_ROOT via WhiteNoise or a CDN."
  },
  {
    "q": "Which command audits your settings for common production security problems before you deploy?",
    "choices": [
      "python manage.py migrate",
      "python manage.py collectstatic",
      "python manage.py check --deploy",
      "python manage.py runserver --prod"
    ],
    "answer": 2,
    "explain": "`check --deploy` inspects your settings and warns about issues like DEBUG=True, a missing ALLOWED_HOSTS, and insecure cookie settings. Run it before every deploy."
  }
]
```
