Just added: Algorithms you can run and practice
Updated Jul 10, 2026 Edit on GitHub

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:

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

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

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.

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

{% 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) 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.

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:

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

  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:

[
  {
    "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?