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.
Install
Section titled “Install”pip install Flask-WTFConfigure a secret key
Section titled “Configure a secret key”CSRF protection requires a SECRET_KEY.
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.
Key idea
Section titled “Key idea”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.
🧪 Try It Yourself
Section titled “🧪 Try It Yourself”Exercise 1 – Create a Flask App
Section titled “Exercise 1 – Create a Flask App”Exercise 2 – Dynamic Route
Section titled “Exercise 2 – Dynamic Route”Exercise 3 – Return JSON
Section titled “Exercise 3 – Return JSON”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:
flowchart TD
R["request to /signup"] --> M{"method"}
M -->|"GET"| G["is_submitted() is False
validate_on_submit() is False
render the empty form"]
M -->|"POST"| S["is_submitted() is True"]
S --> V{"validate() passes?"}
V -->|"yes"| OK["process the data, then REDIRECT"]
V -->|"no"| E["re-render the form
with form.errors populated"]
@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:
| request | is_submitted() | validate_on_submit() |
|---|---|---|
GET /signup | False | False |
POST /signup | True | runs 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:
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.
flowchart LR S["server renders the form"] --> T["hidden csrf_token
signed with SECRET_KEY"] T --> B["browser submits it back"] B --> C{"token valid for this session?"} C -->|"yes"| P["process"] C -->|"no or missing"| R["validation error"]
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.
A dependency that is not declared
Section titled “A dependency that is not declared”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:
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.
See it move
Section titled “See it move”Check yourself
Section titled “Check yourself”-
What exactly does form.validate_on_submit() evaluate?
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.
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.
-
A POST arrives with no CSRF token. What happens by default?
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.
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.
-
Using the Email() validator raises 'Install email_validator for email validation support' on the first POST. Why not at import?
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.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.coffeeCtapch.feedbackHeading
pch.feedbackSubheading