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:
Title
Save
What just happened: method="post" tells the browser to send a POST (the verb for submitting data, from
Routing & Views), 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:
=
return f, 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(...)).
=
return # 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.
= # required for flashing (it signs the session cookie)
=
return
return
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():
{% for message in get_flashed_messages() %}
{{ message }}
{% 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) 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.
=
=
=
return
return
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:
{{ form.csrf_token }}
{{ form.title.label }} {{ form.title() }}
{% for error in form.title.errors %}{{ error }}{% endfor %}
{{ form.submit() }}
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 POSTs 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: 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 GETs 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
- 📝 A form submission is a
POSTwhose body is named fields. Read them withrequest.form["title"](400 if missing) orrequest.form.get("title")(returnsNone). The HTML input'snameis the key. - ⚠️ POST/redirect/GET: after a successful
POST, returnredirect(url_for(...))- never a rendered page - so a refresh re-fetches a page instead of re-submitting the form. - Validate and flash: check the input yourself (
if not title.strip()), and useflash("Note saved.")get_flashed_messages()in the template for one-time messages. Flashing requiresapp.secret_key.
- 📝 Flask-WTF is the extension for real forms: a
FlaskFormclass declares fields + validators, andform.validate_on_submit()parses, validates, and (on failure) re-renders with per-field errors. - 📝 ⚠️ 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. Rawrequest.formhas none. - 💡 Raw
request.formfor 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:
[
{
"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."
}
]
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 3 questions
1. Why redirect after a successful POST instead of rendering a page directly?
2. What does `form.validate_on_submit()` return for the very first GET request to the form's URL?
3. Why does a CSRF token stop a forged cross-site form submission?