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 you've
done yet: 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 to carry through all of it: a form submission is just a POST request whose body is a
bag of named fields. Nothing magic. 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, nothing assumed. Everything richer is something you add. We'll start
with the bare-hands version so you see the raw machinery, then layer on the extension that does the tedious
parts for you. Both are legitimate; knowing which to reach for is the real skill.
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 the load-bearing part — name="title" is the key your view will read by. No name, no value in
the request body; it's 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 request.form.get("title") returns None instead.
That's the whole of Flask's form handling: a dict of submitted fields. No validation, no escaping of what
you store, no protection — just the raw data. Everything else in this phase is something you build or bolt on
top of this one line.
POST, then redirect, then GET
⚠️ There's a bug hiding in that return f"Saved: {title}", 201. The user submits the form, sees "Saved," and
then — out of habit — hits refresh. The browser's refresh re-sends the last request, which was 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. The browser then makes a fresh GET to that page. Now the last request sitting in the browser
is a harmless GET, 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 are handled with two small additions: 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. flash stashes the message in the session
so it lives across the redirect 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, not "dev-only-change-me".
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 after that won't show them again — that's what "one-time" means. You'd put this loop in
your base template (from Templates with Jinja2) so every page can surface a
flash. The {{ message }} is auto-escaped by Jinja, so even a flash built from user input is safe to render.
Flask-WTF — the extension way
That hand-rolled validation works for one field. Now imagine 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 exactly the moment Flask's philosophy
says: reach for an extension. Flask-WTF (a thin Flask wrapper over the WTForms library) gives you
form classes — you declare your fields and their validators once, and it handles parsing, validation, and
re-rendering with errors.
=
=
=
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 (first visit)
or a failed POST, it returns False, so you fall through and re-render the template — and WTForms hands
the form back with per-field error messages 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. That {{ form.csrf_token }} line is the
piece we explain next — and it's the reason Flask-WTF is worth adopting even for small forms.
💡 The honest 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 users submit
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 — because the browser helpfully attaches your user's 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 user's identity attached. The
fix is a secret the attacker can't know: Flask-WTF embeds a per-session CSRF token in your form
({{ form.csrf_token }}) and rejects any POST whose token is missing or wrong. The attacker's forged form
can't include a token it never saw, so it bounces.
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 too: 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: when you
use a FlaskForm and render {{ form.csrf_token }}, validate_on_submit() checks the token automatically,
so you can't forget. (Set app.secret_key — the same one flashing needs — because that's what signs the
token.)
💡 The takeaway to internalize: anything that writes — creates, edits, deletes — must be validated and
CSRF-protected. Read-only GETs are exempt (they shouldn't change state anyway), 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.
You can now take data in safely. 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."
}
]
← Phase 3: Templates with Jinja2 · Guide overview · Phase 5: Working with a Database →
Check your understanding
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?