Flask WTForms Validation — Deep Dive
Validation pipeline internals
When form.validate() runs, it executes in this precise order:
- Pre-validation — Each field calls its
pre_validate()hook (rarely used) - Field-level validators — Each validator in the field’s
validatorslist runs in order - Form-level validators — Methods named
validate_<fieldname>run after field validators - Post-validation — The form’s
validate()method can add cross-field checks
If a field-level validator raises StopValidation, remaining validators for that field are skipped. This is how Optional() works: when the field is empty, it raises StopValidation to bypass other validators.
class Optional:
def __call__(self, form, field):
if not field.raw_data or not field.raw_data[0].strip():
field.errors[:] = []
raise StopValidation()
Understanding this pipeline lets you control execution flow precisely — short-circuiting expensive validations (like database lookups) when cheap checks (like format) fail first.
Dynamic form generation
Sometimes forms can’t be defined statically. A survey builder, a configurable admin panel, or a form with fields based on database records all need dynamic forms.
def build_survey_form(questions):
class SurveyForm(FlaskForm):
pass
for q in questions:
field_name = f'question_{q.id}'
if q.type == 'text':
field = StringField(q.text, validators=[DataRequired()])
elif q.type == 'choice':
choices = [(c.id, c.text) for c in q.choices]
field = SelectField(q.text, choices=choices)
elif q.type == 'number':
field = IntegerField(q.text, validators=[
NumberRange(min=q.min_val, max=q.max_val)
])
setattr(SurveyForm, field_name, field)
return SurveyForm
Usage in a view:
@app.route('/survey/<int:survey_id>', methods=['GET', 'POST'])
def take_survey(survey_id):
survey = Survey.query.get_or_404(survey_id)
FormClass = build_survey_form(survey.questions)
form = FormClass()
if form.validate_on_submit():
save_responses(survey, form)
return redirect(url_for('thanks'))
return render_template('survey.html', form=form)
The key is that setattr adds fields to the class before instantiation. WTForms inspects class attributes during __init__, so fields added after instantiation won’t work.
FormField and FieldList for nested structures
WTForms supports nested forms through FormField and repeating groups through FieldList:
class AddressForm(FlaskForm):
class Meta:
csrf = False # Nested forms shouldn't have their own CSRF
street = StringField('Street', validators=[DataRequired()])
city = StringField('City', validators=[DataRequired()])
zip_code = StringField('ZIP', validators=[DataRequired(), Length(max=10)])
class OrderForm(FlaskForm):
shipping = FormField(AddressForm)
items = FieldList(StringField('Item', validators=[DataRequired()]),
min_entries=1, max_entries=20)
FormField embeds one form inside another. The nested form’s data appears under the parent field’s namespace: form.shipping.street.data.
FieldList creates a repeating group. The HTML name attributes include indices (items-0, items-1), and JavaScript can add/remove entries dynamically. The min_entries and max_entries parameters control bounds.
Gotcha: Nested FlaskForm subclasses include CSRF tokens by default, which creates multiple tokens in one form. Set csrf = False in the nested form’s Meta class.
Multi-step form wizards
Flask doesn’t have built-in wizard support, but the pattern is straightforward using sessions:
class Step1Form(FlaskForm):
name = StringField('Name', validators=[DataRequired()])
email = StringField('Email', validators=[DataRequired(), Email()])
class Step2Form(FlaskForm):
address = StringField('Address', validators=[DataRequired()])
city = StringField('City', validators=[DataRequired()])
class Step3Form(FlaskForm):
card_number = StringField('Card', validators=[DataRequired()])
@app.route('/checkout/step/<int:step>', methods=['GET', 'POST'])
def checkout(step):
forms = {1: Step1Form, 2: Step2Form, 3: Step3Form}
FormClass = forms.get(step)
if not FormClass:
abort(404)
form = FormClass()
if form.validate_on_submit():
# Store validated data in session
session[f'step_{step}'] = {
field.name: field.data
for field in form
if field.name != 'csrf_token'
}
if step < 3:
return redirect(url_for('checkout', step=step + 1))
else:
# All steps complete
return process_order(session)
# Pre-populate from session if user goes back
saved = session.get(f'step_{step}', {})
if saved and request.method == 'GET':
form = FormClass(data=saved)
return render_template(f'checkout_step{step}.html', form=form, step=step)
Session storage keeps data between steps. Users can navigate back and forward without losing progress. The final submission collects data from all session keys.
Custom widgets
Widgets control how fields render as HTML. Override them for custom UI components:
from wtforms.widgets import html_params
from markupsafe import Markup
class StarRatingWidget:
def __call__(self, field, **kwargs):
kwargs.setdefault('id', field.id)
html = f'<div class="star-rating" {html_params(**kwargs)}>'
for i in range(1, 6):
checked = 'checked' if field.data and int(field.data) >= i else ''
html += (
f'<input type="radio" name="{field.name}" '
f'value="{i}" {checked}>'
f'<label>★</label>'
)
html += '</div>'
return Markup(html)
class RatingField(IntegerField):
widget = StarRatingWidget()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.validators.append(NumberRange(min=1, max=5))
Custom widgets separate presentation from validation logic. The field handles data conversion and validation; the widget handles HTML rendering.
Cross-field validation
Validating relationships between fields requires form-level logic:
class DateRangeForm(FlaskForm):
start_date = DateField('Start', validators=[DataRequired()])
end_date = DateField('End', validators=[DataRequired()])
def validate_end_date(self, field):
if field.data <= self.start_date.data:
raise ValidationError('End date must be after start date.')
def validate(self, extra_validators=None):
if not super().validate(extra_validators=extra_validators):
return False
delta = self.end_date.data - self.start_date.data
if delta.days > 365:
self.end_date.errors.append('Date range cannot exceed one year.')
return False
return True
The validate_end_date method handles simple cross-field checks. Override validate() itself for complex multi-field logic that doesn’t belong to any single field.
File upload validation
Flask-WTF extends WTForms with file upload support:
from flask_wtf.file import FileField, FileRequired, FileAllowed, FileSize
class UploadForm(FlaskForm):
avatar = FileField('Avatar', validators=[
FileRequired(),
FileAllowed(['jpg', 'png', 'gif'], 'Images only!'),
FileSize(max_size=5 * 1024 * 1024) # 5MB
])
FileAllowed checks the file extension (not the MIME type — extensions can be spoofed). For true content validation, use a library like python-magic:
import magic
def validate_image_content(form, field):
mime = magic.from_buffer(field.data.read(2048), mime=True)
field.data.seek(0) # Reset stream position
if mime not in ('image/jpeg', 'image/png', 'image/gif'):
raise ValidationError(f'Invalid file type: {mime}')
CSRF for APIs and SPAs
Traditional CSRF tokens work with server-rendered forms. For SPAs consuming a Flask API:
from flask_wtf.csrf import CSRFProtect, generate_csrf
csrf = CSRFProtect(app)
@app.route('/api/csrf-token')
def get_csrf():
return jsonify(csrf_token=generate_csrf())
# Exempt specific API endpoints
@csrf.exempt
@app.route('/api/webhook', methods=['POST'])
def webhook():
# Webhook handlers use signature verification instead
verify_webhook_signature(request)
return process_webhook(request.json)
The SPA fetches a CSRF token from the endpoint and includes it in subsequent POST requests as X-CSRFToken header. The token is tied to the session cookie.
For token-based APIs (JWT/Bearer), CSRF protection is unnecessary — the authentication header serves as the CSRF mitigation. Exempt those routes.
Performance: validation caching
For forms with expensive validators (database queries, external API calls), cache validation results:
class RegistrationForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
def validate_email(self, field):
# Cache the lookup result on the form instance
if not hasattr(self, '_email_user'):
self._email_user = User.query.filter_by(email=field.data).first()
if self._email_user:
raise ValidationError('Email already registered.')
This prevents duplicate queries when validation runs multiple times (e.g., re-validation after form modification).
Testing forms
Test validation logic independently of views:
def test_registration_requires_email():
app = create_app('testing')
with app.test_request_context():
form = RegistrationForm(data={
'username': 'alice',
'password': 'securepass123'
# email missing
})
form.validate()
assert 'email' in form.errors
def test_registration_rejects_duplicate_email(app, db_session):
user = User(email='alice@example.com')
db_session.add(user)
db_session.commit()
with app.test_request_context():
form = RegistrationForm(data={
'email': 'alice@example.com',
'username': 'alice2',
'password': 'securepass123'
})
# Disable CSRF for unit tests
form.meta.csrf = False
assert not form.validate()
assert 'already registered' in form.errors['email'][0]
Testing forms in isolation (without HTTP requests) runs faster and isolates validation logic from routing logic.
One thing to remember: WTForms validation is a pipeline — field-level validators run in order, then form-level methods, with StopValidation for short-circuiting. Every advanced pattern (dynamic forms, wizards, cross-field validation) builds on this pipeline. Master the execution order, and complex form architectures become predictable.
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.