Difficulty: Intermediate
Flask-WTF integrates the WTForms library with Flask, providing form handling, validation, and CSRF protection. Forms are one of the primary ways users interact with web applications, and handling them securely and correctly is essential.
WTForms is a flexible form rendering and validation library for Python. Flask-WTF wraps it with Flask-specific features: CSRF protection (enabled by default), file upload handling, reCAPTCHA support, and integration with Flask's request context. Together, they make form handling clean and secure.
Forms are defined as Python classes that inherit from FlaskForm. Each form field is a class attribute using WTForms field types: StringField, PasswordField, BooleanField, TextAreaField, SelectField, IntegerField, etc. Each field accepts validators that check the submitted data.
In a view function, you instantiate the form and call validate_on_submit(). This method returns True only if the request is POST (or PUT/PATCH) AND all validators pass. If it returns True, you can safely access the submitted data via form.field_name.data. If it returns False on a POST, the form's field.errors contain validation error messages.
Rendering forms in Jinja2 templates is straightforward. Each field renders as an HTML input when called: {{ form.username() }}. You can pass HTML attributes as keyword arguments: {{ form.username(class='form-control', placeholder='Enter username') }}. The {{ form.username.label }} renders the label tag. The {{ form.hidden_tag() }} renders CSRF token and any other hidden fields.
Flask-WTF requires a SECRET_KEY to generate CSRF tokens. Without it, form submissions will fail with a CSRF error. The CSRF token is embedded in the form as a hidden field and validated on submission, protecting against Cross-Site Request Forgery attacks.
For API endpoints that receive JSON instead of form data, you typically do not use WTForms. Flask-WTF forms are designed for HTML form submissions. For JSON APIs, use manual validation or a library like marshmallow or pydantic.
from flask import Flask, render_template, redirect, url_for, flash
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Email, Length
app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'
class LoginForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[DataRequired()])
remember = BooleanField('Remember Me')
submit = SubmitField('Log In')
@app.route('/login', methods=['GET', 'POST'])
def login():
form = LoginForm()
if form.validate_on_submit():
email = form.email.data
password = form.password.data
remember = form.remember.data
# Authenticate user...
flash('Logged in successfully!', 'success')
return redirect(url_for('index'))
return render_template('login.html', form=form)
Forms are classes with field attributes. validate_on_submit() returns True only on POST with valid data. Access submitted data with form.field.data. Validators like DataRequired and Email check the input.
<!-- templates/login.html -->
{% extends 'base.html' %}
{% block content %}
<form method="POST">
{{ form.hidden_tag() }}
<div class="form-group">
{{ form.email.label }}
{{ form.email(class='form-control', placeholder='your@email.com') }}
{% for error in form.email.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="form-group">
{{ form.password.label }}
{{ form.password(class='form-control') }}
{% for error in form.password.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="form-check">
{{ form.remember(class='form-check-input') }}
{{ form.remember.label }}
</div>
{{ form.submit(class='btn btn-primary') }}
</form>
{% endblock %}
{{ form.hidden_tag() }} renders the CSRF token. Fields are called with HTML attributes as kwargs. form.field.errors contains validation error messages to display to the user.
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, message='Username must be 3-20 characters')
])
email = StringField('Email', validators=[
DataRequired(),
Email(message='Invalid email address')
])
password = PasswordField('Password', validators=[
DataRequired(),
Length(min=8, message='Password must be at least 8 characters')
])
confirm_password = PasswordField('Confirm Password', validators=[
DataRequired(),
EqualTo('password', message='Passwords must match')
])
submit = SubmitField('Register')
Validators are chained in a list. Length checks string bounds. EqualTo compares two fields (for password confirmation). Each validator can have a custom error message.
from flask_wtf import FlaskForm
from wtforms import StringField, SelectField, SelectMultipleField, RadioField
from wtforms.validators import DataRequired
class ProfileForm(FlaskForm):
name = StringField('Name', validators=[DataRequired()])
country = SelectField('Country', choices=[
('us', 'United States'),
('uk', 'United Kingdom'),
('ca', 'Canada'),
('au', 'Australia')
])
role = RadioField('Role', choices=[
('developer', 'Developer'),
('designer', 'Designer'),
('manager', 'Manager')
])
skills = SelectMultipleField('Skills', choices=[
('python', 'Python'),
('javascript', 'JavaScript'),
('sql', 'SQL')
])
SelectField renders a dropdown, RadioField renders radio buttons, and SelectMultipleField allows multiple selections. Choices are tuples of (value, label). The submitted data is the value part.
Flask-WTF, WTForms, FlaskForm, validate_on_submit, Form Rendering