Custom Form Validators
Validate forms with custom validators — Python web development. Practical steps, edge cases, and next steps.
Focus: validate forms with custom validators
You've built a form, wired it to a route, and maybe even added a few required attributes. But then real users show up: they submit password fields with a single character, usernames that are all spaces, or email addresses that clearly aren't emails. Browser-side validation is easy to bypass, and Flask's built-in validators only scratch the surface. The pain is real: your app accepts garbage, your database fills with junk, and your users get confused when their data silently disappears. The solution? Custom validators — Python functions that give you complete control over what enters your application, turning messy input into clean, validated data before it ever touches your models.
The problem this lesson solves
Every web developer hits the same wall: default form validation isn't enough. The HTML5 required attribute stops empty fields but does nothing about ' ' (spaces only) or 'abc' for a zip code. Flask-WTF gives you DataRequired, Email, and Length, but what about a rule like "the username must not be 'admin'"? Or "the age must be between 18 and 99"? Or "the password must contain at least one digit"?
Without custom validators, you end up with two ugly options:
- Sloppy validation — trust the client, accept anything, and deal with corrupted data later.
- Spaghetti checks — cram
ifstatements into your route handler, making it long, unreadable, and hard to test.
Custom validators solve this by moving validation logic into the form class itself. They keep your route clean, your validation testable, and your data sane. They are the difference between a prototype and a production-ready application.
Core concept / mental model
Think of a form as a security checkpoint. The browser is the outside world — anyone can walk up with anything. The form's validate() method is the guard who asks a series of questions: "Is this field present? Is it the right length?" Built-in validators are like standard questions, but you need to ask something specific — "Is this a valid corporate email domain?" or "Does this string contain a banned word?" — and that's when you write a custom validator.
In Flask-WTF (and WTForms, the underlying library), a custom validator is just a Python callable. It takes a form and a field, checks the field's data, and if something is wrong, it raises a ValidationError. That's it. The form's validate() method automatically runs every validator attached to a field — built-in or custom — and collects all errors into form.errors.
There are two main ways to define a custom validator:
- Inline method — define a method on your form class named
validate_<fieldname>. This runs automatically when you callform.validate_on_submit(). - Reusable function — define a standalone function and pass it to the
validators=[...]list of a field. This is great when you need the same rule in multiple forms.
This separation of concerns is powerful: the form becomes a single source of truth for what data is acceptable. Your views stay thin, and your tests can directly target validation logic.
How it works step by step
Let's walk through the lifecycle of a form submission when a custom validator is involved.
Step 1: The form class definition
You define a FlaskForm subclass with fields and validators. If you're using a reusable custom validator, you import it or define it first in the same module.
Step 2: The request hits the route
A GET request renders the form. The user fills it and submits via POST. Your route now has a request.form with user data.
Step 3: validate_on_submit() is called
Inside your view, you call form.validate_on_submit(). This performs these actions:
- Checks if the request method is POST (and the form is validated).
- Iterates over every field in the form.
- For each field, runs all its validators — including built-in ones and your custom function or method.
- If any validator raises a
ValidationError, the error message is appended tofield.errorsand the field is marked as invalid. - After all fields are checked,
validate()returnsTrueonly if all fields are valid. Thevalidate_on_submit()returnsTrueonly if the request is POST and validation passes.
Step 4: The route branches
If validate_on_submit() returns True, you process the data (e.g., save to database). If False, you re-render the template with the form — errors are automatically displayed via the template's form.<field>.errors loop.
The key insight: custom validators run inside step 3, so they get the same treatment as built-in ones. They can even access other fields for cross-field checks (like password confirmation).
Hands-on walkthrough
Let's apply this in a realistic Flask app. We'll create a registration form with:
- A username that must be at least 3 characters and not contain spaces.
- An email that must end with
@example.com(just for demo). - A password that must be at least 8 characters and contain a number.
Example 1: Inline method validator
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, Length, ValidationError
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)])
submit = SubmitField('Register')
# Inline custom validator: runs automatically after the field's validators list
def validate_username(self, field):
# Access the form via self, the field via field
if ' ' in field.data:
raise ValidationError("Username cannot contain spaces.")
def validate_email(self, field):
if not field.data.endswith('@example.com'):
raise ValidationError("Only @example.com emails are allowed.")
How it works: When you call form.validate_on_submit(), WTForms sees that validate_username exists on the form and calls it automatically. If the username contains a space, it raises a ValidationError with our custom message. The same goes for the email method.
Expected output: If a user submits username="John Doe", the form.errors dict will contain {'username': ['Username cannot contain spaces.']} and the template will show that error next to the username field.
Example 2: Reusable function validator
If you need the same rule in multiple forms, create a standalone function:
from wtforms.validators import ValidationError
def has_number(form, field):
"""Custom validator to ensure password contains at least one digit."""
if not any(char.isdigit() for char in field.data):
raise ValidationError("Password must contain at least one digit.")
def not_banned_word(banned_words):
"""Factory that returns a validator checking against a banned list."""
def _validator(form, field):
if field.data.lower() in banned_words:
raise ValidationError(f"'{field.data}' is not allowed.")
return _validator
Now use them in a form:
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Length
class LoginForm(FlaskForm):
username = StringField('Username', validators=[
DataRequired(),
not_banned_word(['admin', 'root', 'superuser'])
])
password = PasswordField('Password', validators=[
DataRequired(),
Length(min=8),
has_number
])
submit = SubmitField('Log In')
Why a factory? The not_banned_word function returns a new validator each time, allowing you to customize the banned list per form. This pattern is extremely flexible.
Example 3: Cross-field validation (password confirmation)
Sometimes you need to check one field against another. Inline methods can access self to get sibling fields:
from wtforms import PasswordField
class RegisterForm(FlaskForm):
password = PasswordField('Password', validators=[DataRequired()])
confirm_password = PasswordField('Confirm Password', validators=[DataRequired()])
def validate_confirm_password(self, field):
if field.data != self.password.data:
raise ValidationError("Passwords do not match.")
Expected behavior: If the user types mismatched passwords, an error appears under the confirm field.
Putting it all together in a route
from flask import render_template, redirect, url_for, flash
def register():
form = RegistrationForm()
if form.validate_on_submit():
# In production, save user to DB here
flash(f"Account created for {form.username.data}!", "success")
return redirect(url_for('dashboard'))
return render_template('register.html', form=form)
The template can display errors like this:
<form method="post">
{{ form.hidden_tag() }}
<div>
{{ form.username.label }}<br>
{{ form.username(size=20) }}
{% for error in form.username.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
<!-- repeat for other fields -->
</form>
Now your form is fully protected with custom rules — no messy if statements in the route.
Compare options / when to choose what
You now have two main ways to add custom validation. Here's a quick comparison to help you decide:
| Approach | When to use | Pros | Cons |
|---|---|---|---|
Inline method (validate_<field>) |
The rule is specific to one form | Simple; no extra imports; can easily access other fields | Can bloat the form class if many custom rules |
| Reusable function | The rule applies across multiple forms | DRY; testable in isolation; can be made configurable via factories | Slightly more overhead to write; must remember to pass it to validators |
| Factory function | When you need to parametrize the rule (e.g., banned list) | Highly flexible; clean reuse | A bit more abstract for beginners |
| WTForms built-in | Common checks like email, length, required | Zero code; battle-tested | Cannot handle business-specific logic |
When to use what — summary
- Use built-in validators first — never reinvent the wheel.
- Use an inline method for one-off form-specific rules.
- Use a reusable function for generic rules like "must contain a digit" that appear in multiple forms.
- Use factories when the rule needs parameters (e.g., different banned lists).
- For cross-field validation, always use an inline method because it has access to
self.
Troubleshooting & edge cases
Even with custom validators, things can go wrong. Here are common pitfalls and how to fix them.
1. Validator never runs
Symptom: Your custom validator is never called, and invalid data passes.
Cause: You forgot to call form.validate_on_submit() in the route, or you're using the name validate_<fieldname> incorrectly (e.g., validate_email but the field is email_address).
Fix: Double-check the field name matches the method exactly. For reusable functions, ensure they are in the validators=[...] list of the correct field.
2. ValidationError not imported
Symptom: NameError when you raise ValidationError.
Cause: You imported it from the wrong place.
Fix: Use from wtforms.validators import ValidationError. It's not part of flask_wtf directly.
3. Validator accesses field.data but field is empty
Symptom: AttributeError: 'NoneType' object has no attribute ...
Cause: Your custom validator runs even if the field is empty because you forgot to add DataRequired(). While it's possible to handle None in your validator, it's better to rely on DataRequired() first and keep custom validators focused.
Fix: Always include DataRequired() before your custom validator in the validators list, or check if field.data: in your function.
4. Cross-field validation fails due to field order
Symptom: You access self.password but it's None.
Cause: WTForms validates fields in order of definition. If password is defined after confirm_password, the password field hasn't been validated yet — but field.data is still set from the request, so this is not a real issue. The real issue is if you try to access self.password.errors (not yet populated). Avoid relying on errors of other fields.
Fix: Access .data only, not .errors. If you need the errors, you can re-order fields so the source field comes first.
5. Error messages not showing in template
Symptom: form.errors is populated but nothing displays.
Cause: You're not iterating over form.<field>.errors in the template, or the field renders as a custom widget that doesn't include the loop.
Fix: Use the standard loop {% for error in form.username.errors %} inside the field's div. Also ensure you call form.hidden_tag() to include the CSRF token, otherwise validation may not run due to CSRF failure.
6. Performance — validators run twice
Symptom: Validator side-effects (like database queries) happen multiple times.
Cause: If you call form.validate() manually and later validate_on_submit(), validators run twice. Same if you re-validate a form after submitting.
Fix: Call validate_on_submit() exactly once per request. If you need to re-validate after modifying data, use a fresh form instance.
What you learned & what's next
You now understand how to validate forms with custom validators in Python web development. You learned:
- The core concept: custom validators are just callables that raise
ValidationErrorwhen data is bad. - The mental model: forms are a security checkpoint; custom validators are your specific security questions.
- How to implement them as inline methods and reusable functions, including factory patterns.
- How to handle cross-field checks like password confirmation.
- How to compare inline, reusable, and factory approaches to pick the right one.
- How to troubleshoot common edge cases like validators not running, import errors, and template display issues.
This lesson directly supports the course's next steps. In the next lesson, you'll likely build on this by adding CSRF protection to your forms (Flask-WTF already does this under the hood) and then integrating form validation with database models using Flask-SQLAlchemy. Your form validation skills will ensure that only clean, valid data reaches your database, making your applications more robust and secure.
Now go ahead and add a custom validator to a form you've already built. Practice makes perfect — and your future users will thank you.
Practice recap
Try adding a custom validator to a registration form that checks a username is not in a blocked list (e.g., 'admin', 'root'). First do it with an inline method, then refactor to a reusable factory function and use it on two different forms. Test both valid and invalid submissions to see error messages appear correctly.
Common mistakes
- Forgetting to import
ValidationErrorfromwtforms.validators— usefrom wtforms.validators import ValidationError, not fromflask_wtf. - Naming the inline validator method wrong — it must be
validate_+ exact field name, e.g.,validate_emailfor a field namedemail. - Not handling empty fields in custom validators — always pair with
DataRequired()or checkif field.data:first to avoidNoneTypeerrors. - Trying to access other fields'
.errorsinside a custom validator — use.data, not.errors, because validation order may not guarantee the other field has been processed yet.
Variations
- Use a base form class with reusable custom validators inherited by multiple forms — good for large applications with shared rules.
- Leverage WTForms'
validators.Regexpfor pattern-based checks, orvalidators.AnyOffor enum-like values, instead of writing a custom function for simple cases. - Use a third-party library like
validators(PyPI) for common checks like ISBN, UUID, or credit card numbers, and then wrap them in custom validators for your specific needs.
Real-world use cases
- Enforcing a unique username on signup by checking the database inside a custom validator (raising
ValidationError('Username taken')). - Validating that a user's age is between 18 and 99 on a registration form before creating the account.
- Checking that a coupon code follows a specific format (e.g.,
SAVE20-2025) before applying a discount in an e-commerce app.
Key takeaways
- Custom validators are Python functions (or methods) that raise
ValidationErrorto reject invalid form data. - You have two main implementation styles: inline methods (
validate_<fieldname>) for form-specific rules, and reusable functions for cross-form logic. - Always pair custom validators with built-in ones like
DataRequired()to handle empty fields gracefully. - Cross-field validation (like password confirmation) works by accessing sibling fields via
self.<other_field>.datain an inline method. - Error messages are automatically stored in
form.errorsand can be displayed in templates with a simple loop. - Choosing between inline, reusable, or factory-based validators depends on reusability and parametrization needs.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.