📝 Forms Handling with Flask-WTF
Every registration, login, comment box, and checkout is a form — and forms are where untrusted data enters your app. This lesson shows you how Flask-WTF turns dozens of lines of hand-written validation into a clean Python class, hands you CSRF protection for free, and makes rendering and error handling a pleasure instead of a chore.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain why raw form handling is error-prone and what Flask-WTF and WTForms add
- Define a form as a Python class with fields and built-in validators
- Wire a form into a view with
validate_on_submit()and the POST/redirect/GET pattern - Render fields in Jinja2 (including the crucial CSRF token) and display validation errors
- Write custom validators and handle secure file uploads
Estimated Time: 40–50 minutes • Difficulty: Intermediate
Hands-on: Build a complete registration form with custom validation and error display.
In This Lesson
The Problem With Raw Forms
Forms are the primary way users push data into a web application: signing up, posting a comment, searching, checking out. Because that data comes from outside your control, a good form system has to do several jobs at once — present clear fields, validate what comes back, guard against attacks, and report errors helpfully.
You can do all of that by hand with request.form, but it gets ugly fast. Here is a registration handler written the manual way:
@app.route('/register', methods=['GET', 'POST'])
def register_without_framework():
errors = {}
username = ''
email = ''
if request.method == 'POST':
username = request.form.get('username', '')
email = request.form.get('email', '')
password = request.form.get('password', '')
confirm = request.form.get('confirm', '')
# Validate every field, one condition at a time
if not username:
errors['username'] = 'Username is required'
elif len(username) < 3:
errors['username'] = 'Username must be at least 3 characters'
if '@' not in email:
errors['email'] = 'Invalid email format'
if len(password) < 8:
errors['password'] = 'Password must be at least 8 characters'
if password != confirm:
errors['confirm'] = 'Passwords do not match'
if not errors:
flash('Registration successful!')
return redirect(url_for('login'))
# We must manually pass every value back so the form isn't wiped
return render_template('register.html',
username=username, email=email, errors=errors)
Notice the problems: repetitive if checks, values passed back by hand so the user doesn't lose their input, and — most dangerously — no CSRF protection at all. Multiply this across every form in a real app and it becomes unmaintainable. Flask-WTF replaces the whole pattern with a declarative form class.
What Flask-WTF Gives You
Flask-WTF is a thin, well-integrated bridge between Flask and WTForms — a mature Python library for defining, validating, and rendering forms. Together they give you:
- Declarative form classes: describe fields and rules once, in Python
- Automatic CSRF protection: a signed token is added and checked on every POST
- Server-side validation: reusable validators plus your own custom rules
- File-upload handling: with type and presence validators
- Sticky input & error messages: the form re-renders with what the user typed
📖 Key Terms
WTForms: the framework-agnostic library that defines fields and validators.
CSRF (Cross-Site Request Forgery): an attack where another site tricks a logged-in user's browser into submitting a request to your app. A per-session CSRF token proves the request came from your own form.
Validator: a small callable attached to a field that raises an error if the data is unacceptable.
💡 A useful analogy: Think of Flask-WTF as a restaurant's order system. Instead of every waiter re-checking each ticket by hand ("Did they say how they want the steak? Is that item even on the menu?"), the system enforces a standard form, rejects invalid orders before they reach the kitchen, and stamps each ticket so nobody can forge one.
Installation & Configuration
Install Flask-WTF (it pulls in WTForms automatically). To use the built-in email validator, also install the optional email-validator package:
pip install Flask-WTF email-validator
Flask-WTF needs a SECRET_KEY — it is used to sign the CSRF token. Set it once when you create the app:
import os
from flask import Flask
app = Flask(__name__)
app.config['SECRET_KEY'] = os.environ['SECRET_KEY'] # never hard-code this
⚠️ Never hard-code the secret key
The secret key protects cookies and CSRF tokens. If it leaks (for example, committed to Git), an attacker can forge sessions. Load it from an environment variable and generate a strong random value:
# Generate a good key once, then store it in your environment / .env file
python -c "import secrets; print(secrets.token_hex(32))"
When you enable CSRF at the app level, every POST is protected automatically:
from flask_wtf.csrf import CSRFProtect
csrf = CSRFProtect(app) # now all POST/PUT/DELETE requests require a valid token
You don't strictly need CSRFProtect when using FlaskForm — each form already includes a token — but enabling it globally also protects routes that receive AJAX or JSON POSTs.
Your First Form Class
With Flask-WTF you define a form as a class that inherits from FlaskForm. Each class attribute is a field; each field takes a label and a list of validators. Conventionally these live in a forms.py module:
# forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, Length, EqualTo
class RegistrationForm(FlaskForm):
username = StringField(
'Username',
validators=[DataRequired(), Length(min=3, max=20)],
)
email = StringField(
'Email',
validators=[DataRequired(), Email()],
)
password = PasswordField(
'Password',
validators=[DataRequired(), Length(min=8)],
)
confirm_password = PasswordField(
'Confirm Password',
validators=[DataRequired(), EqualTo('password', message='Passwords must match')],
)
submit = SubmitField('Sign Up')
Compare this to the manual version from Section 1 — the same four fields and rules, but the validation logic is now declared instead of hand-written. The validators here mean:
DataRequired()— the field cannot be blankLength(min=3, max=20)— must be 3–20 charactersEmail()— must look like a valid email addressEqualTo('password')— must match the value of thepasswordfield
Using Forms in Views
In your view you instantiate the form, then call validate_on_submit(). It returns True only when the request is a POST and every validator passes (CSRF included). This lets one view handle both showing the form and processing it:
from flask import render_template, flash, redirect, url_for
from forms import RegistrationForm
@app.route('/register', methods=['GET', 'POST'])
def register():
form = RegistrationForm()
if form.validate_on_submit():
# Submitted AND valid — safe to use the data
username = form.username.data
email = form.email.data
# ... create the user, hash the password, save to the database ...
flash(f'Account created for {username}!', 'success')
return redirect(url_for('login')) # POST/redirect/GET
# GET request, or validation failed — render (errors are attached to fields)
return render_template('register.html', form=form)
💡 Why redirect after a successful POST?
Returning a redirect (the POST/redirect/GET pattern) means the browser lands on a plain GET page. If the user hits refresh, they reload that page instead of re-submitting the form — no accidental double registrations.
Rendering Forms in Templates
WTForms doesn't dictate your HTML — you render each field yourself, which keeps full control over markup and CSS. The one non-negotiable is form.hidden_tag(), which outputs the hidden CSRF token. Forget it and every submission is rejected.
<form method="POST" action="{{ url_for('register') }}">
{{ form.hidden_tag() }} {# renders the CSRF token — required #}
<div class="form-group">
{{ form.username.label }}
{{ form.username(class="form-control") }}
{% if form.username.errors %}
<ul class="errors">
{% for error in form.username.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
Repeating that block for every field is tedious. Extract a Jinja2 macro once and reuse it:
{% macro render_field(field) %}
<div class="form-group">
{{ field.label }}
{{ field(class="form-control") }}
{% if field.errors %}
<ul class="errors">
{% for error in field.errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% endif %}
</div>
{% endmacro %}
<form method="POST" action="{{ url_for('register') }}">
{{ form.hidden_tag() }}
{{ render_field(form.username) }}
{{ render_field(form.email) }}
{{ render_field(form.password) }}
{{ render_field(form.confirm_password) }}
{{ form.submit(class="btn btn-primary") }}
</form>
Now a new field is one line in the template. This same macro pattern is how projects integrate framework styling (Bootstrap, Tailwind) consistently across every form.
Fields & Validation
WTForms ships a field type for nearly every HTML input and a validator for most common rules.
Common field types
| Field | Renders as | Used for |
|---|---|---|
StringField | <input type="text"> | Names, short text |
TextAreaField | <textarea> | Long text, descriptions |
PasswordField | <input type="password"> | Passwords |
BooleanField | <input type="checkbox"> | Agreements, toggles |
SelectField | <select> | Dropdowns |
IntegerField / DecimalField | <input type="number"> | Numbers |
DateField | <input type="date"> | Dates |
FileField | <input type="file"> | Uploads |
Built-in validators
| Validator | Purpose |
|---|---|
DataRequired() | Field must not be empty |
Email() | Must be a valid email address |
Length(min, max) | Enforce text length |
NumberRange(min, max) | Numeric value within a range |
EqualTo('other') | Must match another field |
Regexp(pattern) | Must match a regular expression |
URL() | Must be a valid URL |
Optional() | Skip validation if left blank |
Custom validation
When the built-ins aren't enough, add your own. The cleanest way is an in-form method named validate_<fieldname> — WTForms calls it automatically. This is the standard pattern for "is this username already taken?" checks:
from wtforms.validators import ValidationError
from models import User # your SQLAlchemy model
class RegistrationForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=20)])
email = StringField('Email', validators=[DataRequired(), Email()])
# ... password fields ...
def validate_username(self, field):
if User.query.filter_by(username=field.data).first():
raise ValidationError('That username is already taken.')
def validate_email(self, field):
if User.query.filter_by(email=field.data).first():
raise ValidationError('That email is already registered.')
For a rule you want to reuse across many forms, write a standalone validator instead. A class-based validator can even take arguments:
class NoReservedWords:
"""Reject values containing any reserved word."""
def __init__(self, words, message=None):
self.words = [w.lower() for w in words]
self.message = message or 'This value contains a reserved word.'
def __call__(self, form, field):
if any(w in field.data.lower() for w in self.words):
raise ValidationError(self.message)
class UsernameForm(FlaskForm):
username = StringField('Username', validators=[
DataRequired(),
NoReservedWords(['admin', 'root', 'superuser']),
])
Secure File Uploads
File uploads are a classic source of security holes. Flask-WTF provides FileField with validators that check presence and allowed extensions before the file ever touches disk:
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileRequired, FileAllowed
from wtforms import SubmitField
class PhotoForm(FlaskForm):
photo = FileField('Upload photo', validators=[
FileRequired(),
FileAllowed(['jpg', 'jpeg', 'png', 'gif'], 'Images only!'),
])
submit = SubmitField('Upload')
In the view, always sanitise the filename with Werkzeug's secure_filename() before saving:
import os
from werkzeug.utils import secure_filename
@app.route('/upload', methods=['GET', 'POST'])
def upload():
form = PhotoForm()
if form.validate_on_submit():
file = form.photo.data
filename = secure_filename(file.filename)
file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
flash('Photo uploaded!', 'success')
return redirect(url_for('index'))
return render_template('upload.html', form=form)
⚠️ File upload safety checklist
- Validate the extension with
FileAllowed— never trust the browser-supplied content type alone. - Run every filename through
secure_filename()to strip path traversal (../) tricks. - Cap the size:
app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024rejects anything over 5 MB. - Store uploads outside the web root (or on object storage like S3) so they can't be executed as scripts.
Hands-on Exercise
🏋️ Build a Registration Form for a Learning Platform
Objective: Create a working FlaskForm that combines multiple field types, built-in validators, and a custom validator.
Requirements:
- username — 3–20 characters, required, and must not be a reserved word (
admin,root). - email — required and valid.
- password — required, at least 8 characters, and must contain one uppercase, one lowercase, and one digit (use
Regexp). - confirm_password — must match
password. - education_level — a dropdown (
SelectField) with a few options. - accept_tos — a required checkbox.
💡 Hint
A regex enforcing the password rule looks like r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$'. Reuse the NoReservedWords validator from Section 7 for the username, and remember a required checkbox uses DataRequired().
✅ Sample solution
from flask_wtf import FlaskForm
from wtforms import (StringField, PasswordField, SelectField,
BooleanField, SubmitField)
from wtforms.validators import (DataRequired, Email, Length,
EqualTo, Regexp)
PASSWORD_RULE = Regexp(
r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$',
message='Need at least one uppercase, one lowercase, and one digit.',
)
class NoReservedWords:
def __init__(self, words):
self.words = [w.lower() for w in words]
def __call__(self, form, field):
from wtforms.validators import ValidationError
if field.data.lower() in self.words:
raise ValidationError('That username is reserved.')
class SignUpForm(FlaskForm):
username = StringField('Username', validators=[
DataRequired(), Length(min=3, max=20),
NoReservedWords(['admin', 'root']),
])
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[
DataRequired(), Length(min=8), PASSWORD_RULE,
])
confirm_password = PasswordField('Confirm password', validators=[
DataRequired(), EqualTo('password', message='Passwords must match'),
])
education_level = SelectField('Education level', choices=[
('hs', 'High School'),
('ba', "Bachelor's"),
('ma', "Master's"),
('other', 'Other'),
])
accept_tos = BooleanField('I accept the Terms of Service',
validators=[DataRequired()])
submit = SubmitField('Create account')
🎯 Quick Quiz
Question 1: What does {{ form.hidden_tag() }} output that makes it essential?
Question 2: When does form.validate_on_submit() return True?
Question 3: You want to reject usernames that already exist in the database. What is the idiomatic Flask-WTF approach?
Summary & Quiz
🎉 Key Takeaways
- Flask-WTF + WTForms replace hand-written form handling with a declarative form class.
- Fields and validators are declared once;
validate_on_submit()checks them (and CSRF) in the view. - Always render
form.hidden_tag()— it carries the CSRF token that protects every POST. - Follow the POST/redirect/GET pattern to avoid duplicate submissions.
- Add
validate_<field>methods for custom rules, and usesecure_filename()for uploads.
📚 Further Reading
- Flask-WTF documentation
- WTForms documentation — fields & validators
- OWASP — Cross-Site Request Forgery
🚀 What's Next?
Your forms now collect clean, validated data — but where does it go? Next we connect Flask to a database with Flask-SQLAlchemy, so a submitted form becomes a saved record.
🎉 Nice work!
You can now build secure, maintainable forms. Let's give them somewhere to store their data.