Skip to content

Introduction to Flask-WTF

Reading request.form manually works, but it gets repetitive and error-prone.

Flask-WTF is a popular extension that provides:

  • form classes
  • built-in validators
  • CSRF protection
  • easy rendering helpers

Under the hood it builds on WTForms.

bash
pip install Flask-WTF

CSRF protection requires a SECRET_KEY.

python
from flask import Flask
 
app = Flask(__name__)
app.config["SECRET_KEY"] = "change-this-in-real-apps"

In production, set SECRET_KEY from an environment variable.

Instead of reading arbitrary strings from request.form, you work with a Form object:

  • fields are defined in Python
  • validators run consistently
  • errors are structured and easy to display

This dramatically improves maintainability.

The request cycle a form actually goes through

Section titled “The request cycle a form actually goes through”

One view handles both showing the form and receiving it. What separates the two is whether the request was a submission:

diagram Diagram mermaid
signup.py
@app.route("/signup", methods=["GET", "POST"])
def signup():
    form = SignupForm()
    if form.validate_on_submit():          # False on GET, so this is the whole test
        create_user(form.name.data)
        return redirect(url_for("done"))   # redirect after a successful POST
    return render_template("signup.html", form=form)

Measured:

requestis_submitted()validate_on_submit()
GET /signupFalseFalse
POST /signupTrueruns validation, then True or False

validate_on_submit() is exactly is_submitted() and validate(). That is why the single if handles both directions, and why you never need to inspect request.method yourself.

CSRF protection is the reason to use Flask-WTF at all

Section titled “CSRF protection is the reason to use Flask-WTF at all”

A FlaskForm adds a signed, per-session token to every form. A POST without it fails validation:

no_token.py
client.post("/signup", data={"name": "ada", "email": "a@b.co"})
# form.errors -> {'csrf_token': ['The CSRF token is missing.']}

Note what that is not: it is not a crash and not a 403 by default. It is an ordinary validation error alongside any other, so your existing error-rendering handles it.

diagram Diagram mermaid

Without a token, any other site can make a logged-in user’s browser POST to your app. The token defeats that because a third-party page cannot read it.

email_validator.py
from wtforms.validators import Email
 
class SignupForm(FlaskForm):
    email = StringField("Email", validators=[Email()])

This imports and defines fine, then fails when a form is first validated:

measured
Exception: Install 'email_validator' for email validation support.

wtforms does not depend on email_validator, and Email() imports it lazily inside the validator. So the failure appears on the first POST rather than at startup — install it explicitly (pip install email_validator) and pin it with the rest.

sketch One view, two paths p5.js
validate_on_submit is is_submitted() and validate(). GET short-circuits on the first half, so the same view renders the form and processes it.
pch.quizTag pch.quizDefaultTitle
  1. What exactly does form.validate_on_submit() evaluate?

    pch.quizShowAnswer

    B — is_submitted() and validate(), so it is False on a GET without running validators — Measured: on GET, is_submitted() is False and validate_on_submit() is False. That short-circuit is why one view can both render and process the form.

  2. A POST arrives with no CSRF token. What happens by default?

    pch.quizShowAnswer

    C — an ordinary validation error appears as form.errors with the key csrf_token — Measured form.errors was a dict containing csrf_token mapped to 'The CSRF token is missing.'. It flows through the same error rendering as any other field.

  3. Using the Email() validator raises 'Install email_validator for email validation support' on the first POST. Why not at import?

    pch.quizShowAnswer

    B — Email() imports email_validator lazily inside the validator call, and wtforms does not depend on it — The import happens when the validator runs, so a form class defines cleanly and fails on first use. Install and pin email_validator explicitly.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading