# Flask From Zero

> Learn the Python micro-framework that teaches you what a framework's minimum really is: routing and views, Jinja2 templates, forms and request data, databases via Flask-SQLAlchemy, blueprints and the app-factory pattern, sessions and auth, building a JSON API, and testing and deployment. Small core, your choices on top.


---

# Flask From Zero

Flask is the **micro-framework**: where Django hands you a whole workshop and FastAPI hands you a sharp
API tool, Flask hands you a small, clean core - routing, request/response, and templates - and lets *you*
choose everything else (which database library, which auth, which form handling). That minimalism is its
whole personality. It's why Flask is wonderful for small apps, prototypes, and learning, and why so much
of the Python web world runs on it. And because the core is so small, Flask is the best framework for
*seeing what a web framework actually is* underneath the conveniences.

We build that mental model first the whole way: a Flask app is a router that maps URLs to functions, plus
a request object coming in and a response going out, plus Jinja templates for HTML - and everything else
is an **extension** you bolt on. Once you see that, Flask stops being "the small one" and becomes "the one
where nothing is hidden."

> 📝 This teaches the **framework**. It assumes you know **Python** - functions, decorators, classes
> ([Python From Zero](/guides/python-from-zero)). It pairs naturally with
> [What a Framework Even Is](/guides/what-a-framework-even-is), and it's illuminating to compare with
> [Django](/guides/django-from-zero) (batteries-included) and [FastAPI](/guides/fastapi-from-zero) (async APIs).
> Flask needs a dev server to run, so examples here are shown with the commands to run them yourself.

## How to read this

Read in order - it grows one small app (a notes app) from a single file to a structured, tested,
deployable project. Phases carry difficulty badges.

## The phases

**Part 1 - The small core (🟢 Basic)**
1. **[What Flask Is & Your First App](01-what-flask-is.md)** 🟢 - the micro-framework idea, `@app.route`, and a running app in a few lines.
2. **[Routing & Views](02-routing-and-views.md)** 🟢 - dynamic URLs, HTTP methods, the request object, and responses.
3. **[Templates with Jinja2](03-templates-with-jinja.md)** 🟡 - `render_template`, the Jinja language, inheritance, and auto-escaping.

**Part 2 - A real application (🟡 Intermediate → 🔴)**
4. **[Forms & Request Data](04-forms-and-request-data.md)** 🟡 - handling POST, `request.form`, validation, and CSRF.
5. **[Working with a Database](05-database-with-sqlalchemy.md)** 🟡 - Flask-SQLAlchemy, models, and CRUD - the extension model in action.
6. **[Blueprints & the App Factory](06-blueprints-and-app-factory.md)** 🔴 - structuring beyond one file, and the patterns real Flask apps use.
7. **[Sessions, Auth & Extensions](07-sessions-auth-and-extensions.md)** 🟡 - sessions, Flask-Login, and the extension ecosystem that keeps Flask small.

**Part 3 - APIs, testing & production (🟡 → 🟢)**
8. **[Building a JSON API with Flask](08-building-a-json-api.md)** 🟡 - `jsonify`, REST endpoints, and when to reach for FastAPI instead.
9. **[Testing & Production](09-testing-and-production.md)** 🟡 - the test client, pytest, and deploying with a real WSGI server.
10. **[Where to Go Next](10-where-to-go-next.md)** 🟢 - the extension landscape, Flask vs the field, and what to build.

> The throughline: Flask is a small core plus your chosen extensions. That makes it the clearest window
> into what every web framework is doing - and a joy for anything that doesn't need the whole workshop.


---

# What Flask Is & Your First App

You know [Python](/guides/python-from-zero). Now you want to put something on the web - say a small
**notes app**, where each note has a title and some content. You could reach for a big framework
that hands you a database, a login system, and an admin panel on day one. Or reach for the one that
hands you almost nothing on purpose, letting you add exactly the pieces you need: **Flask**.

Flask's whole personality comes from one decision: **ship a small core and stay out of your way.**
Routing, requests and responses, and HTML templating - that's the box. Databases, authentication,
forms? Those are *your* choices, added as extensions when you need them. The payoff is total
flexibility and a framework small enough to understand top to bottom. The price: more decisions.

## The micro-framework philosophy

📝 **Flask** - a *micro-framework*. "Micro" doesn't mean it's for toy projects; it means the
**core is small and unopinionated**. Flask gives you URL routing, a request/response system, and
the Jinja2 template engine. Everything beyond that - talking to a database, validating forms,
logging users in - you bolt on yourself, usually via a Flask *extension* you pick.

The contrast with its siblings in this library makes "micro" concrete:

- [**Django**](/guides/django-from-zero) is *batteries-included*: an ORM, migrations, an admin
  site, and auth all ship in one box, in exchange for learning Django's conventions.
- [**FastAPI**](/guides/fastapi-from-zero) is *API-first and async*: your type hints become
  validation and auto-generated docs.
- **Flask** is *minimal, assemble-your-own*: a tiny core you extend deliberately, piece by piece.

💡 The plain trade: fewer conventions to learn and total freedom to assemble, but more decisions
and more wiring by hand as the app grows. For a small notes app, a prototype, or learning how web
frameworks work, that's usually the right deal.

## What's under the hood

Flask isn't magic - it stands on two well-worn libraries, worth naming now so nothing feels like a
black box later.

📝 **Werkzeug** - the WSGI/HTTP toolkit Flask is built on. It handles the low-level web machinery:
parsing incoming requests, building responses, matching URLs to code. When Flask talks HTTP, it's
really Werkzeug doing the talking.

📝 **Jinja2** - Flask's template engine. It turns HTML files with `{{ placeholders }}` into finished
pages by filling in your data. (More on this when we render notes in a later phase.)

📝 **WSGI** (Web Server Gateway Interface) - the standard "handshake" between a Python web app and
the web server that runs it. Flask speaks WSGI through Werkzeug, so a production server can run your
app without knowing it's Flask.

## Your first app

One install gets you everything in that core:

```bash
pip install flask
```

*What just happened:* `pip` pulled down Flask and its dependencies, Werkzeug and Jinja2 included.

Now the smallest app that does something. Create a file called `app.py`:

```python
from flask import Flask

app = Flask(__name__)

@app.route("/")
def index():
    return "The notes app is alive"
```

*What just happened:* `app = Flask(__name__)` creates your application object - the thing the server
runs. (`__name__` tells Flask where your app lives, so it can find templates and static files later.)
`@app.route("/")` says "when someone requests the path `/`, run the function below." That
function - a **view** - returns a plain string, and Flask wraps it in a proper HTTP response for
you. No headers to set, no response object to build by hand.

Now run it. Flask ships a command-line tool for development:

```bash
flask run
```

```console
$ flask run
 * Serving Flask app 'app'
 * Debug mode: off
 * Running on http://127.0.0.1:5000 (Press CTRL+C to quit)
```

*What just happened:* `flask run` found your `app.py`, started a small dev server, and bound it to
`http://127.0.0.1:5000`. Open that URL in a browser, or hit it from the terminal:

```console
$ curl http://127.0.0.1:5000/
The notes app is alive
```

*What just happened:* the request for `/` matched your route, Flask called `index()`, and sent back
the string as the response body. A working web app in six lines of code.

⚠️ **The dev server is for development only.** It's convenient - auto-reloads on save, helpful
error pages - but not built for real traffic. Phase 9 covers running Flask in production behind a
proper WSGI server. (There's also `app.run()` callable from inside the script, but `flask run` is
the modern default; stick with it.)

## The decorator routing model

That `@app.route("/")` line is Flask's signature move.

📝 **Routing** - mapping a URL path to the function that should handle it. In Flask you do this with
the `@app.route("/path")` **decorator** placed directly above a view function. The decorator
*registers* the function with Flask's URL map; you never call the function yourself.

That's the whole mental model: **Flask** reads the URL and decides which of *your* functions to
call, not the other way around - the classic
[framework relationship](/guides/what-a-framework-even-is): *"don't call us, we'll call you."* You
fill in the blanks (the views); Flask calls them at the right moment. That's **inversion of
control**, and `@app.route` is how you hand Flask the blanks to fill.

Add a second route and the pattern repeats:

```python
@app.route("/")
def index():
    return "The notes app is alive"

@app.route("/about")
def about():
    return "A tiny app for keeping notes."
```

*What just happened:* two paths, two functions, two decorators. A request to `/` runs `index()`; a
request to `/about` runs `about()`, matched via the map those decorators built. Add a hundred routes
and it's the same idea a hundred times.

Here's the path every request takes:

```mermaid
flowchart LR
  R[HTTP request<br/>GET /about] --> F[Flask router<br/>matches the path]
  F --> V[view function<br/>about]
  V --> Resp[HTTP response<br/>the string]
```

*One idea:* a request comes in, Flask's router matches its path against the routes you registered,
calls the matching view, and turns whatever it returns into the response. Every Flask page you ever
build flows along that arrow.

## Why Flask

You've now seen what makes Flask *Flask*: a small core, built on Werkzeug and Jinja2, with
decorator routing on top.

💡 **The clear fit.** Flask shines for **small apps, prototypes, microservices, and learning.**
Its surface is so small that almost nothing is hidden - arguably the best framework for *seeing how
a web framework actually works*. No magic ORM or auto-generated admin obscuring the request-to-
response flow; just routes, views, and templates you can read end to end. (Reach for
[Django](/guides/django-from-zero) for batteries included on a full website, or
[FastAPI](/guides/fastapi-from-zero) for a typed, async JSON API.)

Next, we go deeper on the piece you just met - **routing and views** - so paths can carry data
(like a note's id), handle different HTTP methods, and return more than a bare string.

## Recap

1. **Flask is a micro-framework:** its small core is routing + request/response + Jinja2 templates.
   Databases, auth, and forms are extensions *you* choose and add.
2. The trade vs. siblings: **Django** is batteries-included (full websites), **FastAPI** is
   async/API-first (typed JSON APIs), **Flask** is minimal assemble-your-own (max flexibility, more
   decisions).
3. Under the hood, Flask is built on **Werkzeug** (WSGI/HTTP toolkit) and **Jinja2** (templates),
   and speaks **WSGI** - the standard contract between a Python app and the server that runs it.
4. A first app is tiny: `app = Flask(__name__)`, an `@app.route("/")` view returning a string, run
   with `flask run`. ⚠️ The dev server is for development only - never production.
5. **Decorator routing** is Flask's signature: `@app.route("/path")` maps a URL to a view function.
   Flask calls your view - *"don't call us, we'll call you"* - the inversion-of-control idea.
6. **Flask fits** small apps, prototypes, microservices, and learning - its small surface means
   little is hidden, so it's the clearest framework for seeing how web frameworks work.

## Quick check

Three questions on the ideas that have to stick - what "micro" means, how routing works, and what
Flask is built on:

```quiz
[
  {
    "q": "What does it mean that Flask is a 'micro-framework'?",
    "choices": [
      "Its core is small - routing, requests/responses, and templates - and you add things like a database or auth yourself via extensions",
      "It can only be used for tiny, throwaway projects",
      "It ships an ORM, admin panel, and auth in one box like Django",
      "It runs faster because it's compiled to machine code"
    ],
    "answer": 0,
    "explain": "'Micro' means the core is small and unopinionated, not that the apps must be small. Flask gives you routing, request/response, and Jinja2 templates; databases, auth, and forms are extensions you choose and add."
  },
  {
    "q": "What does the `@app.route(\"/about\")` decorator do?",
    "choices": [
      "Registers the function below it as the view that runs when a request comes in for the path /about",
      "Immediately calls the function and prints its return value",
      "Creates a new database table named 'about'",
      "Starts the development web server on the /about path"
    ],
    "answer": 0,
    "explain": "@app.route maps a URL path to a view function by registering it in Flask's URL map. You never call the view yourself - Flask matches the incoming URL and calls it for you. That's inversion of control."
  },
  {
    "q": "Which two libraries is Flask built on, and what does each handle?",
    "choices": [
      "Werkzeug (WSGI/HTTP machinery) and Jinja2 (HTML templates)",
      "Starlette (web) and Pydantic (validation)",
      "Django ORM (database) and SQLite (storage)",
      "Uvicorn (server) and asyncio (concurrency)"
    ],
    "answer": 0,
    "explain": "Flask stands on Werkzeug, which handles the low-level WSGI/HTTP work (parsing requests, building responses, matching URLs), and Jinja2, its template engine. Starlette + Pydantic is FastAPI's stack, not Flask's."
  }
]
```


---

# Routing & Views

In Phase 1 you saw the headline move: `@app.route` maps a URL to a function, and whatever it returns becomes
the page. This phase puts meat on that skeleton - how you handle `/notes/7` when the `7` changes every
request, how the *same* URL does one thing on a `GET` and another on a `POST`, where submitted data lives,
and what your options are for what to hand back.

The mental model: **a Flask view is a function that receives a request and returns a response.** That's the
entire job. The decorator decides *which* requests reach your function; inside, you read what you need off
the request, do some work, and return something Flask can turn into an HTTP response.

## Dynamic URLs - capturing part of the path

A route like `/notes` lists every note. But `/notes/7` should show *one* note - the one with id `7`. That `7`
changes per request, so you can't bake it into the route string. Mark it as a **variable segment** with angle
brackets, then receive it as an argument to the view.

```python
@app.route("/notes/<int:note_id>")
def note_detail(note_id):
    return f"You asked for note #{note_id}"
```

*What just happened:* `<int:note_id>` is a placeholder. Flask matches `/notes/7`, pulls out the `7`, and
passes it to `note_detail` as the `note_id` argument - the name after the colon must match the parameter
exactly. `int:` is a **converter**: it tells Flask "this segment is an integer," so `note_id` arrives as the
number `7`, not the string `"7"`, and `/notes/banana` doesn't match this route at all (a 404 instead of
garbage).

The built-in converters you'll reach for:

| Converter | Matches | Example |
|-----------|---------|---------|
| `<string:x>` | any text without a slash (**the default**) | `/notes/<title>` |
| `<int:x>` | a whole number, given to you as an `int` | `/notes/<int:note_id>` |
| `<path:x>` | text *including* slashes | `/files/<path:subpath>` |

⚠️ If you write `<note_id>` with **no converter**, you get `string` by default - a classic source of "why is
my id a string?" bugs. When a segment is a numeric id, say so: `<int:note_id>`.

## HTTP methods - same URL, different verbs

By default a route only answers `GET` requests (the verb a browser uses to *fetch* a page). But creating a
note is a `POST` - the verb for *submitting* data. The REST idea that the URL names the resource and the
method is the verb you apply to it (see [What a Framework Even Is](/guides/what-a-framework-even-is)) shows
up here directly: `/notes` is the collection, and one route can handle both "show me the notes" (`GET`) and
"add a note" (`POST`).

```python
from flask import request

notes = []  # stand-in storage until Phase 5 brings a real database

@app.route("/notes", methods=["GET", "POST"])
def notes_collection():
    if request.method == "POST":
        notes.append(request.form["title"])
        return f"Added a note. Now have {len(notes)}.", 201
    return "Your notes: " + ", ".join(notes)
```

*What just happened:* `methods=["GET", "POST"]` tells Flask this view answers *both* verbs - without it, a
`POST` to `/notes` would be rejected with a `405 Method Not Allowed`. `request.method` tells you *which* verb
you got, so you branch: `POST` reads the submitted title and creates a note; `GET` lists what's there. This
**create-on-POST, list-on-GET** pattern on a single collection URL is the bread and butter of web apps.
(`notes = []` is throwaway in-memory storage so the example runs - real persistence arrives in Phase 5.)

💡 The `201` in `return ..., 201` is the HTTP status code for "Created" - more on status codes in a moment.

## The request object - where the incoming data lives

📝 **`flask.request`** is how your view reads everything about the incoming request - a single object Flask
hands you (technically per-request, but you import it once and use it anywhere inside a view). The parts
you'll use constantly:

| Attribute | Holds | From |
|-----------|-------|------|
| `request.args` | query-string values | `?q=...&sort=...` |
| `request.form` | submitted form fields | an HTML form's `POST` body |
| `request.json` | a parsed JSON body | an API client's `POST` |
| `request.method` | the HTTP verb | `GET`, `POST`, ... |
| `request.headers` | request headers | `User-Agent`, `Authorization`, ... |

Reading a **query parameter** (the part after `?` in the URL) - handy for search and filtering:

```python
@app.route("/search")
def search():
    term = request.args.get("q", "")
    matches = [n for n in notes if term.lower() in n.lower()]
    return f"Notes matching '{term}': {matches}"
```

*What just happened:* a request to `/search?q=milk` lands here, and `request.args.get("q", "")` pulls `milk`
out of the query string. Using `.get("q", "")` instead of `request.args["q"]` means a missing `?q=` gives you
the empty-string default rather than a `400 Bad Request` - the forgiving way to read optional values.

And reading a **form field** from a submitted body:

```python
@app.route("/notes/new", methods=["POST"])
def create_note():
    title = request.form.get("title", "Untitled")
    notes.append(title)
    return f"Created: {title}", 201
```

*What just happened:* when an HTML form `POST`s to `/notes/new`, the field named `title` shows up in
`request.form`, read here with a fallback. ⚠️ `request.args` and `request.form` are *different* buckets:
`args` is the query string, `form` is the request body - reading from the wrong one is a common "why is this
empty?" moment. We'll build and validate real HTML forms in [Forms & Request Data](04-forms-and-request-data.md).

## Returning responses - your options for what to hand back

A view's return value becomes the HTTP response, and Flask is flexible about what it accepts:

```python
from flask import jsonify, redirect, url_for, abort

@app.route("/notes/<int:note_id>")
def note_detail(note_id):
    if note_id >= len(notes):
        abort(404)                              # 1. bail out with an error page
    return f"<h1>{notes[note_id]}</h1>"         # 2. a string → HTML body

@app.route("/api/notes")
def api_notes():
    return jsonify(notes)                       # 3. Python data → JSON response

@app.route("/notes/<int:note_id>/delete", methods=["POST"])
def delete_note(note_id):
    del notes[note_id]
    return redirect(url_for("api_notes"))       # 4. send the browser elsewhere
```

*What just happened:* four ways to respond, all from plain `return`:

1. **`abort(404)`** stops the view immediately and makes Flask return its standard `404 Not Found` page (any
   status works - `abort(403)`, etc.) - the clean way to say "this doesn't exist."
2. **A string** becomes the response body, served as HTML - exactly what you saw in Phase 1.
3. **`jsonify(notes)`** turns Python data (a list, a dict) into a proper JSON response with the right
   `Content-Type` header - the seed of the JSON API in [Building a JSON API](08-building-a-json-api.md).
4. **`redirect(url_for("api_notes"))`** sends the browser to another URL - the standard move after a
   successful `POST` so a refresh doesn't re-submit.

You can also return a **tuple** to set the status code: `return "Created", 201`. Body first, status second.

📝 Notice `url_for("api_notes")` in that redirect. **`url_for` builds a URL from the *view function's name*,
not a hardcoded path.** Pass it `"api_notes"` and Flask returns `/api/notes`. For routes with variable
segments, pass the values as keyword arguments: `url_for("note_detail", note_id=7)` gives you `/notes/7`.

⚠️ Don't hardcode URLs like `redirect("/api/notes")`. Change the route string to `/v2/notes` and every
hardcoded path silently breaks, while `url_for("api_notes")` keeps working because it's tied to the function,
not the path text.

## The flow - and why views stay thin

💡 The whole cycle never changes: **Flask matches the incoming URL to a view function, hands that function the
request, and sends back whatever the function returns.** Match → call → respond - the same request/response
loop every framework runs, laid bare here (the point made in
[What a Framework Even Is](/guides/what-a-framework-even-is)). Routing, the request object, and response
helpers are the three sides of that one loop.

Because the view *is* that seam, ⚠️ **keep it thin.** A good view does three things and stops: parse what it
needs from the request, call a function that does the actual work, and return a response. The business
logic - how a note is validated, how it's saved, how search actually ranks - belongs in plain functions or
modules you call *from* the view, not stuffed inside it. Database access especially lives elsewhere (that's
[Working with a Database](05-database-with-sqlalchemy.md)).

```python
# thin view: parse → delegate → respond
@app.route("/notes/new", methods=["POST"])
def create_note():
    title = request.form.get("title", "")
    note = add_note(title)                 # the real work lives in add_note()
    return redirect(url_for("note_detail", note_id=note.id))
```

*What just happened:* the view reads one field, calls `add_note` (a regular function that owns the logic),
and redirects - readable, and `add_note` stays testable on its own without faking a web request. Right now
your views return raw HTML strings, which gets ugly fast - next phase we hand that job to **templates**.

## Recap

1. **Dynamic URLs** use angle brackets: `<int:note_id>` captures a path segment and passes it to the view.
   Converters (`int`, `string`, `path`) type and constrain the match - ⚠️ no converter means `string` by
   default, so numeric ids arrive as text unless you write `<int:...>`.
2. **HTTP methods** are declared with `methods=["GET", "POST"]`; branch on `request.method` to do "list on
   GET, create on POST" from a single collection URL.
3. **`flask.request`** carries the incoming data: `request.args` (query string), `request.form` (form body),
   `request.json`, `request.headers`, `request.method`. Use `.get(key, default)` for optional values.
4. **Responses** come from `return`: a string (HTML), a `(body, status)` tuple, `jsonify(...)` for JSON,
   `redirect(url_for(...))` to send the browser elsewhere, or `abort(404)` to bail out with an error.
5. 📝 **`url_for("view_name")`** builds URLs from the view function's name - ⚠️ never hardcode paths, or
   they break the moment a route changes.
6. 💡 The whole framework core is one loop: **match the URL → call the view with the request → return a
   response.** Keep views thin - parse, delegate to real functions, respond.

## Quick check

Make sure the request → response cycle stuck:

```quiz
[
  {
    "q": "You write `@app.route(\"/notes/<note_id>\")` (no converter) and request `/notes/7`. What is `note_id` inside the view?",
    "choices": [
      "The string \"7\", because with no converter the default is `string`",
      "The integer 7, because Flask detects it's numeric",
      "A 404 error, because the route doesn't match",
      "None, because you forgot the `int:` converter"
    ],
    "answer": 0,
    "explain": "With no converter, Flask uses `string` by default, so `note_id` is the text \"7\". Write `<int:note_id>` to receive a real integer."
  },
  {
    "q": "A user submits an HTML form with a field named `title` via POST. Where do you read it?",
    "choices": [
      "`request.form[\"title\"]` (or `.get`) - form fields live in the request body",
      "`request.args[\"title\"]` - all submitted values live in args",
      "`request.headers[\"title\"]`",
      "`request.method[\"title\"]`"
    ],
    "answer": 0,
    "explain": "Submitted form fields live in `request.form` (the request body). `request.args` is the URL query string (`?q=...`) - a different bucket."
  },
  {
    "q": "Why is `redirect(url_for(\"api_notes\"))` better than `redirect(\"/api/notes\")`?",
    "choices": [
      "`url_for` builds the URL from the view function's name, so it keeps working if you change the route's path string",
      "`url_for` is faster because it skips URL parsing",
      "Hardcoded paths cause a 404 in production but work in development",
      "There's no difference; both are equally fine"
    ],
    "answer": 0,
    "explain": "`url_for` ties the URL to the view function, not the literal path. Change the route to `/v2/notes` and `url_for` still resolves correctly, while a hardcoded `/api/notes` silently breaks."
  }
]
```


---

# Templates with Jinja2

In Phase 2 your views returned HTML by hand - strings like `f"<h1>{notes[note_id]}</h1>"`. That works for one
line, but falls apart the moment a note page needs a real `<head>`, a nav bar, a loop, and a footer. Stuffing
that into a Python f-string is how you end up with unreadable views and HTML you can't see the shape of.
This phase hands that job to **Jinja2**, the template engine that ships inside Flask.

Hold this mental model: **a view's job is to gather data; a template's job is to turn that data into HTML.**
The view talks to your data (the `Note` objects), bundles up what it found, and passes it to a template that
knows how to lay it out. The view never builds HTML by hand; the template never reaches into the database.
Keeping those jobs apart is why the same list of notes can render as a web page today and (in
[Building a JSON API](08-building-a-json-api.md)) as JSON tomorrow without rewriting your data logic.

## Why templates - getting HTML out of your views

📝 You don't import Jinja2 or wire it up. It's built into Flask, and you reach it through one function:
`render_template`. You give it a template filename and the data it needs; Flask finds the file, runs it with
your data available inside, and returns a finished response full of HTML.

```python
from flask import Flask, render_template

app = Flask(__name__)

notes = [
    {"id": 1, "title": "Buy milk", "content": "2 liters, oat"},
    {"id": 2, "title": "Call dentist", "content": "reschedule cleaning"},
]

@app.route("/notes")
def notes_list():
    return render_template("notes.html", notes=notes)
```

*What just happened:* the view fetched its data (here a throwaway list - a real database arrives in
[Working with a Database](05-database-with-sqlalchemy.md)) and handed it to `render_template("notes.html",
notes=notes)`. Flask finds `notes.html`, runs it with `notes` available inside, and returns the rendered HTML.
The view contains **zero HTML** - it only decides *what* to show, not *how* it looks.

📝 Where does `notes.html` live? In a folder named `templates/` next to your app file. Flask looks there
automatically - you don't configure the path:

```
your-app/
  app.py
  templates/
    notes.html
    note_detail.html
    base.html
```

## The Jinja language

A Jinja template is mostly plain HTML with three special markers sprinkled in - the entire language:

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

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

```html
<h1>Your Notes</h1>

{% if notes %}
  <ul>
  {% for note in notes %}
    <li>
      <a href="{{ url_for('note_detail', note_id=note.id) }}">{{ note.title }}</a>
 - {{ note.content }}
    </li>
  {% endfor %}
  </ul>
{% else %}
  <p>No notes yet. Add your first one.</p>
{% endif %}
```

*What just happened:* `{% for note in notes %}` walks the list, and for each one `{{ note.title }}` prints the
title while `{{ note.content }}` prints the body. `{% if notes %}` / `{% else %}` shows a friendly message
when the list is empty, and `{{ url_for('note_detail', note_id=note.id) }}` builds the link by the view
function's *name* - the same `url_for` from Phase 2 - instead of hardcoding `/notes/1`, so a route change
propagates automatically.

⚠️ Jinja is **deliberately limited** - you can't call arbitrary Python, run a database query, or do heavy
computation from inside a template. That's a feature: real logic belongs in the **view**, where it's visible
and testable. Fighting the template to compute something is a sign the work belongs in the view instead.

## Context - what the template can see

📝 The keyword arguments you pass to `render_template` have a name: the **context**. It is the *entire* world
the template can see - if a name isn't in the context, the template doesn't have it at all, and there's no
reaching back into the view or the database for more.

```python
@app.route("/notes")
def notes_list():
    return render_template(
        "notes.html",
        notes=notes,
        page_title="My Notebook",
    )
```

*What just happened:* the view passed two things into the context - `notes` and `page_title`. Inside
`notes.html`, both `{{ notes }}` and `{{ page_title }}` are now available, *and nothing else from the view is*.
Rename the keyword (`notes=` becomes `items=`) and `{{ notes }}` silently goes blank, because the template's
name must match the key you passed. That tight boundary is what makes templates predictable: to know what a
template can use, you only have to read the context.

## Template inheritance - write the layout once

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

```html
<!-- templates/base.html -->
<!DOCTYPE html>
<html>
<head>
  <title>{% block title %}Notebook{% endblock %}</title>
</head>
<body>
  <nav><a href="{{ url_for('notes_list') }}">All notes</a></nav>

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

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

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

{% block title %}Your Notes - Notebook{% endblock %}

{% block content %}
  <h1>Your Notes</h1>
  <ul>
  {% for note in notes %}
    <li>{{ note.title }}</li>
  {% endfor %}
  </ul>
{% endblock %}
```

*What just happened:* `base.html` lays out the page once and marks two spots - `{% block title %}` and
`{% block content %}` - as overridable holes. The child's `{% extends "base.html" %}` says "start from that
skeleton," then its own `{% block %}` tags pour content into the matching holes, never repeating the `<nav>`,
`<footer>`, or `<head>`.

💡 This is the **DRY** win for server-rendered HTML: one base template, many children, zero duplicated chrome.
Change the footer in `base.html` and every page that extends it updates at once. Add a third page later and
you write only its `{% block content %}`, getting the whole shell for free.

## Auto-escaping - the XSS shield you get for free

By default, **Jinja auto-escapes every variable it outputs.** If a note's `content` contains
`<script>alert('xss')</script>`, Jinja 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 note content:  Nice list! <script>steal()</script>
Rendered to page:      Nice list! &lt;script&gt;steal()&lt;/script&gt;
```

*What just happened:* a malicious note body went *into* the template via `{{ note.content }}`, but
auto-escaping defanged it on the way *out*. The visitor 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 trust-the-input family as SQL
injection; for the full picture read [SQL Injection & XSS](/guides/sql-injection-and-xss).

⚠️ The escape hatch is the `|safe` filter, which tells Jinja "trust this, render it raw," turning the shield
**off** for that value. Only reach for it on content *you* generated or have already sanitized - never on
anything a user typed. `{{ note.content|safe }}` on a user-submitted note is exactly how an XSS hole gets
created. When in doubt, leave it escaped.

💡 Templates are the surface the user actually sees. So far data has flowed one direction: storage → view →
template → browser. Next we reverse it: **forms** are how data flows back *in*, from the user to your app.

## Recap

1. **`render_template("notes.html", notes=notes)`** is how a view returns HTML: it finds the file in
   `templates/`, runs it with your data, and returns the response. Jinja2 ships inside Flask - no setup.
2. The **Jinja language** has three shapes: `{{ value }}` to output, `{% tag %}` for logic (`{% for %}`,
   `{% if %}`), and `{{ value|filter }}` to transform. ⚠️ It's deliberately limited - real logic stays in the
   view. `url_for` works in templates, so build links by view name, not hardcoded paths.
3. The **context** is the keywords you pass to `render_template`, and it's the template's *entire* world. Only
   names you pass are visible inside; the name in the template must match the key.
4. **Template inheritance** - `{% block %}` holes in `base.html`, `{% extends "base.html" %}` in children - 
   gives you shared layout with zero duplication. 💡 The DRY win for server-rendered HTML.
5. 💡 Jinja **auto-escapes** variables by default, blocking XSS for free. ⚠️ `|safe` turns that off - only use
   it on content you trust, never on user input.

## Quick check

Make sure the data → HTML handoff stuck:

```quiz
[
  {
    "q": "Your view calls `render_template(\"notes.html\", notes=notes)`. Inside the template, what is available?",
    "choices": [
      "Only `{{ notes }}` - the context is exactly what you pass, nothing more",
      "Every variable defined in the view function",
      "`notes` plus anything in the global Python scope",
      "Nothing, because templates can't receive Python data"
    ],
    "answer": 0,
    "explain": "The context is the keywords you pass to render_template. Only `notes` is available; the template can't reach back into the view for anything else."
  },
  {
    "q": "A note's content contains `<script>steal()</script>`. You render it with `{{ note.content }}`. What does the visitor's browser do?",
    "choices": [
      "Runs the script - XSS succeeds",
      "Prints the literal text harmlessly because Jinja auto-escapes it",
      "Strips the tag silently and shows nothing",
      "Throws a template error and 500s"
    ],
    "answer": 1,
    "explain": "Jinja auto-escapes by default, converting the angle brackets to entities so the browser prints the text instead of executing it. Adding `|safe` would disable this and reopen the XSS hole."
  },
  {
    "q": "What does `{% extends \"base.html\" %}` at the top of a child template do?",
    "choices": [
      "Imports Python functions from base.html into the child",
      "Copies base.html's HTML inline before rendering",
      "Tells Jinja to start from base.html's skeleton and fill its `{% block %}` holes with the child's blocks",
      "Runs base.html as a separate request first"
    ],
    "answer": 2,
    "explain": "`{% extends %}` makes the child inherit base.html's layout; the child's `{% block %}` tags pour content into the matching holes, so shared chrome (nav, footer, head) lives in one place."
  }
]
```


---

# Forms & Request Data

Up to now your notes app has *shown* data. This phase is where it starts *taking it in* - a real form where
a person types a note title and hits **Save**. That single act touches more of Flask than anything so far: an
HTML form, a `POST` handler, validation, a redirect, a confirmation message, and - the part everyone forgets
until it bites them - protecting the form from being submitted by a site that isn't yours.

The mental model: **a form submission is just a `POST` request whose body is a bag of named fields.** The
browser packs up the form's inputs and ships them in the request body; your view reads them out of
`request.form`. Flask's tiny core gives you exactly that and stops - no form library, no validation, no CSRF.
Everything richer is something you *add*. We'll start bare-hands, then layer on the extension that does the
tedious parts for you.

## Reading a raw form - the bare-hands version

📝 An HTML form is two things: a `<form>` that says *where* and *how* to submit, and inputs that carry named
values. Here's the create-note form:

```html
<form method="post" action="/notes/new">
  <label>Title <input type="text" name="title"></label>
  <button type="submit">Save</button>
</form>
```

*What just happened:* `method="post"` tells the browser to send a `POST` (the verb for submitting data, from
[Routing & Views](02-routing-and-views.md)), and `action="/notes/new"` is the URL it submits to. The `name`
attribute is load-bearing - `name="title"` is the key your view reads by. No `name`, no value in the request
body - the single most common "my field is missing" cause.

On the server, the view reads those fields off `request.form`:

```python
from flask import request

@app.route("/notes/new", methods=["POST"])
def create_note():
    title = request.form["title"]
    notes.append(title)
    return f"Saved: {title}", 201
```

*What just happened:* the browser's `POST` lands here, and `request.form["title"]` pulls the value of the
input named `title` out of the request body. `request.form` behaves like a dict - `request.form["title"]`
raises a `400 Bad Request` if the key is missing, while `.get("title")` returns `None` instead. That's the
*whole* of Flask's form handling: a dict of submitted fields, no validation, no escaping, no protection.

## POST, then redirect, then GET

⚠️ There's a bug hiding in that `return f"Saved: {title}", 201`. The user submits, sees "Saved," and then - 
out of habit - hits **refresh**. The browser re-sends the *last request*, the `POST`, so it submits the note
*again*. And again, every refresh. Duplicate notes, and a confused user.

The fix is a discipline with a name: **POST/redirect/GET**. After a successful `POST`, don't render a page - 
**redirect** the browser to a normal page with `redirect(url_for(...))`.

```python
from flask import request, redirect, url_for

@app.route("/notes/new", methods=["POST"])
def create_note():
    title = request.form["title"]
    notes.append(title)
    return redirect(url_for("notes_collection"))  # send them to the list page
```

*What just happened:* instead of returning HTML from the `POST`, the view returns a `302` redirect to the
notes list, and the browser makes a *fresh `GET`* to that page - the last request sitting in the browser is
now harmless, so refreshing re-fetches the list instead of re-submitting the form. `url_for` builds the
target from the view function's name (never hardcode the path - see Phase 2). Make this your reflex: **any
successful `POST` ends in a redirect, not a rendered page.**

## Validate, then flash a message

Right now `create_note` trusts whatever arrives, but an empty title is garbage and the user deserves to know
their note saved. Both need a small addition: a validation check and a **flash message** - a one-time note
that survives the redirect and shows up on the *next* page.

```python
from flask import request, redirect, url_for, flash

app.secret_key = "dev-only-change-me"  # required for flashing (it signs the session cookie)

@app.route("/notes/new", methods=["POST"])
def create_note():
    title = request.form.get("title", "").strip()
    if not title:
        flash("Title can't be empty.")
        return redirect(url_for("notes_collection"))
    notes.append(title)
    flash("Note saved.")
    return redirect(url_for("notes_collection"))
```

*What just happened:* `request.form.get("title", "").strip()` reads the field forgivingly and trims
whitespace, so a box of spaces counts as empty. If it's blank, we `flash` an error and redirect *back*
without saving; otherwise we save and `flash` a success message, which stays in the session and appears
exactly once on the next request. ⚠️ Flashing needs `app.secret_key` set - flashed messages ride in the
signed session cookie, and without a key Flask raises an error. Use a real random secret in production.

The messages don't show themselves - your template pulls them out with `get_flashed_messages()`:

```html
{% for message in get_flashed_messages() %}
  <p class="flash">{{ message }}</p>
{% endfor %}
```

*What just happened:* `get_flashed_messages()` returns the queued messages and *clears* them in the same
move, so a refresh afterward won't show them again - that's what "one-time" means. Put this loop in your base
template (from [Templates with Jinja2](03-templates-with-jinja.md)) so every page can surface a flash;
`{{ message }}` is auto-escaped by Jinja, so a flash built from user input is safe to render.

## Flask-WTF - the extension way

That hand-rolled validation works for one field. Imagine instead a form with a title, a body, a category, and
a "required / max length / must be one of these" rule on each - the `if not this and not that` pile grows
fast, and you're re-implementing the same checks every project. 📝 This is the moment Flask's philosophy says
reach for an **extension**. **Flask-WTF** (a thin wrapper over the WTForms library) gives you form
*classes* - declare your fields and validators once, and it handles parsing, validation, and re-rendering.

```python
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired, Length

class NoteForm(FlaskForm):
    title = StringField("Title", validators=[DataRequired(), Length(max=120)])
    submit = SubmitField("Save")

@app.route("/notes/new", methods=["GET", "POST"])
def create_note():
    form = NoteForm()
    if form.validate_on_submit():
        notes.append(form.title.data)
        flash("Note saved.")
        return redirect(url_for("notes_collection"))
    return render_template("new_note.html", form=form)
```

*What just happened:* `NoteForm` declares the form as a class - each field names its type (`StringField`) and
its rules (`DataRequired`, `Length(max=120)`). The view's one decision is `form.validate_on_submit()`, which
returns `True` only when the request is a `POST` **and** every validator passes. On success you read clean
data off `form.title.data` and follow the same POST/redirect/GET you already know. On a `GET` or a *failed*
`POST` it's `False`, so you re-render the template and WTForms hands the form back with per-field errors
already attached - no manual `if not title` checks; the validators *are* the rules.

In the template you let the form render itself, errors and all:

```html
<form method="post">
  {{ form.csrf_token }}
  {{ form.title.label }} {{ form.title() }}
  {% for error in form.title.errors %}<span class="error">{{ error }}</span>{% endfor %}
  {{ form.submit() }}
</form>
```

*What just happened:* `{{ form.title() }}` renders the `<input>`, `{{ form.title.label }}` its label, and the
loop prints any validation errors WTForms attached to that field. `{{ form.csrf_token }}` is the piece we
explain next, and it's the reason Flask-WTF is worth adopting even for small forms.

💡 The practical rule of thumb: raw `request.form` is fine for a trivial, one-off field where you fully control
the input. For anything you'd call a *real* form - multiple fields, validation rules, anything submitted
repeatedly - reach for Flask-WTF. You'll write less code *and* get CSRF for free.

## CSRF - and why it's not optional for writes

📝 ⚠️ **CSRF (Cross-Site Request Forgery)** is an attack where a malicious site silently makes a logged-in
user's browser submit a request to *your* app - the browser attaches your session cookie to any request to
your domain, even one triggered from `evil.com`. A hidden form on the attacker's page that `POST`s to
`/notes/new` (or worse, `/account/delete`) fires with your identity attached. The fix is a secret the
attacker can't know: Flask-WTF embeds a **per-session CSRF token** (`{{ form.csrf_token }}`) and rejects any
`POST` whose token is missing or wrong - a forged form can't include a token it never saw.

This is the same family of bug as the injection holes in
[SQL Injection & XSS, Explained](/guides/sql-injection-and-xss): an action gets *trusted* that shouldn't be - 
there, untrusted input treated as code; here, an untrusted request treated as the user's intent. The cure
rhymes: don't trust input you can't verify.

⚠️ Raw `request.form` has **no CSRF protection at all** - the hand-rolled `create_note` from earlier in this
phase will happily accept a forged cross-site `POST`. That's the strongest argument for Flask-WTF: use a
`FlaskForm` and render `{{ form.csrf_token }}`, and `validate_on_submit()` checks the token automatically.
(Set `app.secret_key` - the same one flashing needs - because that's what signs the token.)

💡 The takeaway: **anything that writes - creates, edits, deletes - must be validated *and* CSRF-protected.**
Read-only `GET`s are exempt, but the moment a request mutates data, both guards apply. Flask-WTF gives you
both in one move, which is why it's the default choice for forms that matter.

## Recap

1. 📝 **A form submission is a `POST` whose body is named fields.** Read them with `request.form["title"]`
   (400 if missing) or `request.form.get("title")` (returns `None`). The HTML input's `name` is the key.
2. ⚠️ **POST/redirect/GET:** after a successful `POST`, return `redirect(url_for(...))` - never a rendered
   page - so a refresh re-fetches a page instead of re-submitting the form.
3. **Validate and flash:** check the input yourself (`if not title.strip()`), and use `flash("Note saved.")`
   + `get_flashed_messages()` in the template for one-time messages. Flashing requires `app.secret_key`.
4. 📝 **Flask-WTF** is the extension for real forms: a `FlaskForm` class declares fields + validators, and
   `form.validate_on_submit()` parses, validates, and (on failure) re-renders with per-field errors.
5. 📝 ⚠️ **CSRF** lets a malicious site submit your form using a logged-in user's cookie; Flask-WTF blocks it
   with a per-session token (`{{ form.csrf_token }}`) it validates automatically. Raw `request.form` has none.
6. 💡 Raw `request.form` for trivial input; Flask-WTF for anything real. **Validate *and* CSRF-protect
   anything that writes.**

Next we stop appending to a throwaway list and give those notes a real home: a database.

## Quick check

Make sure the form-handling essentials stuck:

```quiz
[
  {
    "q": "Why redirect after a successful POST instead of rendering a page directly?",
    "choices": [
      "So a browser refresh re-fetches a page (a GET) instead of re-submitting the form",
      "Because Flask forbids returning HTML from a POST handler",
      "Redirects are faster than rendering a template",
      "It's the only way to set a 201 status code"
    ],
    "answer": 0,
    "explain": "POST/redirect/GET: redirecting makes the last request a harmless GET, so refreshing re-fetches the page rather than re-submitting the form and creating duplicates."
  },
  {
    "q": "What does `form.validate_on_submit()` return for the very first GET request to the form's URL?",
    "choices": [
      "False, because it's True only on a POST where all validators pass",
      "True, so you can pre-fill the form",
      "None, because there's no data yet",
      "It raises an error on GET requests"
    ],
    "answer": 0,
    "explain": "`validate_on_submit()` is True only when the request is a POST and every validator passes. On a GET (first visit) it's False, so you fall through and render the empty form."
  },
  {
    "q": "Why does a CSRF token stop a forged cross-site form submission?",
    "choices": [
      "The attacker's page can't include a per-session token it never saw, so the POST is rejected",
      "It encrypts the form data so the attacker can't read it",
      "It blocks all requests that come from a different domain",
      "It logs the user out whenever a foreign request arrives"
    ],
    "answer": 0,
    "explain": "Flask-WTF embeds a secret per-session token in the form and checks it on POST. A forged form on another site can't know that token, so its submission fails validation."
  }
]
```


---

# Working with a Database

Until now our notes have lived in a Python list, so they evaporate the instant you restart the server. That was fine for learning request handling, but no real app remembers its data by holding it in a variable. We need to *persist* - write notes somewhere that survives a restart, a crash, a deploy - and this phase teaches Flask to talk to a database.

📝 **Flask has no built-in database layer.** Unlike Django, which ships its own ORM as part of the framework, Flask's core knows nothing about SQL, tables, or rows - deliberately. Flask is a small core plus whatever you choose to bolt on. Want a database? You *add* an extension. This phase is the clearest, most concrete look you'll get at that philosophy: "Flask = small core + chosen extensions," played out with the most popular choice, Flask-SQLAlchemy.

## The extension philosophy, made concrete

📝 An **ORM** (Object-Relational Mapper) maps your objects to database rows and back. You work with a `Note` object in Python; the ORM figures out the `INSERT`, `SELECT`, and `UPDATE` statements that move it in and out of a table. If that idea is new, the [Hibernate & JPA guide](/guides/hibernate-and-jpa-from-zero) walks through the object-vs-table "impedance mismatch" in depth (Java, but the concept is identical), and [what a database actually is](/guides/what-a-database-is) covers the tables-rows-columns foundation underneath it.

Python's premier ORM is **SQLAlchemy** - a standalone library that works with or without Flask. **Flask-SQLAlchemy** is a thin *integration layer*: it doesn't reinvent the ORM, it wires SQLAlchemy into Flask's app and request lifecycle so the two cooperate cleanly. SQLAlchemy does the heavy lifting; Flask-SQLAlchemy just makes it feel native to Flask.

```mermaid
flowchart LR
  V[Your view function] --> FS[Flask-SQLAlchemy<br/>integration glue]
  FS --> SA[SQLAlchemy<br/>the actual ORM]
  SA --> DB[(Database<br/>SQLite file)]
```

*What just happened:* the diagram is the whole stack. Your view talks to Flask-SQLAlchemy, the glue; the glue hands off to SQLAlchemy, the real ORM; SQLAlchemy generates SQL and runs it against the database. Flask itself isn't in this chain - it gained database powers purely by *adding an extension*.

First, install it (this pulls SQLAlchemy in as a dependency):

```console
$ pip install Flask-SQLAlchemy
```

*What just happened:* one package, and your Flask app can now speak to a database. Nothing about Flask's core changed - you extended it from the outside.

## Setup: wiring the extension into the app

The extension needs two things: where the database lives, and a handle (`db`) you'll use everywhere to talk to it.

```python
from flask import Flask
from flask_sqlalchemy import SQLAlchemy

app = Flask(__name__)

# Where the database lives. SQLite = a single file on disk, perfect for learning.
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///notes.db"

# Create the extension and bind it to this app.
db = SQLAlchemy(app)
```

*What just happened:* the config line is a **connection URI** - `sqlite:///notes.db` means "use SQLite, stored in a file called `notes.db` next to the app." (Swap that string for a `postgresql://...` URI later and almost nothing else changes.) `db = SQLAlchemy(app)` creates the extension and hands it the app so it can hook into Flask's lifecycle: a database **session per request** - a fresh workspace that opens when a request arrives and closes when the response goes out. You don't manage that plumbing; the extension does.

💡 That `db` object is your gateway to everything - defining models, querying, saving. By convention it lives at module level so the rest of your app can import it.

## Defining a model

A model is a Python class that describes one table. Our notes app needs exactly one: a `Note` with an id, a title, and some content.

```python
class Note(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(120), nullable=False)
    content = db.Column(db.Text, nullable=False)

    def __repr__(self):
        return f"<Note {self.id}: {self.title}>"
```

*What just happened:* by inheriting from `db.Model`, `Note` becomes a mapped entity - SQLAlchemy now knows this class corresponds to a table. Each `db.Column` is one column: `id` is an auto-incrementing integer **primary key**, `title` is a short string capped at 120 characters, and `content` is `Text` for longer, unbounded writing. `nullable=False` means the database refuses to store a note missing that field. `__repr__` is just for readable debugging.

That class *implies* a table. The `CREATE TABLE` SQLAlchemy generates for it looks like this:

```sql
CREATE TABLE note (
    id INTEGER NOT NULL PRIMARY KEY,
    title VARCHAR(120) NOT NULL,
    content TEXT NOT NULL
);
```

*What just happened:* this is the table your `Note` class describes, written in the database's own language. The mapping is one-to-one: class → table (`note`), attribute → column, `db.String(120)` → `VARCHAR(120)`, `nullable=False` → `NOT NULL`. You never write this SQL by hand. To actually create the table:

```python
with app.app_context():
    db.create_all()
```

*What just happened:* `db.create_all()` looks at every model you've defined and creates any tables that don't yet exist. It runs inside an **app context** because the extension needs to know *which* app's database to build against - a detail that matters once you have more than one app, covered next phase.

## CRUD: the four things you do to data

CRUD is Create, Read, Update, Delete - the four operations every data-backed app performs. With the model in place, each one is a few lines, and the SQL stays invisible. Let's wire them into the note views from [Phase 4](04-forms-and-request-data.md), where notes used to go into a list.

**Create** - make an object, add it to the session, commit:

```python
@app.route("/notes", methods=["POST"])
def create_note():
    note = Note(title=request.form["title"], content=request.form["content"])
    db.session.add(note)      # stage it in the session
    db.session.commit()       # write it to the database for real
    return redirect(url_for("list_notes"))
```

*What just happened:* you build a plain `Note` object from the submitted form data, then `db.session.add(note)` *stages* it - pending, not yet saved. `db.session.commit()` is the moment it actually hits the database (SQLAlchemy generates the `INSERT`). After commit, `note.id` is populated automatically. The redirect-after-POST pattern from Phase 4 still applies.

**Read** - fetch all notes, or one:

```python
@app.route("/notes")
def list_notes():
    notes = Note.query.all()                     # SELECT * FROM note
    return render_template("notes.html", notes=notes)

@app.route("/notes/<int:note_id>")
def show_note(note_id):
    note = Note.query.get_or_404(note_id)        # fetch by PK, or 404
    return render_template("note.html", note=note)
```

*What just happened:* `Note.query` is your query entry point. `.all()` runs a `SELECT` and returns every row as a list of `Note` objects - the ORM's whole promise. `.get_or_404(note_id)` looks up a single note by primary key and, if none exists, raises a 404 instead of returning `None` and letting a later line crash. Need to filter on a non-key column? `Note.query.filter_by(title="Groceries").all()` runs a `SELECT ... WHERE title = ?`.

**Update and Delete** - both go through the session and finish with a commit:

```python
# Update: change the object, then commit.
note = Note.query.get_or_404(note_id)
note.title = "Updated title"
db.session.commit()           # SQLAlchemy notices the change and runs UPDATE

# Delete: remove from the session, then commit.
db.session.delete(note)
db.session.commit()           # runs DELETE
```

*What just happened:* for an update you don't call a "save" method - you just *mutate the object* and commit. SQLAlchemy tracks which loaded objects changed and generates the `UPDATE` for exactly those. Delete is symmetric with add: `db.session.delete(note)` stages the removal, and `commit()` makes it real. The session is the common thread through all four operations.

## The session, migrations, and the gotchas worth knowing now

📝 **`db.session` is the unit of work.** Think of it as a notepad of pending changes - adds, deletes, and modifications to loaded objects. Nothing touches the real database until you `commit()`. This is the single most important habit:

⚠️ **No commit, no persistence.** Forget `db.session.commit()` and your `add` quietly does nothing durable - the request ends, the session is discarded, and your note is gone. The mirror-image habit is `db.session.rollback()`: if something goes wrong mid-request, roll back to throw away the half-finished pending changes so the next request starts clean. Commit on success; roll back on error.

⚠️ **`create_all()` only creates *missing* tables - it never alters existing ones.** Add a `created_at` column to `Note` and `create_all()` will see the already-existing `note` table and do nothing. Schema changes to a live table are handled by **Flask-Migrate** (a Flask wrapper around Alembic, SQLAlchemy's migration tool), which generates and versions the `ALTER TABLE` steps. `create_all()` is a fine starting line; it's not how you change a schema later.

⚠️ **The N+1 trap is still here.** The moment your `Note` grows a relationship - say each note belongs to a notebook - looping over notes and touching `note.notebook` can fire one extra query *per note*. One query becomes N+1, and your list page slows to a crawl as data grows. It's the most common ORM performance bug across every language; the [why is my query slow](/guides/why-is-my-query-slow) guide unpacks how to spot and fix it.

💡 Your notes app *persists* now - restart the server and they're still there. You got there by **adding an extension** and letting it integrate cleanly: Flask's defining philosophy, worked end to end. That same pattern - small core, chosen extensions - is what the next phase scales up.

## Recap

1. **Flask ships no ORM** - unlike Django, you add one. This is the purest example of "Flask = small core + chosen extensions."
2. **Flask-SQLAlchemy is integration glue** over SQLAlchemy (Python's real ORM); it wires a per-request session into Flask's lifecycle so the ORM feels native.
3. **A model is a class** inheriting `db.Model`, with `db.Column` attributes mapping one-to-one to table columns; `db.create_all()` builds the tables.
4. **CRUD flows through `db.session`**: `add` + `commit` to create, `Note.query.all()` / `get_or_404()` / `filter_by()` to read, mutate-then-commit to update, `delete` + `commit` to remove.
5. ⚠️ **No commit, no persistence** - `db.session` holds pending changes until you `commit()`; `rollback()` on error. And `create_all()` never alters existing tables - schema changes need Flask-Migrate (Alembic).
6. ⚠️ The **N+1 query trap** applies the moment you add relationships - the same ORM gotcha you'd hit in any language.

## Quick check

Three questions on the ideas that have to stick before Phase 6:

```quiz
[
  {
    "q": "Why does Flask need Flask-SQLAlchemy at all, instead of having a database layer built in?",
    "choices": [
      "Flask's core ships no ORM by design - it's a small core, and you add database support as an extension",
      "Flask has a built-in ORM but it's deprecated, so you replace it with Flask-SQLAlchemy",
      "Flask-SQLAlchemy is required to run any Flask app, database or not",
      "Flask can only talk to SQLite, so the extension adds support for other databases"
    ],
    "answer": 0,
    "explain": "Unlike Django, Flask deliberately ships no ORM. It's a small core plus chosen extensions; Flask-SQLAlchemy is the extension you add to gain database support, integrating the standalone SQLAlchemy ORM into Flask's lifecycle."
  },
  {
    "q": "You call db.session.add(note) in a view but never call commit(). What happens to the note?",
    "choices": [
      "Nothing durable - the change stays pending in the session and is discarded when the request ends, so the note is not saved",
      "It is saved immediately, because add() writes to the database right away",
      "Flask raises an error at the end of the request forcing you to commit",
      "It is saved, but only to memory, and reloaded automatically next startup"
    ],
    "answer": 0,
    "explain": "db.session is the unit of work: add() only stages a pending change. Nothing hits the database until commit(). Without it, the session is discarded at request's end and the note is lost - commit on success, rollback on error."
  },
  {
    "q": "You add a new column to your Note model and run db.create_all() again. The column doesn't appear. Why?",
    "choices": [
      "create_all() only creates tables that don't already exist - it never alters an existing table; schema changes need a migration tool like Flask-Migrate",
      "You forgot to commit the session after create_all()",
      "create_all() can only be run once per database, ever",
      "SQLite doesn't support adding columns, so you must switch to PostgreSQL"
    ],
    "answer": 0,
    "explain": "create_all() builds only missing tables. The note table already exists, so it does nothing. Evolving an existing schema (adding columns, etc.) is handled by Flask-Migrate (Alembic), which generates versioned ALTER TABLE steps."
  }
]
```


---

# Blueprints & the App Factory

Your notes app works. It persists, it does CRUD, it renders templates - and it all lives in one `app.py` that's quietly getting longer every phase. Routes, models, config, and the `db` object are all piling into a single module, and the day you add login, then tags, then an API, it becomes a 600-line scroll where everything imports everything and you're afraid to touch any of it.

📝 **A growing Flask app is organized by two ideas: blueprints split your routes into modules, and an app factory builds the app inside a function instead of at the top of a file.** Neither is exotic - they're the structure essentially every non-trivial Flask app converges on, and what lets Flask grow past the toy stage without collapsing under its own imports.

## The one-file problem

⚠️ A real app outgrows a single `app.py`. Think about what's accumulated in yours: the `Flask(__name__)` instance, the `db` config and setup, the `Note` model, every `@app.route`, the form handling. That's tolerable at five routes. At fifty - spread across notes, auth, tags, and an API - it's a single file where unrelated features sit shoulder to shoulder, and where the `app` object created at the top gets imported by everything below it.

That last detail is the real trap, and we'll come back to it. Flask's answer is two patterns: **blueprints** carve the routes into modules, and the **app factory** turns the app's creation into a function.

## Blueprints: a mini-app you register

📝 **A Blueprint is a group of related routes (and their templates and static files) that you define separately, then *register* onto the app.** Think of it as a self-contained module of the application - a `notes` blueprint, an `auth` blueprint, a `tags` blueprint - each a little bundle of views that knows nothing about the others. You build them in isolation and plug them into the app at the end.

The key shift: instead of decorating routes with `@app.route`, you decorate them with `@<blueprint>.route`. The blueprint collects the routes; the app doesn't even exist yet at this point in the file.

```python
# app/notes/routes.py
from flask import Blueprint, render_template, request, redirect, url_for
from app.models import Note, db

# Create the blueprint: a name, the import name, and an optional URL prefix.
notes_bp = Blueprint("notes", __name__, url_prefix="/notes")

@notes_bp.route("/")
def list_notes():
    notes = Note.query.all()
    return render_template("notes.html", notes=notes)

@notes_bp.route("/", methods=["POST"])
def create_note():
    note = Note(title=request.form["title"], content=request.form["content"])
    db.session.add(note)
    db.session.commit()
    return redirect(url_for("notes.list_notes"))
```

*What just happened:* `Blueprint("notes", __name__, url_prefix="/notes")` creates a route group named `"notes"`, and every route on it gets `/notes` prepended - so `@notes_bp.route("/")` is really `/notes/`. The routes look exactly like ones you already know, except they hang off `notes_bp` instead of `app`. Notice `url_for("notes.list_notes")` is now *namespaced* - a feature: two blueprints can each have a `list` view without colliding, because they're `notes.list` and `auth.list`.

A blueprint on its own does nothing - it's a definition sitting in a file. It only becomes live routes when the app registers it:

```python
app.register_blueprint(notes_bp)
```

*What just happened:* `register_blueprint` is the moment the blueprint's routes get copied onto the real app, prefix and all. Before this line, `notes_bp` is inert; after it, `GET /notes/` actually resolves to `list_notes`. You'll call `register_blueprint` once per blueprint, all in one place - inside the factory.

💡 If you've read the [Django guide](/guides/django-from-zero), this will feel familiar: a blueprint is Flask's rough equivalent of a Django **app** - a focused, self-contained slice of features. Django apps are a framework convention with batteries attached; a blueprint is just a lightweight grouping you opt into. Same goal, much thinner mechanism.

## The app factory pattern

So blueprints handle the routes. But there's still that `app = Flask(__name__)` sitting at module level, created the instant the file is imported. The app factory changes that.

📝 **Instead of a module-level `app`, you write a `create_app()` function that builds the app, configures it, wires up extensions and blueprints, and returns it.** The app is no longer a global that springs into existence on import - it's something you *construct on demand* by calling a function.

```python
# app/__init__.py
from flask import Flask
from app.models import db

def create_app(config_object="config.DevConfig"):
    app = Flask(__name__)
    app.config.from_object(config_object)   # load settings (see below)

    db.init_app(app)                        # bind the extension to THIS app

    from app.notes.routes import notes_bp   # import here, on purpose
    app.register_blueprint(notes_bp)

    return app
```

*What just happened:* everything that used to live at the top of `app.py` now happens *inside a function*. `create_app` makes a fresh `Flask` instance, loads its config from an object, binds the database extension to that specific app with `db.init_app(app)`, registers the blueprints, and hands the finished app back. Nothing runs at import time - it runs when you *call* `create_app()`.

Two concrete payoffs. First, **you can build different apps from the same code.** Your tests can call `create_app("config.TestConfig")` for an app pointed at a throwaway database, while production calls `create_app("config.ProdConfig")` - same factory, different config, no globals to monkey-patch. Second, **no import-time side effects.** A module-level `app = Flask(...)` *does work the moment anyone imports the file*, which makes it surprisingly hard to test and reason about. The factory defers all of that until you ask for it.

💡 To run it, your entry point just calls the factory: `app = create_app()`. In development, `flask --app "app:create_app" run` calls the factory for you.

## The circular-import trap (and why the factory helps)

Now the payoff for that "import here, on purpose" comment - this is THE classic Flask structure bug, the one that bites nearly everyone the first time they split a file.

⚠️ Picture the naive version. `app.py` creates `app` and `db`, then imports your models so the routes can use them. But `models.py` needs `db` to define `Note(db.Model)` - so it imports `db` from `app.py`. Now `app.py` imports `models.py` and `models.py` imports `app.py`: a **circular import**. Python starts loading one, hits the import of the other, loops back to the first before it's finished defining `db`, and you get `ImportError: cannot import name 'db'` from a module that clearly defines `db`.

The factory pattern dissolves this. The trick is two-step: **create the extension object at module level, but *bind* it to an app inside the factory.**

```python
# app/models.py
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()                 # created here, NOT bound to any app yet

class Note(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(120), nullable=False)
    content = db.Column(db.Text, nullable=False)
```

*What just happened:* `db = SQLAlchemy()` with **no app argument** creates the extension in a detached state - it exists, models can inherit from `db.Model`, but it isn't tied to any particular Flask app. This is the linchpin: `models.py` now imports only from `flask_sqlalchemy`, never from your app module, so there's no cycle to form. The binding happens later, in the factory, with `db.init_app(app)` - the line that finally says "this `db` belongs to *this* app."

The blueprint gets imported in `create_app` *inside* the function, not at the top of the file. That import only runs when the factory runs - well after `db` and the app are set up - so the routes can safely import `db` without tripping the cycle. 💡 The rule of thumb: extension objects live at module level (unbound), models import only the extension, and the factory does the binding and the blueprint imports.

## Config & the application context

The factory loaded config with `app.config.from_object(...)`. 📝 **`app.config` is a dictionary of every setting your app needs** - the database URI, the secret key, debug flags - and the clean way to fill it is from a *config class*, one per environment:

```python
# config.py
import os

class Config:
    SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only-change-me")

class DevConfig(Config):
    SQLALCHEMY_DATABASE_URI = "sqlite:///notes.db"
    DEBUG = True

class ProdConfig(Config):
    SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"]
    DEBUG = False
```

*What just happened:* a base `Config` holds shared settings, and `DevConfig`/`ProdConfig` inherit and override what differs. The factory picks one by name, so switching environments is a one-argument change - and secrets like `SECRET_KEY` and the production database URL come from environment variables, never hard-coded into the repo. (Non-negotiable; the [Secrets Management](/guides/secrets-management) guide explains why a committed secret is a compromised secret.)

📝 **The application context is how your code finds "the current app" without importing it.** Once you have multiple possible apps (dev, test, prod) built by a factory, there's no single global `app` to reach for - so Flask, during each request, makes the active app available through two objects: `current_app` (the app handling this request) and `g` (a scratchpad for per-request data). That's why Phase 5's `db.create_all()` had to run inside `with app.app_context():` - it needed to know *which* app's database to build.

Put it all together and a grown-up Flask project has a predictable shape:

```text
notes/                       ← project root
├── config.py                ← Config classes (Dev / Prod / Test)
├── run.py                   ← entry point: app = create_app()
├── app/
│   ├── __init__.py          ← create_app() lives here (the factory)
│   ├── models.py            ← db = SQLAlchemy() + the Note model
│   ├── notes/
│   │   └── routes.py        ← notes_bp blueprint + its views
│   ├── auth/
│   │   └── routes.py        ← auth_bp blueprint (Phase 7)
│   └── templates/           ← Jinja templates
└── tests/                   ← create_app("config.TestConfig")
```

*What just happened:* the responsibilities are now physically separated. `config.py` holds settings, `app/__init__.py` is the factory that assembles everything, `models.py` owns the database, and each feature (`notes`, `auth`) is a blueprint in its own folder with its own routes. `tests/` builds its own app against a test config. Every file here has one job, and you can open `notes/routes.py` knowing it's *only* about notes.

💡 Blueprints + the app factory are the structure every serious Flask app uses - not because a framework forces it, but because it's what makes a Flask app *scale*. The same small-core-plus-extensions philosophy that added a database in Phase 5 lets you grow the app itself: modular pieces, assembled on demand, with no global app and no circular imports.

## Recap

1. ⚠️ **One `app.py` doesn't scale** - routes, models, config, and a module-level `app` piling into a single file becomes unworkable (and import-fragile) as features grow. Blueprints + the app factory are the fix.
2. **A blueprint is a modular group of routes** you define with `@blueprint.route`, then `app.register_blueprint(...)` to make live. `url_for` becomes namespaced (`notes.list_notes`). It's Flask's lightweight equivalent of a Django app.
3. **The app factory** is a `create_app(config)` function that builds, configures, and returns the app - instead of a module-level global. It lets you create per-environment apps (test vs prod) and avoids import-time side effects.
4. ⚠️ **The circular-import trap** (models import `app`, `app` imports models) is broken by creating extensions unbound at module level (`db = SQLAlchemy()`), binding inside the factory (`db.init_app(app)`), and importing blueprints *inside* `create_app`.
5. **Config comes from classes** (`app.config.from_object("config.DevConfig")`) with secrets pulled from the environment; the **application context** (`current_app`, `g`) is how code finds the current app without importing a global - which is why `app.app_context()` exists.

## Quick check

Three questions on the ideas that have to stick before Phase 7:

```quiz
[
  {
    "q": "What is a Flask blueprint?",
    "choices": [
      "A modular group of related routes (and templates/static) you define separately, then register onto the app with app.register_blueprint()",
      "A configuration file that stores the app's secret key and database URL",
      "A replacement for the database model that describes table structure",
      "A built-in Flask feature that automatically generates an admin interface"
    ],
    "answer": 0,
    "explain": "A blueprint is a self-contained bundle of routes (plus its templates and static files) defined with @blueprint.route. It does nothing until app.register_blueprint() copies its routes onto the real app. It's Flask's lightweight equivalent of a Django app."
  },
  {
    "q": "Why use an app factory (a create_app() function) instead of a module-level app = Flask(__name__)?",
    "choices": [
      "It lets you build apps with different config (e.g. test vs prod) from the same code and avoids import-time side effects",
      "It makes the app run faster because Flask caches the global instance",
      "It is required by Flask - apps won't start without a factory function",
      "It automatically encrypts the SECRET_KEY before the app starts"
    ],
    "answer": 0,
    "explain": "A factory builds the app on demand, so tests can call create_app('config.TestConfig') and prod can call create_app('config.ProdConfig') from identical code. It also defers all setup until called, eliminating the import-time side effects a module-level app causes."
  },
  {
    "q": "What breaks the classic Flask circular-import trap between app.py and models.py?",
    "choices": [
      "Creating the extension unbound at module level (db = SQLAlchemy()) and binding it to the app inside the factory with db.init_app(app)",
      "Importing models at the very top of app.py before anything else runs",
      "Putting the Note model and all the routes back into a single app.py file",
      "Renaming the db variable to something unique in each module"
    ],
    "answer": 0,
    "explain": "The cycle forms when models import app and app imports models. The fix: create db = SQLAlchemy() unbound in models.py (which then imports only flask_sqlalchemy, no app), and bind it inside create_app() via db.init_app(app). Importing blueprints inside the factory keeps the cycle from forming too."
  }
]
```


---

# Sessions, Auth & Extensions

Right now anyone who can reach your notes app can create notes. There's no "logged in," no "this is *my* note" - the whole concept of a user doesn't exist yet. This phase fixes that the Flask way: a tiny built-in piece (the session) plus an extension you bolt on (Flask-Login).

The mental model: **logging a user in is just remembering, across requests, that this browser belongs to a known person.** HTTP forgets you between requests - that's the bare problem the [Servlet sessions guide](/guides/the-servlet-api) walks through from the ground up. Flask's answer is the trick every framework uses: an id rides along in a cookie, and the server reads it back on the next request. Flask wraps that in an object called `session`.

## The Flask `session`

📝 **`flask.session` is a dict-like object backed by a signed cookie.** You write to it like a dictionary; Flask serializes those values, signs them, and ships them to the browser as a cookie. On the next request the browser sends the cookie back, Flask verifies the signature, and `session` is repopulated. Data you put in it persists across requests *for that one user* - which is exactly what "stay logged in" needs.

It needs one thing to work: a secret key, because that's what the signature is made with.

```python
from flask import Flask, session, redirect, url_for

app = Flask(__name__)
app.secret_key = "change-me-to-a-long-random-value"  # signs the session cookie

@app.route("/visit")
def visit():
    # Read with a default, increment, write back.
    session["visits"] = session.get("visits", 0) + 1
    return f"You've visited {session['visits']} times."
```

*What just happened:* `session` behaves like a normal dict - `session.get("visits", 0)` reads a value (defaulting to 0 the first time), and `session["visits"] = ...` writes it. But it's not stored on the server: Flask packs the whole dict into the signed cookie. Reload the page and the count climbs, since the browser hands the cookie back each time. Without `app.secret_key` set, Flask refuses to use the session at all.

⚠️ **Signed is not encrypted.** The signature makes the cookie *tamper-proof* - change one byte and Flask rejects it - but by default the contents are only base64-encoded, not hidden. Anyone who reads the cookie can read its values. Never put secrets in the session: no passwords, no API keys, no private data. Store a *reference* (like a user id) and look up the sensitive stuff server-side. Same "the cookie is just the key" principle the [Servlet sessions guide](/guides/the-servlet-api) hammers on.

💡 Keep `secret_key` genuinely secret and random in production - load it from an environment variable, never hardcode it. If an attacker learns it, they can forge any session.

## Auth = identity (a quick recap)

Before wiring up login, get one distinction straight - mixing them up is the root of most auth bugs.

📝 **Authentication is *who you are*; authorization is *what you're allowed to do*.** Logging in is authentication - proving you're the owner of an account. Deciding whether you may edit *this particular note* is authorization. This phase is about the first one; the [authentication vs authorization guide](/guides/auth-vs-authz) draws the full line between them.

The other non-negotiable: how you store passwords.

⚠️ **Never store passwords as plain text. Hash them.** When a user signs up you store a one-way *hash* of their password, not the password itself; at login you hash what they typed and compare hashes. Werkzeug (which ships with Flask) gives you exactly the two functions for this:

```python
from werkzeug.security import generate_password_hash, check_password_hash

hashed = generate_password_hash("hunter2")        # store THIS in the database
print(hashed)
# 'scrypt:32768:8:1$k9...': algorithm + parameters + salt + hash, all in one string

check_password_hash(hashed, "hunter2")   # True - correct password
check_password_hash(hashed, "wrong")     # False - wrong password
```

*What just happened:* `generate_password_hash` runs the password through a deliberately slow, salted hashing algorithm and returns a single string holding the algorithm, its parameters, the random salt, and the hash - that's what goes in your `User` table's `password_hash` column. At login, `check_password_hash` re-hashes the attempt with the stored salt and compares, returning `True` only on a real match. You never store, log, or compare the raw password. The [how passwords are stored guide](/guides/how-passwords-are-stored) explains why hashing-with-salt is the only acceptable approach.

## Flask-Login: the extension

Flask doesn't ship a login system - true to form, you add one. 💡 The standard choice is **Flask-Login**, the extension pattern from [Phase 5](05-database-with-sqlalchemy.md) all over again: a focused library that integrates cleanly into Flask's request lifecycle and the session you just met. It manages the "is this browser logged in, and as whom?" bookkeeping so you don't hand-roll it.

It needs three pieces wired together: a `LoginManager`, a `User` model that mixes in `UserMixin`, and a *user loader* that turns a stored id back into a user object.

```python
from flask_login import LoginManager, UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
# db is the Flask-SQLAlchemy handle from Phase 5

login_manager = LoginManager(app)
login_manager.login_view = "auth.login"   # where @login_required sends anonymous users

class User(db.Model, UserMixin):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True, nullable=False)
    password_hash = db.Column(db.String(255), nullable=False)

    def check_password(self, password):
        return check_password_hash(self.password_hash, password)

@login_manager.user_loader
def load_user(user_id):
    return User.query.get(int(user_id))   # called on every request to restore current_user
```

*What just happened:* `LoginManager(app)` plugs Flask-Login into the app and the session machinery. `login_view` tells it which route to bounce unauthenticated visitors to. The `User` model is an ordinary Flask-SQLAlchemy model - but `UserMixin` adds the properties Flask-Login expects for free (`is_authenticated`, `get_id()`, and friends). The `@login_manager.user_loader` is the linchpin: Flask-Login stores only the user *id* in the session cookie, and on every request it calls `load_user` with that id to fetch the full `User` - the "store a reference, look up the rest server-side" rule made concrete.

Now the login view itself - verify the password, then log the user in:

```python
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_user, logout_user, login_required

auth = Blueprint("auth", __name__)   # the auth blueprint from Phase 6

@auth.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        user = User.query.filter_by(username=request.form["username"]).first()
        if user and user.check_password(request.form["password"]):
            login_user(user)                      # <-- the session now remembers this user
            return redirect(url_for("notes.list_notes"))
        flash("Invalid username or password.")
    return render_template("login.html")

@auth.route("/logout")
@login_required
def logout():
    logout_user()                                 # forget the user for this session
    return redirect(url_for("auth.login"))
```

*What just happened:* on a POST we look up the user by username and verify the typed password with `check_password` (hash comparison, never plain text). If both check out, `login_user(user)` records the user's id in the session - from here on, every request from this browser is recognized as that user until they log out. We deliberately don't tell the visitor *which* field was wrong. `logout_user()` does the reverse. This all lives in an `auth` blueprint, from [Phase 6](06-blueprints-and-app-factory.md).

## Protecting routes

With login working, you can now demand it. 📝 **`@login_required` is a decorator that gates a view behind authentication.** Stack it on any route and Flask-Login intercepts anonymous visitors before your code runs, redirecting them to `login_view`. Here's the create-note route from [Phase 5](05-database-with-sqlalchemy.md), now protected and stamping each note with its owner:

```python
from flask_login import login_required, current_user

@notes.route("/notes", methods=["POST"])
@login_required                                   # must be logged in to reach this
def create_note():
    note = Note(
        title=request.form["title"],
        content=request.form["content"],
        user_id=current_user.id,                  # who created it
    )
    db.session.add(note)
    db.session.commit()
    return redirect(url_for("notes.list_notes"))
```

*What just happened:* `@login_required` sits between the route and the function. An anonymous visitor never reaches the body - Flask-Login redirects them to the login page and (if configured) remembers where they were headed. Inside the view, `current_user` is the logged-in `User` object Flask-Login restored via your `user_loader`; reading `current_user.id` records who owns the note. (The `user_id` column on `Note` is the relationship plumbing from Phase 5.)

`current_user` is available in templates too, which is how you show different UI to logged-in and logged-out visitors:

```html
{% if current_user.is_authenticated %}
  <p>Signed in as {{ current_user.username }} - <a href="{{ url_for('auth.logout') }}">Log out</a></p>
  <a href="{{ url_for('notes.new_note') }}">New note</a>
{% else %}
  <a href="{{ url_for('auth.login') }}">Log in</a> to create notes.
{% endif %}
```

*What just happened:* `current_user.is_authenticated` is one of the properties `UserMixin` gave the `User` model - `True` for a logged-in user, `False` for an anonymous one. Flask-Login injects `current_user` into every template automatically, so you can branch on it without passing it in. Server-side `@login_required` is the real guard; this template check is just a clear UI cue on top of it.

## The extension ecosystem

Sessions came from Flask's tiny core; *everything else* - the ORM in Phase 5, login here - arrived as an extension you chose and wired in. 💡 **This is how Flask stays small: a rich ecosystem of focused extensions you compose into exactly the app you need.** A few you'll meet constantly:

- **Flask-SQLAlchemy** - the database/ORM layer (Phase 5).
- **Flask-Login** - session-based authentication (this phase).
- **Flask-WTF** - form handling and CSRF protection (Phase 4).
- **Flask-Migrate** - schema migrations via Alembic (Phase 5's gotcha).
- **Flask-Mail** - sending email.
- **Flask-CORS** - cross-origin headers for APIs (handy in Phase 8).

⚠️ **The flip side is real: you assemble and maintain the stack yourself.** Django hands you auth, an ORM, an admin, and forms in one box, version-matched and integrated. Flask hands you a core and a catalog - you pick each piece, wire it in, and keep the versions playing nicely. For a small or focused app that freedom is a gift; for a large team it's overhead Django would have absorbed.

💡 **Sessions plus Flask-Login give you real authentication; the extension model gives you everything else.** You've now seen Flask's whole personality - a small, transparent core you grow by deliberate choices. Next we point that app outward and have it speak JSON.

## Recap

1. **`flask.session` is a dict-like store backed by a signed cookie** and needs `app.secret_key`. Write to it like a dict; values persist across requests for that user because the cookie round-trips on every request.
2. ⚠️ **Signed ≠ encrypted.** The session cookie is tamper-proof but readable by default - never put secrets in it. Store a reference (a user id) and look up sensitive data server-side.
3. **Authentication (who you are) is separate from authorization (what you can do)**, and passwords must be hashed - use Werkzeug's `generate_password_hash` / `check_password_hash`, never plain text.
4. **Flask-Login is the standard auth extension**: a `LoginManager`, a `UserMixin` model, a `user_loader` to restore `current_user` from the session, and `login_user` / `logout_user` in your views.
5. **`@login_required` gates a route** behind authentication and redirects anonymous visitors to the login page; `current_user` gives you the logged-in user in views and templates (`current_user.is_authenticated`).
6. 💡 **Flask stays small via its extension ecosystem** (Flask-SQLAlchemy, Flask-WTF, Flask-Login, Flask-Migrate, Flask-Mail, Flask-CORS…) - you compose them yourself, which is freedom for small apps and assembly work versus Django's all-in-one.

## Quick check

Three questions on the ideas that have to stick before Phase 8:

```quiz
[
  {
    "q": "The Flask session cookie is signed. What does that protect against, and what does it NOT protect against?",
    "choices": [
      "It protects against tampering (a forged cookie is rejected) but not against reading - values are not encrypted by default, so don't store secrets there",
      "It encrypts the contents, so it's safe to store passwords and API keys in the session",
      "It protects against the cookie being read by JavaScript, but allows tampering",
      "It hides the cookie from the browser entirely; only the server can see it"
    ],
    "answer": 0,
    "explain": "Signing makes the cookie tamper-proof - Flask rejects any modified cookie - but the values are only encoded, not encrypted. Anyone who reads the cookie reads the data, so store a reference (like a user id), never secrets."
  },
  {
    "q": "In a Flask-Login setup, what is the @login_manager.user_loader function for?",
    "choices": [
      "It takes the user id stored in the session and returns the full User object, so current_user is available on each request",
      "It checks the typed password against the stored hash during login",
      "It decides which routes require authentication",
      "It creates the session cookie and signs it on login"
    ],
    "answer": 0,
    "explain": "Flask-Login stores only the user id in the session. On every request it calls user_loader with that id to fetch the full User from the database and populate current_user - the 'store a reference, look it up server-side' pattern."
  },
  {
    "q": "You put @login_required on the create-note route. An anonymous visitor POSTs to it. What happens?",
    "choices": [
      "Flask-Login intercepts the request before the view body runs and redirects the visitor to the configured login_view",
      "The view runs but current_user is None, so you must check for it manually",
      "Flask returns a 500 error because no user is logged in",
      "The note is created and assigned to a default anonymous user"
    ],
    "answer": 0,
    "explain": "@login_required gates the view: an unauthenticated request never reaches your code. Flask-Login redirects the visitor to login_view (and can remember where they were headed), so the create-note logic only ever runs for a logged-in user."
  }
]
```


---

# Building a JSON API with Flask

Everything you've built so far hands back HTML. A view runs `render_template`, the browser gets a page, a human reads it - the right shape when the *consumer* is a person looking at a screen. But notes don't only get read by people: a mobile app might want them, a React frontend might fetch them, another service might sync them. None of those wants a styled HTML page - they want raw data, JSON.

📝 **An API and a web page are the same Flask app wearing two different hats.** Same routing, same view functions, same request cycle - the *only* thing that changes is what a view returns. Return `render_template(...)` and you've served a page for a human. Return `jsonify(...)` and you've served data for a program. Flask was never "a web-page framework" - it's a request-to-response framework, and JSON is just another kind of response. This phase swaps the hat.

If "GET, POST, paths, status codes, resources" aren't yet second nature, read [REST APIs explained](/guides/rest-apis-explained) alongside this.

## HTML for humans, JSON for programs

The split is worth making concrete. So far a note route looked like this:

```python
@app.route("/notes/<int:note_id>")
def show_note(note_id):
    note = Note.query.get_or_404(note_id)
    return render_template("note.html", note=note)
```

*What just happened:* this fetches a `Note` (the model from [Phase 5](05-database-with-sqlalchemy.md)) and renders it into an HTML template - perfect for a browser, useless to a program that just wants title and content as data.

The API version of that same idea returns the note *as data*:

```python
from flask import jsonify

@app.route("/api/notes/<int:note_id>")
def get_note(note_id):
    note = Note.query.get_or_404(note_id)
    return jsonify({"id": note.id, "title": note.title, "content": note.content})
```

*What just happened:* same fetch, different return. `jsonify(...)` takes a Python dict, serializes it to a JSON string, and sets the `Content-Type: application/json` header so the client knows what it's receiving. The two routes coexist in one app: `/notes/<id>` serves a page, `/api/notes/<id>` serves the data. 💡 The `/api` prefix is just a convention; Flask doesn't treat it specially.

## JSON endpoints: reading and writing data

📝 **Flask has no built-in serializer.** It won't magically turn a `Note` object into JSON - `jsonify(note)` would fail, because Flask doesn't know which fields you want or how to format them. *You* decide the shape by converting the model to a plain dict yourself. A small helper keeps that in one place:

```python
def note_to_dict(note):
    return {"id": note.id, "title": note.title, "content": note.content}
```

*What just happened:* one function that defines your API's note shape. Every endpoint that returns a note goes through it, so the JSON stays consistent. This hand-serialization is the price of Flask's minimalism - later we'll see the extension that automates it.

Now the read-the-collection endpoint:

```python
@app.route("/api/notes")
def list_notes_api():
    notes = Note.query.all()
    return jsonify([note_to_dict(n) for n in notes])
```

*What just happened:* `Note.query.all()` pulls every note as objects, the comprehension runs each through `note_to_dict`, and `jsonify` serializes the resulting list. A client hitting this gets:

```http
GET /api/notes
```

```json
[
  {"id": 1, "title": "Groceries", "content": "milk, eggs, bread"},
  {"id": 2, "title": "Ideas", "content": "write the Flask guide"}
]
```

*What just happened:* a clean JSON array, ready for any program to parse.

Creating a note is the interesting direction, because now data flows *in*. A browser form sends `request.form`, but an API client sends a JSON body, read with `request.get_json()`:

```python
from flask import request

@app.route("/api/notes", methods=["POST"])
def create_note_api():
    data = request.get_json()
    note = Note(title=data["title"], content=data["content"])
    db.session.add(note)
    db.session.commit()
    return jsonify(note_to_dict(note)), 201
```

*What just happened:* `request.get_json()` parses the incoming JSON body into a Python dict - the API-client counterpart to `request.form`. You build a `Note` from it, then `add` + `commit` exactly as in [Phase 5](05-database-with-sqlalchemy.md). The return is a tuple: the created note as JSON, plus `201`, a *status code* that matters more than it looks.

A client creating a note sends and receives:

```http
POST /api/notes
Content-Type: application/json

{"title": "Read more", "content": "finish the Flask guide"}
```

```json
{"id": 3, "title": "Read more", "content": "finish the Flask guide"}
```

*What just happened:* the client posts a JSON body, your view reads it, saves it, and echoes back the created note with its real `id` filled in. That round-trip - send data, get the saved version back - is the bread and butter of a REST API.

## Status codes and JSON errors

The number a response carries is half its meaning. `200 OK` says "here's what you asked for." `201 Created` says "I made the thing you posted." `404 Not Found` says "no such note." A client *reads* these - your mobile app checks the status before it trusts the body. (The [HTTP & JSON API basics](/guides/http-and-json-api-basics) guide is the cheat sheet for which code means what.)

Returning a code is just the second item in a return tuple, as you saw with `201`. For a deliberate "not found," Flask gives you `abort`:

```python
from flask import abort

@app.route("/api/notes/<int:note_id>")
def get_note(note_id):
    note = Note.query.get(note_id)
    if note is None:
        abort(404)
    return jsonify(note_to_dict(note))
```

*What just happened:* `abort(404)` immediately stops the view and triggers Flask's 404 handling - no `return` needed, no risk of running the rest of the function on a `None`. (`get_or_404` from Phase 5 is the one-liner version of this exact pattern.)

⚠️ **Flask's default error responses are HTML pages, not JSON.** Out of the box, `abort(404)` sends back a little HTML document - fine for a browser, but a JSON client trying to `JSON.parse` an HTML page gets a parse error and no idea what went wrong. An API must answer errors in the same language as everything else: JSON. Fix this by registering an error handler:

```python
@app.errorhandler(404)
def not_found(error):
    return jsonify({"error": "Not found"}), 404
```

*What just happened:* `@app.errorhandler(404)` tells Flask "when a 404 happens *anywhere*, run this instead of the default HTML page." Now every 404 - from `abort(404)`, `get_or_404`, or an unmatched route - comes back as `{"error": "Not found"}` with the right status code. Register the same for `400` and `500` and your API speaks one language end to end.

## Structuring an API as it grows

Two endpoints fit fine in one file. Twenty do not. The tool for that is the [blueprint from Phase 6](06-blueprints-and-app-factory.md) - group all your `/api` routes into one blueprint and register it under a shared prefix:

```python
from flask import Blueprint

api = Blueprint("api", __name__, url_prefix="/api")

@api.route("/notes")
def list_notes_api():
    return jsonify([note_to_dict(n) for n in Note.query.all()])
```

*What just happened:* the blueprint collects every API route under `/api` automatically - define `@api.route("/notes")` and it lives at `/api/notes`. Your API becomes one self-contained module, cleanly separated from the HTML side of the app.

And the hand-serialization? Flask's extension philosophy shows up again. 💡 The same "small core + chosen extensions" pattern from Flask-SQLAlchemy applies to APIs: **marshmallow** (or **Flask-Marshmallow**) defines schemas that serialize models to JSON *and* validate incoming data, killing the boilerplate of `note_to_dict` and manual lookups. **Flask-RESTful** offers a class-based structure for organizing resources. You don't need either to ship an API, but reaching for one as it grows is the natural next step.

## Flask vs FastAPI for APIs, plainly

You can absolutely build a real, production API with Flask - plenty of teams do, and you just saw how. But clarity matters more than loyalty: 💡 **FastAPI was *built* for APIs in a way Flask wasn't.**

Flask is a general web framework that does HTML and JSON equally well, with APIs as one thing among many. [FastAPI](/guides/fastapi-from-zero) made the API the *whole point*. The things you did by hand this phase - writing `note_to_dict`, validating `request.get_json()`, registering JSON error handlers, and documenting every endpoint - FastAPI does automatically from your type hints. One typed function declaration becomes validation, serialization, *and* interactive `/docs`, all kept in sync.

The clear dividing line:

- **Reach for Flask APIs** when you're already in a Flask app and want to add a JSON endpoint or two, when you want full manual control over every response, or when the API is a *side* of a larger HTML app.
- **Reach for FastAPI** when the API *is* the product - a backend serving a frontend or mobile app, a microservice, anything where validation and documentation carry real weight and you'd rather not hand-roll them.

Neither is "better." Flask trades automation for control and simplicity; FastAPI trades a stricter, type-hint-driven style for a huge amount of automation. Pick by what you're building.

## Recap

1. **An API is the same Flask app, different return value** - swap `render_template` for `jsonify`. Same routing, same request cycle; only the response shape changes (data for programs, not pages for humans).
2. **`jsonify` serializes a dict and sets the JSON content type**, but Flask has **no built-in model serializer** - you convert models to dicts yourself (a `note_to_dict` helper keeps the shape in one place).
3. **Read incoming JSON with `request.get_json()`** - the API counterpart to `request.form` - then save through `db.session` exactly as before.
4. **Status codes are part of the contract**: return `(body, 201)` on create, `abort(404)` when missing. ⚠️ Default Flask errors are HTML - register `@app.errorhandler` handlers so errors come back as JSON.
5. **Structure a growing API with a blueprint** under an `/api` prefix; **marshmallow / Flask-RESTful** are the extensions that automate serialization and validation - the extension pattern again.
6. **Flask can do APIs; FastAPI was built for them.** Use Flask APIs inside an existing Flask app or for full control; reach for [FastAPI](/guides/fastapi-from-zero) when the API is the product and you want validation, serialization, and docs for free.

## Quick check

Three questions on the ideas that have to stick before Phase 9:

```quiz
[
  {
    "q": "What is the core difference between a Flask view that serves an HTML page and one that serves a JSON API response?",
    "choices": [
      "Only what the view returns - render_template for a page, jsonify for data; the routing and request cycle are identical",
      "API views must use a completely separate Flask application object",
      "API views cannot use the database or models that HTML views use",
      "Flask needs a special 'API mode' enabled in config before JSON works"
    ],
    "answer": 0,
    "explain": "An API is the same Flask app wearing a different hat. Same routing, same view functions, same request cycle - the only change is returning jsonify(...) instead of render_template(...). The two can coexist in one app."
  },
  {
    "q": "You call abort(404) in an API endpoint, but your JSON client reports a parse error instead of a clean error message. Why?",
    "choices": [
      "Flask's default error responses are HTML pages - you must register an @app.errorhandler(404) that returns jsonify(...) so errors come back as JSON",
      "abort(404) is not a real Flask function and silently does nothing",
      "The client must send a special header to receive JSON errors",
      "404 errors can never return a body, so there is nothing for the client to parse"
    ],
    "answer": 0,
    "explain": "Out of the box Flask returns a small HTML page for errors. A JSON client trying to parse HTML fails. Registering @app.errorhandler(404) (and 400, 500) to return jsonify({\"error\": ...}), code makes the whole API speak JSON consistently."
  },
  {
    "q": "When is FastAPI the more natural choice over Flask for an API, according to this phase?",
    "choices": [
      "When the API is the product - and you want validation, serialization, and interactive docs generated automatically from type hints",
      "Always - Flask cannot build production APIs at all",
      "Only when you need to serve HTML pages alongside the API",
      "Only for tiny one-or-two-endpoint additions to an existing app"
    ],
    "answer": 0,
    "explain": "Flask can build real APIs, but FastAPI was built for them: type hints drive automatic validation, serialization, and /docs. Reach for Flask APIs inside an existing Flask app or for full control; reach for FastAPI when the API is the whole point."
  }
]
```


---

# Testing & Production

You've built a real notes app - routes, a database, auth, an API. Two questions separate a side project from something you'd let other people touch: *how do I know I didn't break anything?* and *how do I run this so it's not just alive on my laptop?*

📝 **Testing and deployment are the same idea pointed in two directions: you want your app to run somewhere other than the place you built it.** A test runs your app in a throwaway, controlled environment to check its behavior. Production runs it in a hardened, public environment to serve real users. The thing that makes *both* clean - the payoff of all that structure from Phase 6 - is the app factory. `create_app()` lets you build a fresh app for a test, and a different fresh app for production, from the exact same code.

## The test client: call your app without a server

The fear most people have about testing a web app is that they'll need to start the server, fire HTTP requests at `localhost`, and tear it all down - slow, flaky, painful. Flask sidesteps that entirely.

📝 **`app.test_client()` gives you a fake browser that calls your app in-process - no running server, no network, no port.** You call `client.get("/notes")` or `client.post("/notes", data={...})` and Flask routes the request through your real view functions and hands you back a response object - a function call wearing an HTTP costume. Tests run *fast* and reliable.

If you've never written a test before, read [Your First Unit Test](/guides/your-first-unit-test) - it teaches the Arrange-Act-Assert shape we're about to use:

```python
def test_notes_page_loads(client):
    # Act: ask the app for the notes page
    response = client.get("/notes/")

    # Assert: check the status code and the body
    assert response.status_code == 200
    assert b"Notes" in response.data
```

*What just happened:* `client.get("/notes/")` runs the request through your app's routing and view function and returns a `response`. We check two things every view test checks: `response.status_code` (200? redirect 302? not found 404?) and `response.data` (the raw bytes of the body). `response.data` is **bytes**, not a string, so compare against a byte string or decode it first. That `client` argument isn't magic; it's a pytest fixture we're about to build.

## The app factory makes testing clean

Where does `client` come from? This is where Phase 6 pays off. 💡 **Because you have a `create_app(config)` factory, your tests can build a brand-new app wired to a TEST config - an in-memory database, CSRF turned off - and nothing about it touches your real app.**

A pytest **fixture** is a reusable bit of setup that tests can request just by naming it as an argument. We'll write two: one that builds a test app, and one that hands tests a client for it. Put them in `tests/conftest.py` (pytest auto-discovers fixtures there):

```python
# tests/conftest.py
import pytest
from app import create_app
from app.models import db

@pytest.fixture
def app():
    app = create_app({
        "TESTING": True,
        "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",  # throwaway DB
        "WTF_CSRF_ENABLED": False,                        # no CSRF tokens in tests
    })
    with app.app_context():
        db.create_all()      # build the schema in the in-memory DB
        yield app            # hand the app to the test
        db.drop_all()        # tear it down afterward

@pytest.fixture
def client(app):
    return app.test_client()
```

*What just happened:* the `app` fixture calls `create_app(...)` with a **test config dict** - an in-memory SQLite database (`sqlite:///:memory:`) that exists only for the test and vanishes after, plus `WTF_CSRF_ENABLED: False` so form-posting tests skip CSRF tokens. Inside an app context it creates the tables, `yield`s the app, then drops everything. The `client` fixture depends on `app` and returns `app.test_client()`. Now any test that names `client` gets a fresh, isolated app every time.

⚠️ **Never run your tests against your real database.** A test that calls `client.post(...)` to create a note, or exercises a delete route, will happily write to - or wipe - whatever database the app is pointed at. The in-memory test config gives tests their own disposable world.

(For this fixture to work, `create_app` needs to accept a config dict - a small tweak to the Phase 6 factory: `if isinstance(config, dict): app.config.update(config)` alongside the `from_object` path.)

## What to test

You don't need 100% coverage to get value. Aim at the things that *break in ways users notice*. For a Flask app, three layers:

```python
def test_create_note_redirects(client):
    response = client.post("/notes/", data={"title": "Buy milk", "content": "2%"})
    assert response.status_code == 302            # POST then redirect (PRG pattern)

def test_notes_requires_login(client):
    response = client.get("/notes/")              # not logged in
    assert response.status_code == 302            # bounced to the login page
    assert "/login" in response.headers["Location"]

def test_note_str(client):
    from app.models import Note
    note = Note(title="Hello", content="world")
    assert "Hello" in str(note)                   # plain model logic, no HTTP
```

*What just happened:* three kinds of test. The first checks a **view response** - posting a note should redirect (302), the classic Post/Redirect/Get pattern. The second checks **auth** - an unauthenticated request to a `login_required` route should redirect to `/login`, verified via the `Location` header. The third is **pure logic** on the model - no client, no HTTP.

💡 That ordering mirrors the **test pyramid**: lots of fast, cheap logic/model tests at the bottom, a solid middle layer of view tests, and only a few slow end-to-end tests up top. Most of your tests should be the cheap kind.

## Production: the dev server is NOT for production

This is the one rule from this phase that, if you remember nothing else, saves you from a real incident.

⚠️ **`flask run` and the underlying Werkzeug development server are for development only. Never put them in front of real users.** The dev server is single-threaded by default (one slow request blocks everyone), it's not built to survive hostile traffic, and Flask's own startup banner literally warns you: *"WARNING: This is a development server. Do not use it in a production deployment."* It will fall over under load, and has no business being on the public internet.

What you run instead is a real **WSGI server**. WSGI is the standard contract between a Python web app and the server that runs it - Flask speaks WSGI, and production-grade servers speak WSGI back. The most common one is **gunicorn**: battle-tested, multi-worker, boring in the best way.

```bash
gunicorn --workers 4 --bind 0.0.0.0:8000 "app:create_app()"
```

*What just happened:* gunicorn imports your `app` package, **calls your factory** (`create_app()`) to build the application, and serves it. `--workers 4` spins up four worker processes so four requests can be handled truly in parallel (a rough starting point is `2 × CPU cores + 1`). `--bind 0.0.0.0:8000` listens on port 8000 on all interfaces. In a typical deploy, **nginx** sits in front of gunicorn to terminate TLS, serve static files, and shield the app, but gunicorn runs your Python.

For the full story on getting this onto a server with a domain and HTTPS, see [Ship Your Side Project](/guides/ship-your-side-project). Dev server for `localhost`, gunicorn for the world.

## Config & Docker

Running the right server is half of "production." The other half is the right *config*. A production app is configured differently from your laptop in a few non-negotiable ways:

- ⚠️ **`DEBUG = False`.** With `DEBUG = True`, an unhandled error shows the visitor a full traceback *and* an interactive Werkzeug debugger console - which can execute arbitrary Python on your server. Debug mode in production is one of the most dangerous misconfigurations there is.
- ⚠️ **`SECRET_KEY` from the environment, never hard-coded.** It signs your session cookies; if committed to the repo, anyone who reads your code can forge logins.
- **A real database** (Postgres), not the SQLite file you developed against.
- **Static files served by nginx or a CDN**, not by Flask.

That's exactly what the `ProdConfig` class from Phase 6 encodes. The factory selects it, and you're configured safely with no code change.

To package the whole thing so it runs the same everywhere, wrap it in a container. Here's a minimal `Dockerfile`:

```dockerfile
FROM python:3.12-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

# Run the app with gunicorn, NOT the Flask dev server
CMD ["gunicorn", "--workers", "4", "--bind", "0.0.0.0:8000", "app:create_app()"]
```

*What just happened:* this builds on a slim Python base image, installs dependencies first (so Docker caches that layer and rebuilds stay fast), copies your code, and - crucially - its `CMD` launches **gunicorn**, not `flask run`. The container runs identically on your laptop, a teammate's machine, or a cloud host. If `FROM`, layers, and `CMD` are unfamiliar, [Docker Without the Magic](/guides/docker-without-the-magic) walks through each line.

💡 Clean testing and clean production config are *the same capability*. Both come from `create_app()` building a fresh, independently-configured app on demand - test config for pytest, prod config for gunicorn, dev config for your laptop. The factory you wrote in Phase 6 to dodge circular imports is the foundation that lets you both trust your app and ship it.

## Recap

1. 📝 **The test client calls your app in-process.** `app.test_client()` returns a fake browser - `client.get(...)` / `client.post(...)` run requests through your real views with no running server. Fast and reliable. Assert on `response.status_code` and `response.data` (which is bytes).
2. 💡 **The app factory makes tests clean.** A pytest fixture calls `create_app(test_config)` with an in-memory SQLite DB and CSRF off, builds the schema, yields a client, then tears down - a fresh isolated app per test. ⚠️ Never test against the real database.
3. **Test three layers:** view responses (status, redirects, content), auth (login-required routes redirect), and pure model/logic. Follow the test pyramid - mostly cheap, fast tests at the bottom.
4. ⚠️ **The dev server is not for production.** `flask run` / Werkzeug is single-threaded and insecure. Run behind a real WSGI server - **gunicorn** with multiple workers (`gunicorn "app:create_app()"`), typically with nginx in front.
5. ⚠️ **Production config is different and it matters.** `DEBUG = False` (debug mode leaks tracebacks and an RCE-capable console), `SECRET_KEY` and `DATABASE_URL` from the environment (never hard-coded), a real DB, static files via nginx/CDN - all selected by the factory's `ProdConfig`. A minimal Dockerfile runs gunicorn, not the dev server.

## Quick check

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

```quiz
[
  {
    "q": "What does Flask's app.test_client() let you do?",
    "choices": [
      "Call your app in-process - routing requests through your real view functions - without starting a server or using the network",
      "Start a real development server on a random port and send it HTTP requests over the network",
      "Automatically generate test cases for every route in your application",
      "Connect your tests to the production database so they exercise real data"
    ],
    "answer": 0,
    "explain": "test_client() returns a fake browser that runs requests through your real views in-process - no server, no port, no network. That makes tests fast and reliable, and you assert on response.status_code and response.data (bytes)."
  },
  {
    "q": "Why should you never use flask run / the Werkzeug development server in production?",
    "choices": [
      "It's single-threaded and insecure - built for development, not hostile public traffic - so you run a real WSGI server like gunicorn instead",
      "It can only serve one route at a time regardless of how the app is written",
      "It refuses to start unless DEBUG is set to True, which is unsafe",
      "It doesn't support templates or static files, only JSON responses"
    ],
    "answer": 0,
    "explain": "The dev server is single-threaded by default and not hardened for public traffic - Flask itself warns against it. Production runs behind a real WSGI server like gunicorn (multiple workers), usually with nginx in front."
  },
  {
    "q": "Which production configuration setting is a serious security risk if you get it wrong?",
    "choices": [
      "DEBUG = True in production - it exposes tracebacks and an interactive debugger console that can run arbitrary code (an RCE risk)",
      "Setting --workers to a number higher than your CPU core count",
      "Serving static files through nginx instead of through Flask",
      "Using an in-memory SQLite database for the production app"
    ],
    "answer": 0,
    "explain": "With DEBUG = True, an unhandled error shows visitors a full traceback plus the Werkzeug interactive debugger, which can execute Python on your server - a remote-code-execution hole. Production must use DEBUG = False, and pull SECRET_KEY from the environment, never hard-code it."
  }
]
```


---

# Where to Go Next

Stop and look at what you can actually do now. You can stand up a Flask app, route URLs to view functions, read the request and shape the response, render HTML with Jinja2, handle forms with CSRF protection, persist data through Flask-SQLAlchemy, structure a growing project with blueprints and an app factory, log people in with sessions and Flask-Login, serve a JSON API with `jsonify`, and prove it all works with the test client and pytest before deploying behind a real WSGI server. That's not a toy - that's a real application.

Here's the quietly bigger win: because Flask's core is so small, you didn't just learn a framework - you saw what a framework *is*. A router mapping URLs to functions, a request coming in, a response going out, templates for HTML, everything else bolted on as an extension you chose. Nothing was hidden behind a wall of conventions.

This last phase isn't more decorators - it's the map: the extensions you'll reach for next, a clear word about async, where Flask sits among the other Python frameworks, and one concrete thing to go build.

## The extension landscape

Flask gives you a small core, and *you* compose the stack you need from extensions - why the ecosystem matters as much as the framework. Here are the branches you'll meet first.

```mermaid
flowchart TD
  Flask[Your Flask app] --> Tasks[Celery / RQ + a broker]
  Flask --> API[Flask-RESTful / marshmallow]
  Flask --> Mail[Flask-Mail]
  Flask --> CORS[Flask-CORS]
  Flask --> Cache[Flask-Caching + Redis]
  Flask --> Admin[Flask-Admin]
```

A line on each:

- **Celery / RQ** - background and scheduled work. When a job is slow, needs retrying, or must survive a restart (sending a batch of emails, processing an upload), hand it to an external worker fronted by a **message broker** (usually Redis). Your view drops the job and returns immediately.
- **Flask-RESTful / marshmallow** - richer APIs. Once your JSON endpoints grow past `jsonify`, these give you structured resources, request parsing, and serialization/validation schemas.
- **Flask-Mail** - sending email, with sensible defaults for SMTP and attachments.
- **Flask-CORS** - cross-origin requests, added the day a separate frontend needs to call your API from the browser.
- **Flask-Caching** - caching expensive results, usually backed by **Redis**, to take load off your database.
- **Flask-Admin** - a quick admin interface over your models, without building one from scratch.

💡 You don't need any of these on day one. The skill is recognizing the *shape* of the problem and knowing there's a well-worn extension for it.

## A word on async

You'll eventually hear that Flask "supports async," and that's true - you can write `async def` view functions in modern Flask. But look plainly at what's underneath: Flask is a **WSGI** framework, synchronous at heart. It runs your async view by spinning up an event loop for that one request, which helps for the occasional `await` but doesn't turn Flask into a high-concurrency async server.

📝 If your workload is genuinely async-heavy - lots of concurrent connections, streaming, talking to many slow services at once - you'll be happier on an **ASGI** framework built for it from the ground up, like [FastAPI](/guides/fastapi-from-zero). Flask's async support is a convenience, not a foundation.

## The clear-eyed framework map

You now know enough to choose a framework *on purpose* rather than by habit. None of these is "better" - they're aimed at different jobs. 💡 The plain version:

- **Flask** - small apps, prototypes, microservices, and learning. Pick it for a small core and full control over the stack. (You're here.)
- **Django** - when you want **batteries included**: a built-in admin, a mature ORM, auth, and a thousand conventions for a big application. If you'd otherwise rebuild half of Django out of Flask extensions, just use [Django](/guides/django-from-zero).
- **FastAPI** - async, validation-heavy **APIs**, where type hints drive request parsing, response shaping, and auto-generated docs. See [FastAPI From Zero](/guides/fastapi-from-zero).

Knowing all three means you stop arguing about which is "best" and start asking "best for *this*?" That's a senior instinct, and you've got the pieces for it now.

## What to build next

Reading more won't make this stick. Building one real thing will. Here's the assignment, and it's deliberately concrete.

Take the **notes app** you grew across this guide and carry it all the way home:

- Add **authentication** so each user has their own notes (Flask-Login, sessions, hashed passwords).
- Add a **JSON API** alongside the HTML pages, so the same data is available to other clients.
- Add a **test suite** with pytest and the test client, covering the routes that matter.
- **Structure** it properly with **blueprints** and an **app factory**, the way a real Flask project is laid out.
- **Deploy** it with **gunicorn**, `DEBUG=False`, behind a reverse proxy - somewhere you can hit it from your phone.

That single project exercises nearly everything you learned, and finishing it teaches you more than three more tutorials would. When you want the canonical reference, the **official Flask documentation** is excellent and genuinely readable, and **the Flask Mega-Tutorial** (Miguel Grinberg's) is the long-form, build-along classic the community sends everyone to.

Flask's smallness wasn't a limitation, it was the lesson. Because nothing was hidden, you now see the whole machine - the router, the request, the response, the template, and the extensions you chose to bolt on. Go finish the notes app, deploy it, and show someone. You're ready.

## Recap

1. **You can ship a real Flask app** - routed, templated, form-handling, database-backed, blueprint-structured, authenticated, tested, and deployed - plus a JSON API. And you understand *why* each piece works, because Flask hid nothing.
2. **Compose your stack from extensions** - Celery/RQ for background work, Flask-RESTful/marshmallow for richer APIs, Flask-Mail, Flask-CORS, Flask-Caching (Redis), Flask-Admin. The skill is matching the problem's shape to the right extension.
3. **Async, plainly** - Flask runs `async def` views, but it's a synchronous WSGI framework at heart. For genuinely async-heavy workloads, an ASGI framework like FastAPI fits better.
4. **Choose a framework on purpose** - Flask for small/prototype/microservice/full-control, Django for batteries-included big apps, FastAPI for async validation-heavy APIs. Knowing all three lets you pick for *this* job.
5. **Build one thing and finish it** - carry the notes app to a deployed, authenticated, tested, JSON-API-having, blueprint-structured app served by gunicorn. Lean on the official docs and the Flask Mega-Tutorial.

## Quick check

Three decisions to take with you as you leave this guide:

```quiz
[
  {
    "q": "You need to send a batch of emails that's slow, must be retried on failure, and must survive a restart. What fits best in a Flask app?",
    "choices": [
      "An async def view that awaits the email send inline",
      "An external worker like Celery or RQ fronted by a broker such as Redis",
      "A Jinja2 template that renders the emails",
      "Flask-CORS, since email crosses origins"
    ],
    "answer": 1,
    "explain": "Slow, retryable, restart-surviving work belongs on an external worker (Celery / RQ) backed by a message broker like Redis. The view drops the job on the broker and returns immediately."
  },
  {
    "q": "Which statement about Flask and async is the accurate one?",
    "choices": [
      "Flask is fully async and matches FastAPI for concurrency",
      "Flask cannot run async def views at all",
      "Flask supports async def views but is a synchronous WSGI framework at heart; for async-heavy workloads an ASGI framework like FastAPI fits better",
      "Async views in Flask require Django"
    ],
    "answer": 2,
    "explain": "Modern Flask runs async def views by spinning up an event loop per request, but it's WSGI and synchronous underneath. Heavily concurrent/async workloads are a better fit for an ASGI framework like FastAPI."
  },
  {
    "q": "You're building a large web application and want a built-in admin, a mature ORM, and auth handed to you out of the box. Which framework is the on-purpose choice?",
    "choices": [
      "Flask, because it's the micro-framework",
      "Django, because it's batteries-included",
      "FastAPI, because it's async",
      "It doesn't matter; they're interchangeable"
    ],
    "answer": 1,
    "explain": "Django is batteries-included: admin, ORM, auth, and conventions for a full application. Flask shines for small apps and full control; FastAPI for async, validation-heavy APIs."
  }
]
```
