Skip to content

Creating Form Classes

A Flask-WTF form class defines:

  • fields
  • validators
  • default values
python
from flask_wtf import FlaskForm
from wtforms import StringField, EmailField, SubmitField
from wtforms.validators import DataRequired, Email, Length
 
 
class ContactForm(FlaskForm):
    name = StringField("Name", validators=[DataRequired(), Length(min=2, max=50)])
    email = EmailField("Email", validators=[DataRequired(), Email()])
    submit = SubmitField("Send")
python
from flask import Flask, render_template, redirect, url_for
 
app = Flask(__name__)
app.config["SECRET_KEY"] = "dev-key"
 
 
@app.route("/contact", methods=["GET", "POST"])
def contact():
    form = ContactForm()
 
    if form.validate_on_submit():
        # Access clean field values:
        name = form.name.data
        email = form.email.data
        # ...save/send email...
        return redirect(url_for("contact_success"))
 
    return render_template("contact.html", form=form)

It’s shorthand for:

  • request is POST
  • AND form validates

This keeps your route clean.

A form is a class, and the fields are its schema

Section titled “A form is a class, and the fields are its schema”
forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, IntegerField, SelectField, SubmitField
from wtforms.validators import DataRequired, Length, NumberRange, Optional
 
class SignupForm(FlaskForm):
    name = StringField("Name", validators=[DataRequired(), Length(min=2, max=20)])
    age = IntegerField("Age", validators=[Optional(), NumberRange(min=0, max=130)])
    role = SelectField("Role", choices=[("u", "User"), ("a", "Admin")])
    go = SubmitField("Sign up")

Each attribute declares three things at once — the HTML control, the Python type the value is coerced to, and the rules it must satisfy:

diagram Diagram mermaid

Measured error dictionaries for a form with name, age and email:

submittedform.errors
name='a', bad emailname: ['Field must be between 2 and 20 characters long.'], email: ['Invalid email address.']
age='999'age: ['Number must be between 0 and 130.']
age='abc'age: ['Not a valid integer value.', 'Number must be between 0 and 130.']
name missingname: ['This field is required.']

Two details worth reading off that table:

  • Errors are collected per field, and a field can hold more than one. age='abc' produced two, because coercion failed and the range check then ran against the resulting None.
  • Validation does not stop at the first failing field. form.errors is the complete picture, which is what lets you re-render every problem at once.
required.py
DataRequired()    # fails if the coerced value is FALSY: '', 0, False, None
InputRequired()   # fails only if nothing was submitted for the field

For a number that may legitimately be 0, or a checkbox that may legitimately be unchecked, DataRequired rejects a valid answer. Use InputRequired when the question is “did they send this field”, and DataRequired when it is “is there a meaningful value”.

custom.py
from wtforms.validators import ValidationError
 
class SignupForm(FlaskForm):
    name = StringField("Name", validators=[DataRequired()])
 
    def validate_name(self, field):          # validate_<fieldname>
        if User.query.filter_by(name=field.data).first():
            raise ValidationError("That name is taken.")

A method called validate_<fieldname> is picked up automatically and runs after that field’s validator list. Raising ValidationError appends to field.errors exactly like a built-in.

sketch How one submission becomes form.errors p5.js
Each field runs its validators in order. Failures accumulate per field, and validation never stops at the first bad field.
pch.quizTag pch.quizDefaultTitle
  1. Submitting age=abc to an IntegerField with NumberRange(0, 130) produced two errors on that field. Why two?

    pch.quizShowAnswer

    B — coercion to int failed, and the range validator then ran against the resulting None — Measured: ['Not a valid integer value.', 'Number must be between 0 and 130.']. Errors accumulate per field rather than stopping at the first.

  2. Why should Optional() be first in a validators list?

    pch.quizShowAnswer

    B — it stops the chain when the field is empty, so later validators do not fire on an empty value — List order is execution order. Placed after NumberRange, the range check runs first on an empty value and reports a spurious error.

  3. Which validator should guard a number field where 0 is a legitimate answer?

    pch.quizShowAnswer

    B — InputRequired, because DataRequired rejects falsy values including 0 — DataRequired fails on any falsy coerced value, so it rejects 0, an empty string and an unchecked box. InputRequired only asks whether the field was submitted.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading