Flask WTForms Validation — Core Concepts
What Flask-WTF actually does
Flask-WTF is a thin integration layer between Flask and WTForms. WTForms handles form definition and validation. Flask-WTF adds three things: CSRF protection, file upload support, and integration with Flask’s request object.
When you create a form class, you’re defining both the structure (what fields exist) and the rules (what makes data valid). The validation runs server-side, giving you a reliable last line of defense regardless of what the client sends.
Form definition and validation flow
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField
from wtforms.validators import DataRequired, Email, Length
class RegistrationForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[
DataRequired(), Length(min=8, max=128)
])
username = StringField('Username', validators=[
DataRequired(), Length(min=3, max=30)
])
In your view:
@app.route('/register', methods=['GET', 'POST'])
def register():
form = RegistrationForm()
if form.validate_on_submit():
# All validators passed, data is in form.email.data, etc.
create_user(form.email.data, form.password.data)
return redirect(url_for('login'))
return render_template('register.html', form=form)
validate_on_submit() does two things: checks if the request method is POST (or PUT/PATCH), then runs every validator on every field. If any validator fails, it returns False and populates form.errors.
Built-in validators
WTForms ships with validators for common checks:
| Validator | What it checks |
|---|---|
DataRequired() | Field is not empty |
Email() | Valid email format |
Length(min, max) | String length bounds |
NumberRange(min, max) | Numeric value bounds |
EqualTo('field') | Matches another field (password confirmation) |
Regexp(pattern) | Matches a regex |
URL() | Valid URL format |
Optional() | Allows empty (skips other validators if empty) |
Validators run in order. Put Optional() first if the field can be blank — otherwise DataRequired() will reject it before other validators run.
Custom validators
Two approaches exist for custom validation logic.
Inline validators — methods named validate_<fieldname> on the form class:
class RegistrationForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
def validate_email(self, field):
if User.query.filter_by(email=field.data).first():
raise ValidationError('Email already registered.')
Flask-WTF automatically calls these during validation. They’re perfect for checks that need database access or form-specific context.
Reusable validators — standalone functions for rules used across multiple forms:
def unique_email(form, field):
if User.query.filter_by(email=field.data).first():
raise ValidationError('Email already registered.')
class RegistrationForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email(), unique_email])
CSRF protection
Every form rendered through Flask-WTF includes a hidden CSRF token. When the form is submitted, Flask-WTF verifies the token matches the user’s session. This prevents cross-site request forgery — where a malicious site tricks a user’s browser into submitting forms to your app.
In templates, include the token:
<form method="POST">
{{ form.hidden_tag() }}
{{ form.email.label }} {{ form.email() }}
<button type="submit">Register</button>
</form>
form.hidden_tag() renders the CSRF token field. Without it, form submission fails with a 400 error.
For AJAX requests, you can include the token in a header instead:
fetch('/api/update', {
method: 'POST',
headers: { 'X-CSRFToken': csrfToken },
body: JSON.stringify(data)
});
Error display
After failed validation, form.errors contains a dictionary of field names to error messages:
{% for field in form %}
{{ field.label }} {{ field() }}
{% for error in field.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
{% endfor %}
This keeps error messages next to the fields they belong to, giving users clear guidance on what to fix.
Common misconception
“Client-side validation makes server-side unnecessary.” Client-side validation (JavaScript) improves user experience — instant feedback without a page reload. But it can be bypassed by anyone with browser dev tools. Server-side validation with WTForms is the security boundary. Use both: client-side for convenience, server-side for safety.
Data conversion
Form fields automatically convert submitted strings to Python types. IntegerField converts to int, FloatField to float, BooleanField to bool. If conversion fails, a validation error is raised. This means by the time your view function accesses form.field.data, the types are already correct.
One thing to remember: WTForms validates after conversion. Define your fields with the right type, add validators for business rules, and check validate_on_submit() before accessing data. The form is your contract: if validation passes, the data conforms to your expectations.
See Also
- Python Django Admin Get an intuitive feel for Django Admin so Python behavior stops feeling unpredictable.
- Python Django Basics Get an intuitive feel for Django Basics so Python behavior stops feeling unpredictable.
- Python Django Celery Integration Why your Django app needs a helper to handle slow jobs in the background.
- Python Django Channels Websockets How Django can send real-time updates to your browser without you refreshing the page.
- Python Django Custom Management Commands How to teach Django new tricks by creating your own command-line shortcuts.