Form Validation with Flask-WTF & WTForms
Reviewed & published by Brayan K
WTForms is a Python library that declares web forms as classes with typed fields and validators, automatically checking submitted data and collecting error messages for you.
Part of the free Flask course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
In this lesson you'll declare a form, attach validators like DataRequired and Length, run validation against submitted data, read the errors, and see how Flask-WTF adds CSRF protection.
What You'll Learn in This Lesson
- Declaring a form as a class with fields
- Attaching validators: DataRequired, Length, Email
- Running validate() against submitted data
- Reading the form.errors dictionary
- Accessing cleaned values in form.data
- How Flask-WTF adds FlaskForm and CSRF
1 Declaring a Form with Fields
In WTForms, a form is a class. Each attribute is a field — StringField, PasswordField, IntegerField, and so on — and you pass each field a list of validators. Validators are small rules: DataRequired() means the field cannot be empty, Length(min=3) enforces a minimum length.
To validate real input you feed the form a MultiDict (the dictionary-like type Flask's request.form actually is). The runnable example builds a registration form and validates a good submission, then reads back the cleaned data.
# forms.py — declare a form and validate good input (runnable)
from wtforms import Form, StringField, PasswordField
from wtforms.validators import DataRequired, Length, Regexp
from werkzeug.datastructures import MultiDict
# A simple email pattern keeps this runnable without extra packages.
EMAIL_RE = r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
class RegistrationForm(Form):
username = StringField("Username", [DataRequired(), Length(min=3, max=20)])
email = StringField("Email", [DataRequired(), Regexp(EMAIL_RE, message="Invalid email address.")])
password = PasswordField("Password", [DataRequired(), Length(min=8)])
# MultiDict is what request.form looks like behind the scenes:
submitted = MultiDict({"username": "ada", "email": "ada@example.com", "password": "longenough"})
form = RegistrationForm(submitted)
print("valid?", form.validate()) # True
print("data:", form.data) # cleaned values, ready to save
# ✅ Expected output:
# valid? True
# data: {'username': 'ada', 'email': 'ada@example.com', 'password': 'longenough'}Output: valid? True and a data dict with the username, email, and password — the cleaned values you would persist to the database.
2 When Validation Fails: Reading Errors
When a submission breaks the rules, validate() returns False and fills form.errors — a dictionary keyed by field name, where each value is a list of human-readable messages. This is exactly what you loop over in a template to show users what went wrong.
The example sends a too-short username, a malformed email, and a short password, then prints the collected errors.
# forms.py — what happens when input is bad (runnable)
from wtforms import Form, StringField, PasswordField
from wtforms.validators import DataRequired, Length, Regexp
from werkzeug.datastructures import MultiDict
EMAIL_RE = r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
class RegistrationForm(Form):
username = StringField("Username", [DataRequired(), Length(min=3, max=20)])
email = StringField("Email", [DataRequired(), Regexp(EMAIL_RE, message="Invalid email address.")])
password = PasswordField("Password", [DataRequired(), Length(min=8)])
bad = RegistrationForm(MultiDict({"username": "a", "email": "nope", "password": "123"}))
print("valid?", bad.validate()) # False
for field, messages in bad.errors.items():
print(field, "->", messages)
# ✅ Expected output:
# valid? False
# username -> ['Field must be between 3 and 20 characters long.']
# email -> ['Invalid email address.']
# password -> ['Field must be at least 8 characters long.']Output: valid? False, then one line per field — username reports the length rule, email reports the pattern, and password reports the minimum length.
3 Flask-WTF: FlaskForm, CSRF & Templates
In a real app you do not pass request.form yourself. Flask-WTF gives you FlaskForm, which reads the request automatically, and validate_on_submit(), which returns True only on a POST whose data passes validation. It also injects a hidden, signed CSRF token so forged cross-site submissions are rejected.
Here is the production view and template. It is read-only because it needs the flask_wtf package, but the form class is the same one you ran above.
🎯 Your Turn: Fill in the Blanks
Complete the login form. Replace each ___ so the username is required and the password must be at least 6 characters.
# 🎯 YOUR TURN: replace each ___ to finish the form
from wtforms import Form, StringField, PasswordField
from wtforms.validators import DataRequired, Length
from werkzeug.datastructures import MultiDict
class LoginForm(Form):
username = StringField("Username", [___()]) # must not be empty
password = PasswordField("Password", [DataRequired(), Length(min=___)]) # at least 6 chars
good = LoginForm(MultiDict({"username": "ada", "password": "secret1"}))
print("good valid?", good.___()) # which method runs validation?
bad = LoginForm(MultiDict({"username": "", "password": "x"}))
print("bad valid?", bad.validate())
print("errors:", dict(bad.___)) # which attribute holds errors?Common Errors & Quick Reference
❌ 400 Bad Request: The CSRF token is missing
Your template did not render form.hidden_tag(), or app.secret_key is unset. Add both — the token cannot be signed without a secret key.
❌ Install 'email_validator' for email validation support
The Email() validator needs an extra package: pip install email_validator. Until then, use a Regexp rule as a stand-in.
❌ Form always invalid on GET
With Flask-WTF use validate_on_submit(), not validate(). It returns False on a GET so you can show the empty form instead of a wall of errors.
| Task | Code |
|---|---|
| Required field | DataRequired() |
| Length rule | Length(min=3, max=20) |
| Run validation | form.validate() |
| Read errors | form.errors |
🏆 Mini Challenge: A Contact Form
Build and validate a contact form with three rules.
- A required name field, 2–50 characters
- A required message field, at least 10 characters
- Validate a too-short message and print the error
# 🏆 Mini Challenge: validate a contact form
from wtforms import Form, StringField, TextAreaField
from wtforms.validators import DataRequired, Length
from werkzeug.datastructures import MultiDict
class ContactForm(Form):
name = StringField("Name", [DataRequired(), Length(min=2, max=50)])
message = TextAreaField("Message", [DataRequired(), Length(min=10)])
ok = ContactForm(MultiDict({"name": "Ada", "message": "Hello there, team!"}))
print("ok valid?", ok.validate()) # True
short = ContactForm(MultiDict({"name": "Ada", "message": "hi"}))
print("short valid?", short.validate()) # False
print("message errors:", short.errors["message"])❓ Frequently Asked Questions
Lesson complete — your forms validate themselves!
You can declare a form as a class, attach validators, run validate(), read form.errors, and you understand how Flask-WTF layers on FlaskForm and CSRF protection.
Practice quiz
In WTForms, how is a form defined?
- As a dictionary
- As a function
- As a class with field attributes
- As a JSON schema
Answer: As a class with field attributes. A WTForms form is a class where each attribute is a field like StringField or PasswordField.
Which validator means a field cannot be empty?
- DataRequired()
- NotNull()
- Mandatory()
- Filled()
Answer: DataRequired(). DataRequired() enforces that the field must contain data.
Which class does Flask-WTF provide on top of WTForms?
- WebForm
- FormBase
- FlaskWTForm
- FlaskForm
Answer: FlaskForm. Flask-WTF adds FlaskForm, which auto-reads request.form and adds CSRF protection.
What does validate_on_submit() return?
- Always True
- True only on a POST whose data passes validation
- The cleaned data dict
- The list of errors
Answer: True only on a POST whose data passes validation. validate_on_submit() returns True only for a valid POST, and False on GET or invalid input.
Where does WTForms record validation messages?
- form.errors
- form.messages
- form.problems
- form.invalid
Answer: form.errors. form.errors is a dict keyed by field name, each value a list of messages.
Which validator enforces a minimum length?
- Size(min=3)
- Length(min=3)
- MinLength(3)
- Chars(min=3)
Answer: Length(min=3). Length(min=3, max=20) enforces minimum and maximum character counts.
What does CSRF protection defend against?
- SQL injection
- Slow queries
- Forged cross-site request submissions
- Cross-site scripting
Answer: Forged cross-site request submissions. CSRF tricks a logged-in user's browser into sending an unintended request; the token blocks forged ones.
Which template call renders the hidden CSRF token?
- form.csrf()
- form.token()
- form.secure()
- form.hidden_tag()
Answer: form.hidden_tag(). form.hidden_tag() outputs the CSRF token; omit it and Flask-WTF rejects the POST with a 400.
Where are cleaned values available after validation?
- form.data
- form.cleaned
- form.values
- form.results
Answer: form.data. After validation passes, the cleaned values are accessible through form.data.
What does the built-in Email() validator require?
- A secret key
- Nothing extra
- The email_validator package
- A database connection
Answer: The email_validator package. Email() needs the email_validator package installed; otherwise use a Regexp rule as a stand-in.
Continue this course
- Previous: User Login with Flask-Login
- Next: SQLAlchemy Relationships — Model one-to-many and many-to-many relationships between tables
- Quick reference: Flask cheat sheet
Frequently asked questions
What is the difference between WTForms and Flask-WTF?
WTForms is the standalone library that defines fields and validators and runs validation. Flask-WTF is a thin Flask integration on top of it: it adds FlaskForm (which auto-reads request.form), built-in CSRF protection, and file-upload helpers. You write the same field and validator code in both.
How does form.validate() work?
When you call validate() (or validate_on_submit() in Flask-WTF), WTForms runs each field's list of validators in order. If any validator fails, it records a message in form.errors under that field's name and validate() returns False. If all pass, it returns True and the cleaned values are available in form.data.
What is CSRF and why does Flask-WTF add a token?
CSRF (Cross-Site Request Forgery) tricks a logged-in user's browser into submitting a request they did not intend. Flask-WTF inserts a hidden, signed token into every form and checks it on submit, so a forged request from another site is rejected because it cannot know the token.
How do I show validation errors in my template?
After validate_on_submit() returns False, loop over form.<field>.errors in your Jinja template and render each message next to the field. The errors dict is keyed by field name, so {% for e in form.email.errors %} shows every problem with the email field.