Custom Validators & File Uploads

Difficulty: Intermediate

While WTForms provides many built-in validators, real applications often need custom validation logic. Custom validators let you enforce business rules like checking if a username is already taken, validating password complexity, or ensuring uploaded files meet your requirements.

There are two ways to create custom validators in WTForms. Inline validators are methods on the form class named validate_<fieldname>. They are called automatically after the field's built-in validators pass. Standalone validators are reusable functions or classes that can be used across multiple forms.

Inline validators follow the naming convention validate_fieldname(self, field). They receive the form instance and the field being validated. To signal a validation error, raise a ValidationError with the error message. This method is convenient for one-off validations specific to a single form.

Standalone validators are functions that take a form and field argument, or classes that implement __call__(self, form, field). They can be parameterized by accepting arguments in the constructor. Standalone validators are reusable across multiple forms and fields.

Flask-WTF extends WTForms with FileField and MultipleFileField for file uploads. Flask-WTF also provides validators: FileRequired (file must be uploaded), FileAllowed (restrict file extensions), and FileSize (limit file size). These validators integrate with Flask's request.files.

File upload forms must use enctype='multipart/form-data'. In the template, render the file input with {{ form.file() }} and ensure the form tag has the correct enctype. The submitted file is a Werkzeug FileStorage object accessible via form.file.data.

For complex file validation (checking image dimensions, validating PDF content, scanning for malware), you may need custom validators. These can read the file stream, check headers, or use external libraries. Remember to seek back to the beginning of the file stream after reading it, or the save() call will write an empty file.

Code examples

Inline Custom Validators

from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField
from wtforms.validators import DataRequired, Length, ValidationError
from app.models import User

class RegistrationForm(FlaskForm):
    username = StringField('Username', validators=[DataRequired(), Length(min=3)])
    email = StringField('Email', validators=[DataRequired()])
    password = PasswordField('Password', validators=[DataRequired(), Length(min=8)])

    def validate_username(self, field):
        """Check if username is already taken."""
        user = User.query.filter_by(username=field.data).first()
        if user:
            raise ValidationError('Username already taken.')

    def validate_email(self, field):
        """Check if email is already registered."""
        user = User.query.filter_by(email=field.data).first()
        if user:
            raise ValidationError('Email already registered.')

    def validate_password(self, field):
        """Enforce password complexity."""
        password = field.data
        if not any(c.isupper() for c in password):
            raise ValidationError('Password must contain an uppercase letter.')
        if not any(c.isdigit() for c in password):
            raise ValidationError('Password must contain a digit.')

Methods named validate_<fieldname> run automatically after built-in validators. They receive the field and raise ValidationError on failure. This is ideal for database uniqueness checks and custom business rules.

Reusable Standalone Validators

from wtforms.validators import ValidationError
import re

# Function-based validator
def no_special_chars(form, field):
    if not re.match(r'^[a-zA-Z0-9_]+
#39;, field.data): raise ValidationError('Only letters, numbers, and underscores allowed.') # Class-based validator (parameterized) class Unique: def __init__(self, model, field, message='Already exists.'): self.model = model self.field = field self.message = message def __call__(self, form, field): exists = self.model.query.filter( getattr(self.model, self.field) == field.data ).first() if exists: raise ValidationError(self.message) # Usage in form class UserForm(FlaskForm): username = StringField('Username', validators=[ DataRequired(), no_special_chars, # Function validator Unique(User, 'username', 'Username taken.') # Class validator ])

Function validators take (form, field) and raise ValidationError. Class validators use __init__ for configuration and __call__ for validation. Both can be reused across multiple forms.

File Upload with Flask-WTF

from flask import Flask, render_template
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileRequired, FileAllowed, FileSize
from wtforms import StringField, SubmitField
from werkzeug.utils import secure_filename
import os

app = Flask(__name__)
app.config['SECRET_KEY'] = 'secret'
app.config['UPLOAD_FOLDER'] = 'uploads'

class UploadForm(FlaskForm):
    title = StringField('Title')
    photo = FileField('Photo', validators=[
        FileRequired('Please upload a file.'),
        FileAllowed(['jpg', 'jpeg', 'png', 'gif'], 'Images only!'),
        FileSize(max_size=5 * 1024 * 1024, message='Max 5 MB')
    ])
    submit = SubmitField('Upload')

@app.route('/upload', methods=['GET', 'POST'])
def upload():
    form = UploadForm()
    if form.validate_on_submit():
        file = form.photo.data
        filename = secure_filename(file.filename)
        file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
        return f'Uploaded: {filename}'
    return render_template('upload.html', form=form)

# upload.html must have:
# <form method="POST" enctype="multipart/form-data">
#     {{ form.hidden_tag() }}
#     {{ form.photo() }}
#     {{ form.submit() }}
# </form>

FileField handles file uploads. FileRequired ensures a file is uploaded. FileAllowed restricts extensions. FileSize limits file size. The form template must use enctype='multipart/form-data'.

Custom File Validator

from wtforms.validators import ValidationError
from PIL import Image
import io

class ImageDimensions:
    """Validate image dimensions."""
    def __init__(self, min_width=0, min_height=0, max_width=4000, max_height=4000):
        self.min_width = min_width
        self.min_height = min_height
        self.max_width = max_width
        self.max_height = max_height

    def __call__(self, form, field):
        if not field.data:
            return

        file = field.data
        try:
            image = Image.open(file.stream)
            width, height = image.size
            file.stream.seek(0)  # Reset stream for later save()

            if width < self.min_width or height < self.min_height:
                raise ValidationError(
                    f'Image must be at least {self.min_width}x{self.min_height}px'
                )
            if width > self.max_width or height > self.max_height:
                raise ValidationError(
                    f'Image must be at most {self.max_width}x{self.max_height}px'
                )
        except Exception as e:
            raise ValidationError(f'Invalid image: {e}')

# Usage:
# photo = FileField('Photo', validators=[
#     FileRequired(),
#     ImageDimensions(min_width=200, min_height=200)
# ])

Custom file validators can inspect file content. This one checks image dimensions using Pillow. Important: seek(0) resets the file stream after reading, otherwise save() writes an empty file.

Key points

Concepts covered

Custom Validators, validate_ Methods, FileField, FileAllowed, FileRequired, FileSize