Updated Jun 22, 2026

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:

<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), 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:

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 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(...)).

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. 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.

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. 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() %}
  <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 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.

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 (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 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. 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

  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.

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?

Was this page helpful?