Custom Form Validators

Validate forms with custom validators — Python web development. Practical steps, edge cases, and next steps.

Focus: validate forms with custom validators

Sponsored

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:

  1. Sloppy validation — trust the client, accept anything, and deal with corrupted data later.
  2. Spaghetti checks — cram if statements 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 call form.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:

  1. Checks if the request method is POST (and the form is validated).
  2. Iterates over every field in the form.
  3. For each field, runs all its validators — including built-in ones and your custom function or method.
  4. If any validator raises a ValidationError, the error message is appended to field.errors and the field is marked as invalid.
  5. After all fields are checked, validate() returns True only if all fields are valid. The validate_on_submit() returns True only 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 ValidationError when 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 ValidationError from wtforms.validators — use from wtforms.validators import ValidationError, not from flask_wtf.
  • Naming the inline validator method wrong — it must be validate_ + exact field name, e.g., validate_email for a field named email.
  • Not handling empty fields in custom validators — always pair with DataRequired() or check if field.data: first to avoid NoneType errors.
  • Trying to access other fields' .errors inside a custom validator — use .data, not .errors, because validation order may not guarantee the other field has been processed yet.

Variations

  1. Use a base form class with reusable custom validators inherited by multiple forms — good for large applications with shared rules.
  2. Leverage WTForms' validators.Regexp for pattern-based checks, or validators.AnyOf for enum-like values, instead of writing a custom function for simple cases.
  3. 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 ValidationError to 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>.data in an inline method.
  • Error messages are automatically stored in form.errors and can be displayed in templates with a simple loop.
  • Choosing between inline, reusable, or factory-based validators depends on reusability and parametrization needs.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.