π Forms Handling with Flask-WTF
Forms are where users hand your app their data β and where a lot can go wrong: missing fields, bad emails, and CSRF attacks. Flask-WTF wraps the WTForms library to give you declarative form classes, automatic validation, and built-in security, so you write far less boilerplate and far fewer bugs.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Install and configure Flask-WTF, including the required
SECRET_KEY - Define a form class with fields and validators
- Process submissions with the
validate_on_submit()workflow and the Post/Redirect/Get pattern - Render form fields, labels, and errors in a Jinja2 template
- Explain how CSRF protection works and write a custom validator
Estimated Time: 40β50 minutes β’ Difficulty: Intermediate
Hands-on: Build a complete, validated contact form end to end.
In This Lesson
Why Form Libraries Exist
Handling a form by hand means juggling a lot of tedious, security-sensitive work: rendering the right HTML, reading POST data, checking every field, showing error messages, and defending against forgery. Do any of it wrong and you have a broken β or worse, exploitable β form.
Flask-WTF is a thin bridge between Flask and the WTForms library. You declare a form once as a Python class, and it handles rendering hints, validation, error collection, and CSRF tokens for you.
π‘ A useful analogy: A form class is a bouncer with a guest list. You write the rules once ("name required, email must look real, message at least 10 characters"), and the bouncer checks every submission at the door, turning away bad data with a clear reason before it ever reaches your business logic.
Installing & Configuring Flask-WTF
Install the package (it pulls WTForms in as a dependency):
pip install Flask-WTF
Flask-WTF's CSRF protection and WTForms both need Flask's SECRET_KEY to sign tokens. Using the application factory pattern, load the key from the environment so it never lands in version control:
import os
from flask import Flask
def create_app():
app = Flask(__name__)
# In production, SECRET_KEY MUST come from the environment.
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", os.urandom(32))
from .routes import main
app.register_blueprint(main)
return app
β οΈ Guard your SECRET_KEY
The SECRET_KEY signs session cookies and CSRF tokens. Use a long, random value, load it from an environment variable, and never commit it. Using os.urandom() as shown is fine for development, but it changes on every restart β set a fixed key in production so existing sessions survive a redeploy.
Defining Form Classes
A form is a Python class that subclasses FlaskForm. Each field is a class attribute; each carries a label and a list of validators:
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Email, Length
class ContactForm(FlaskForm):
name = StringField("Name", validators=[DataRequired(), Length(min=2, max=50)])
email = StringField("Email", validators=[DataRequired(), Email()])
message = TextAreaField("Message", validators=[DataRequired(), Length(min=10)])
subscribe = BooleanField("Subscribe to our newsletter")
submit = SubmitField("Send message")
π Key Terms
Field: one input in the form (text, checkbox, select, etc.), declared as a class attribute.
Validator: a rule that a field's value must satisfy, e.g. DataRequired().
CSRF token: a hidden, signed value that proves a submission came from your own form.
Fields & Validators
Common field types
| WTForms field | Purpose | HTML equivalent |
|---|---|---|
StringField | Single-line text | <input type="text"> |
TextAreaField | Multi-line text | <textarea> |
PasswordField | Masked input | <input type="password"> |
BooleanField | Checkbox | <input type="checkbox"> |
SelectField | Dropdown | <select> |
IntegerField | Whole number | <input type="number"> |
SubmitField | Submit button | <input type="submit"> |
Common validators
| Validator | Enforces |
|---|---|
DataRequired() | Field must not be empty |
Email() | Looks like a valid email (needs email-validator installed) |
Length(min=, max=) | Text length within bounds |
NumberRange(min=, max=) | Number within bounds |
EqualTo("other") | Matches another field (e.g. confirm password) |
Optional() | Skip remaining validators if empty |
π‘ The Email() validator has a dependency
WTForms' Email() validator relies on the separate email-validator package. Install it with pip install email-validator, or you'll get an import error at validation time.
Handling Forms in Routes
In the view, instantiate the form and call validate_on_submit(). It returns True only when the request is a POST and every validator passed β the perfect single check for "is this a valid submission?"
from flask import Blueprint, render_template, redirect, url_for, flash
from .forms import ContactForm
main = Blueprint("main", __name__)
@main.route("/contact", methods=["GET", "POST"])
def contact():
form = ContactForm()
if form.validate_on_submit():
# All validators passed β access data via form.<field>.data
flash(f"Thanks, {form.name.data}! We'll reply to {form.email.data}.", "success")
# Redirect after POST so a refresh won't resubmit the form.
return redirect(url_for("main.contact"))
# GET request, or validation failed: render (with errors, if any).
return render_template("contact.html", form=form)
That redirect on success is the Post/Redirect/Get (PRG) pattern. Without it, a user who refreshes after submitting would re-POST the form and duplicate the action. Redirecting to a GET page makes refresh harmless.
Rendering Forms in Templates
In the template, {{ form.hidden_tag() }} emits the CSRF token (and any hidden fields). Each field renders its label and input; loop over field.errors to show validation messages:
{% extends "base.html" %}
{% block title %}Contact Us{% endblock %}
{% block content %}
<h1>Contact Us</h1>
{% include "partials/flashes.html" %}
<form method="post" novalidate>
{{ form.hidden_tag() }} {# CSRF token β required #}
<div class="form-group">
{{ form.name.label }}
{{ form.name(class="form-control") }}
{% for error in form.name.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
<div class="form-group">
{{ form.email.label }}
{{ form.email(class="form-control") }}
{% for error in form.email.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
<div class="form-group">
{{ form.message.label }}
{{ form.message(class="form-control", rows=5) }}
{% for error in form.message.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
<div class="form-check">
{{ form.subscribe() }} {{ form.subscribe.label }}
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
{% endblock %}
β Cut the repetition with a macro
Every field's markup is nearly identical. Recall macros from the Jinja2 lesson β extract one render_field macro and call it per field:
{% macro render_field(field) %}
<div class="form-group">
{{ field.label }}
{{ field(class="form-control") }}
{% for error in field.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
{% endmacro %}
{{ render_field(form.name) }}
{{ render_field(form.email) }}
{{ render_field(form.message) }}
CSRF & Custom Validators
What CSRF protection does
Cross-Site Request Forgery tricks a logged-in user's browser into submitting a request they didn't intend β say, a hidden form on a malicious site posting to your /transfer-money route. Flask-WTF defends against this by embedding a secret, per-session token in every form via form.hidden_tag(). On submission it checks the token; a forged request from another site can't know it, so it's rejected.
The token in the rendered HTML
<input type="hidden" name="csrf_token"
value="IjXk9β¦signedβ¦value">
Custom validators
For rules the built-ins don't cover β like a database uniqueness check β add a method named validate_<fieldname> to the form class. WTForms calls it automatically and a raised ValidationError becomes a field error:
from wtforms.validators import ValidationError
from .models import User
class RegistrationForm(FlaskForm):
username = StringField("Username", validators=[DataRequired(), Length(min=3, max=20)])
password = PasswordField("Password", validators=[DataRequired(), Length(min=8)])
confirm = PasswordField(
"Confirm password",
validators=[DataRequired(), EqualTo("password", message="Passwords must match.")],
)
submit = SubmitField("Register")
def validate_username(self, field):
if User.query.filter_by(username=field.data).first():
raise ValidationError("That username is already taken.")
β οΈ Always validate on the server
HTML attributes like required and type="email" improve the user experience, but a determined client can bypass them entirely. Server-side validation via WTForms is your real guarantee β never trust the browser alone.
Hands-on Exercise
ποΈ Build a Validated Contact Form
Objective: Wire a form class, a route, and a template into a working, validated contact form.
Instructions:
- Create a
FeedbackFormwith aname(required, 2β50 chars), anemail(required, valid email), arating(SelectFieldwith choices 1β5), and acommentstextarea (optional, max 500). - Set
SECRET_KEYin your app factory and installemail-validator. - Add a
/feedbackroute usingvalidate_on_submit()and the PRG pattern; flash a thank-you on success. - Render the form with
{{ form.hidden_tag() }}, labels, inputs, and per-field errors. - Submit with an empty name and a bad email to confirm the error messages appear.
π‘ Hint
SelectField choices are a list of (value, label) tuples: choices=[("5", "Excellent"), ("4", "Good"), β¦]. Use Optional() as the first validator on comments so an empty box doesn't trigger the length check.
β Sample solution (form class)
from flask_wtf import FlaskForm
from wtforms import StringField, SelectField, TextAreaField, SubmitField
from wtforms.validators import DataRequired, Email, Length, Optional
class FeedbackForm(FlaskForm):
name = StringField("Name", validators=[DataRequired(), Length(min=2, max=50)])
email = StringField("Email", validators=[DataRequired(), Email()])
rating = SelectField(
"Rating",
choices=[("5", "Excellent"), ("4", "Good"), ("3", "Okay"),
("2", "Poor"), ("1", "Bad")],
validators=[DataRequired()],
)
comments = TextAreaField("Comments", validators=[Optional(), Length(max=500)])
submit = SubmitField("Send feedback")
π― Quick Quiz
Question 1: What does form.validate_on_submit() return True for?
Question 2: Why redirect after a successful form submission (the PRG pattern)?
Question 3: Which template call outputs the CSRF token needed for a valid POST?
Summary & Quiz
π Key Takeaways
- Flask-WTF + WTForms turn forms into declarative Python classes with fields and validators.
- A configured
SECRET_KEYis required for CSRF protection and sessions. validate_on_submit()is the one check for "valid POST"; follow success with the PRG redirect.- Templates render fields with
form.hidden_tag(), labels, inputs, andfield.errors. - CSRF tokens block forged submissions;
validate_<field>methods add custom rules. Always validate on the server.
π Further Reading
π What's Next?
Your forms now collect clean, validated data β but that data needs somewhere to live. Next we'll persist it with the Flask-SQLAlchemy extension, mapping Python objects to database rows.
π Great work!
Secure, validated input is a core backend skill. Let's give that data a home in a database.