Forms with Flask-WTF

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Flask 3.1.3 (released 2026-02-18), Flask-WTF 1.3.0 (released 2026-04-23), WTForms 3.2.2, wtforms-sqlalchemy 0.4.2 and Werkzeug 3.1.9, and was checked on 2026-10-07. Flask 3.2 was not released on that date, so anything described as "3.2" is upcoming. The code uses Python 3.10+ syntax and every example was executed on Python 3.13 with app.test_client(). A web form is the one place where a stranger types into your application on purpose, so a form library has three jobs: turn strings into Python values, decide whether the values are acceptable, and prove that the request was meant to be sent by the user who sent it. WTForms does the first two, Flask-WTF connects WTForms to Flask’s request, session and configuration and adds the third (CSRF protection). The scenario is the Bookshelf catalogue that the other pages of this section build on: a form to add a book, a form to review it and a form to upload its cover.

Why Use a Form Library

The Flask request object already gives you everything that was submitted (see Request Data), so a form library is not strictly necessary. The following view handles a book form by hand:

from flask import Flask, request

raw_app = Flask("raw")


@raw_app.post("/books")
def create_book_raw():
    title = request.form.get("title", "").strip()
    pages = request.form.get("pages", "")
    errors = {}
    if not title:
        errors["title"] = "Title is required."
    elif len(title) > 120:
        errors["title"] = "Title is too long."
    if not pages.isdecimal() or not 1 <= int(pages) <= 5000:
        errors["pages"] = "Pages must be a number between 1 and 5000."
    if errors:
        return {"errors": errors}, 422
    return {"title": title, "pages": int(pages)}, 201


with raw_app.test_client() as client:
    print(client.post("/books", data={"title": "", "pages": "abc"}).get_json())
    print(client.post("/books", data={"title": "Dune", "pages": "412"}).get_json())

This works for two fields and then stops scaling. Every field needs its own conversion, its own error messages, its own re-display of what the user typed, its own HTML and, on every state-changing request, a CSRF check that is easy to forget. WTForms puts those concerns in one declarative class:

  • Declaration. A form is a class whose attributes are fields; each field carries its type, label, validators and filters.

  • Processing. The form reads the submitted data, converts it (IntegerField yields an int, DateField a date) and keeps the raw input so the page can be re-rendered with what the user typed.

  • Validation. Validators run per field and collect error messages in form.errors.

  • Rendering. Each field renders itself as HTML through a widget and can be given extra attributes, so the template never repeats names, ids or values.

  • Security. Flask-WTF adds a CSRF token field to every FlaskForm and validates it as part of validate().

Which library does what is worth knowing, because the documentation is split:

Package Responsibility

WTForms

Form, fields, validators, widgets, filters, the Meta class (CSRF and translation hooks) and the processing and validation algorithm. Knows nothing about Flask.

Flask-WTF

FlaskForm (reads request, supplies the CSRF secret from Flask’s config), CSRFProtect, the csrf_token() and csrf_meta_tag() template helpers, file fields and validators (FileField, FileRequired, FileAllowed, FileSize) and the reCAPTCHA field.

ItsDangerous

Signs and time-stamps the CSRF token (the same library that signs the session cookie).

Werkzeug

Supplies request.form, request.files and FileStorage, which WTForms reads through wrap_formdata.

wtforms-sqlalchemy

QuerySelectField and friends and the model_form helper. A separate package, because the extensions that used to live inside WTForms were removed in WTForms 3.

Form handling in other frameworks is described in Django Forms and in the FastAPI section, where Pydantic models play the role of the form class. A Flask application that exposes only a JSON API does not need WTForms; it needs a schema library instead (see the REST API pages of this section).

Installing

pip install flask-wtf                  # pulls in WTForms
pip install "flask-wtf[email]"         # adds email-validator, required by the Email validator
pip install wtforms-sqlalchemy         # QuerySelectField, model_form (optional)

The Email validator imports email_validator at validation time and raises an exception when the package is missing, so install the email extra whenever a form validates addresses. The Flask-WTF documentation site still opens on its 1.2.x version in October 2026 although 1.3.0 is the current release; the 1.3.0 behaviour described below was confirmed against the installed package.

FlaskForm, Fields and Validators

A form is a subclass of flask_wtf.FlaskForm. Fields come from wtforms and validators from wtforms.validators:

import os
import secrets

from flask import Flask, flash, redirect, render_template_string, request, url_for
from flask_wtf import FlaskForm
from wtforms import BooleanField, IntegerField, StringField, SubmitField, TextAreaField
from wtforms.validators import InputRequired, Length, NumberRange, Optional, Regexp

app = Flask(__name__)
# Read the key from the environment; fall back to a random per-process key for local runs only.
app.config["SECRET_KEY"] = os.environ.get("BOOKSHELF_SECRET_KEY") or secrets.token_hex(32)


class BookForm(FlaskForm):
    title = StringField("Title", validators=[InputRequired(), Length(max=120)])
    isbn = StringField(
        "ISBN-13",
        validators=[InputRequired(), Regexp(r"^97[89]\d{10}$", message="Enter 13 digits, no hyphens.")],
    )
    pages = IntegerField("Pages", validators=[InputRequired(), NumberRange(min=1, max=5000)])
    summary = TextAreaField("Summary", validators=[Optional(), Length(max=2000)])
    in_stock = BooleanField("In stock", default=True)
    submit = SubmitField("Save")


BOOKS: list[dict] = []

SECRET_KEY is mandatory: the CSRF token is signed with it (or with WTF_CSRF_SECRET_KEY), and a form without a key raises RuntimeError("A secret key is required to use CSRF.") the first time it is rendered. Never write the key in the source; generate it with python -c "import secrets; print(secrets.token_hex())" and inject it through the environment or the instance configuration. The random fallback above is acceptable for a single development process only, because every Gunicorn worker would invent a different key and reject the others' tokens.

The View: validate_on_submit

@app.route("/books/new", methods=["GET", "POST"])
def new_book():
    form = BookForm()
    if form.validate_on_submit():
        BOOKS.append({"title": form.title.data, "isbn": form.isbn.data, "pages": form.pages.data})
        flash("Book saved.")
        return redirect(url_for("new_book"))  # Post/Redirect/Get
    status = 422 if form.errors else 200
    return render_template_string(PAGE, form=form), status

FlaskForm() called without arguments does the Flask-specific work. If the current request uses POST, PUT, PATCH or DELETE, it takes the submitted data from request.files and request.form (combined when files are present), or from the JSON body when the request is JSON and there are no form fields. On a GET it builds an empty form. Pass formdata=None to ignore the request entirely, or any other mapping to feed the form from somewhere else.

validate_on_submit() is is_submitted() and validate(). A form that is not submitted (a GET) is never validated, so a first visit does not display "this field is required" everywhere. Three habits make this pattern correct:

  • Redirect after a successful POST (Post/Redirect/Get), so a browser refresh does not resubmit the form and flash() shows its message on the next page.

  • Return an error status when validation fails. Returning 422 Unprocessable Content (or 400) tells HTMX, Turbo and scripts that the submission was rejected; returning 200 works for plain browsers but hides the failure from tooling.

  • Read values from form.<field>.data, never from request.form, so the values you use are the converted and validated ones.

The page itself is rendered with Jinja. A macro keeps the markup for one field in one place:

PAGE = """
{% macro render_field(field) %}
  <div class="field{% if field.errors %} has-error{% endif %}">
    {{ field.label }}
    {{ field(**kwargs) }}
    {% for message in field.errors %}<span class="error">{{ message }}</span>{% endfor %}
  </div>
{% endmacro %}
<form method="post" novalidate>
  {{ form.hidden_tag() }}
  {{ render_field(form.title) }}
  {{ render_field(form.isbn, placeholder="9780441172719") }}
  {{ render_field(form.pages, min=1) }}
  {{ render_field(form.summary, rows=4) }}
  {{ render_field(form.in_stock) }}
  {{ form.submit() }}
</form>
"""

import re


def csrf_token_from(html: str) -> str:
    return re.search(r'name="csrf_token"[^>]*value="([^"]+)"', html).group(1)


with app.test_client() as client:
    page = client.get("/books/new")
    token = csrf_token_from(page.get_data(as_text=True))

    bad = client.post("/books/new", data={"csrf_token": token, "title": "Dune", "isbn": "123", "pages": "x"})
    html = bad.get_data(as_text=True)
    print(bad.status_code, "Enter 13 digits" in html, 'value="Dune"' in html)

    missing = client.post("/books/new", data={"title": "Dune", "isbn": "9780441172719", "pages": "412"})
    print(missing.status_code, "The CSRF token is missing." in missing.get_data(as_text=True))

    good = client.post(
        "/books/new",
        data={"csrf_token": token, "title": "Dune", "isbn": "9780441172719", "pages": "412"},
    )
    print(good.status_code, good.headers["Location"], BOOKS)

The facts the example demonstrates:

  • A field is callable. field(placeholder="...", rows=4) renders the widget with those attributes added; a trailing underscore (class_=) or render_kw on the field covers reserved words, and data_foo= becomes data-foo.

  • field.errors is a list of messages; form.errors is a dict of field name to messages. The rejected submission is rendered with what the user typed (value="Dune"), because the field keeps raw_data.

  • form.hidden_tag() renders every hidden field, including the CSRF token. Forgetting it is the most common reason for a form that "never validates".

  • novalidate on the <form> turns off the browser’s own validation so that the server’s messages are the ones shown; browser validation is a convenience and never a defence, because anyone can send a request without a browser.

  • Jinja autoescapes the field output: widgets return Markup, while messages and values are escaped. Do not wrap user input in |safe.

flowchart TD A["Request reaches the view"] --> B["FlaskForm() is built"] B --> C{"Method is POST, PUT, PATCH or DELETE?"} C -->|"No (GET)"| D["Form shows defaults or object data"] C -->|"Yes"| E["formdata chosen: files plus form, form, or JSON"] E --> F["Each field processes raw input into data"] F --> G["validate runs the csrf_token field, field validators and validate_name methods"] G -->|"Errors"| H["Page re-rendered with form.errors, status 422"] G -->|"No errors"| I["View acts on form data, then redirects"] D --> J["Template renders the form"] H --> J
Figure 1. Life of a Flask-WTF form submission

Validators

A validator is a callable validator(form, field) that returns nothing when the value is fine and raises wtforms.validators.ValidationError (message collected, validation continues with the next validator) or StopValidation (message collected, the chain stops) otherwise. The built-in ones that matter:

Validator Behaviour and pitfalls

InputRequired()

Passes if the browser sent a non-empty raw value. Use it for text and numbers. It sets the required flag on the field.

DataRequired()

Passes if the converted value is truthy. A BooleanField unchecked or an IntegerField containing 0 fails it, which is rarely what you want; use it for fields where an empty value is meaningless.

Optional()

If the input is empty it stops the chain, so later validators such as Length(min=3) do not run on an empty optional field. It also clears earlier errors from the conversion step.

Length(min, max)

Length of the string or list; either bound may be omitted.

NumberRange(min, max)

Compares numbers and dates; either bound may be omitted.

Regexp(regex, flags, message)

re.match semantics: anchored at the start only, so add $ yourself.

Email(), URL(), IPAddress(), MacAddress(), UUID()

Syntax checks. Email needs email_validator; URL accepts any scheme-like host by default (require_tld is on), so it does not by itself prevent javascript: style input when you later render the value in an href.

EqualTo(fieldname)

For "repeat the password" fields.

AnyOf(values), NoneOf(values)

Allow or forbid listed values; since WTForms 3.2 they also work on multi-valued fields such as SelectMultipleField.

ReadOnly(), Disabled()

Reject a value different from the object’s (or any submitted value for Disabled), for fields rendered read-only.

Validators can be exercised outside a view with a test request context, which is also the quickest way to learn how a form reacts:

from werkzeug.datastructures import MultiDict


def check(data: dict, **kwargs):
    with app.test_request_context("/books/new", method="POST"):
        form = BookForm(formdata=MultiDict(data), meta={"csrf": False}, **kwargs)
        ok = form.validate()
        return ok, form.errors


print(check({"title": "Dune", "isbn": "9780441172719", "pages": "412"}))
print(check({"title": "", "isbn": "123", "pages": "0"}))
print(check({"title": "x" * 200, "isbn": "9780441172719", "pages": "abc"}))

A failed conversion (pages="abc") adds "Not a valid integer value." and, because InputRequired saw a non-empty raw value, the chain continues: NumberRange then runs on None and adds its own message. Put Optional() first when a field may be empty, and expect more than one message per field. Messages are the defaults of the validators and are translated when the translation hooks described later are active; pass message= to override one.

Inline and Extra Validators

A form method called validate_<fieldname> is appended to that field’s validators, and validate() accepts per-call extra validators. Both receive (form, field) (the inline one is bound, so it receives only field):

from wtforms.validators import ValidationError


class ReviewForm(FlaskForm):
    reviewer = StringField("Your name", validators=[InputRequired(), Length(max=60)])
    rating = IntegerField("Rating", validators=[InputRequired(), NumberRange(min=1, max=5)])
    comment = TextAreaField("Comment", validators=[Length(max=1000)])

    def validate_comment(self, field):
        if self.rating.data is not None and self.rating.data <= 2 and len(field.data or "") < 20:
            raise ValidationError("Please explain a low rating in at least 20 characters.")


def banned_words(form, field):
    if "spoiler" in (field.data or "").lower():
        raise ValidationError("No spoilers, please.")


with app.test_request_context("/", method="POST"):
    data = MultiDict({"reviewer": "Ana", "rating": "1", "comment": "meh"})
    form = ReviewForm(formdata=data, meta={"csrf": False})
    print(form.validate(), form.errors)

    form = ReviewForm(formdata=data, meta={"csrf": False})
    print(form.validate(extra_validators={"comment": [banned_words]}), form.errors)

    form = ReviewForm(
        formdata=MultiDict({"reviewer": "Ana", "rating": "5", "comment": "A spoiler follows"}),
        meta={"csrf": False},
    )
    print(form.validate(extra_validators={"comment": [banned_words]}), form.errors)

Source: WTForms: Forms

validate_on_submit(extra_validators=...) takes the same dictionary of field name to list of callables (supported since Flask-WTF 1.1.0), which suits rules that depend on the request or on the database and therefore do not belong in a reusable class. Cross-field rules (a date range, "one of these two is required") go into an inline method on the last field involved, or into an overridden validate() that calls super().validate() first and then appends to form.form_errors. Since WTForms 3.2 form-level errors are stored under the empty-string key of form.errors instead of None; code that reads form.errors[None] must change.

Fields Worth Knowing

Field data is Notes

StringField, TextAreaField, PasswordField

str

PasswordField never renders the submitted value back. Strip and normalise with filters=[...].

IntegerField, DecimalField, FloatField

int, Decimal, float

Use DecimalField for money, never FloatField. A bad conversion yields None and an error message.

BooleanField

bool

Unchecked boxes send nothing, so the default must be applied by the field, not by the browser.

DateField, DateTimeField, TimeField

date, datetime, time

format is a strptime pattern; DateTimeLocalField matches the HTML datetime-local input.

SelectField, RadioField

the chosen value, converted by coerce

choices is a list of values, (value, label) pairs or a grouped dict; validate_choice=True rejects values not in choices.

SelectMultipleField

list

Same options as SelectField.

EmailField, URLField, TelField, SearchField, ColorField

str

Render the matching HTML5 input type. They live in wtforms.fields since WTForms 3; the old wtforms.fields.html5 module no longer exists.

HiddenField

str

Hidden is not trusted: the browser can change it. Never carry prices or permissions in one.

FormField, FieldList

nested form data, list

Reusable sets of fields, described next.

FileField, MultipleFileField

FileStorage or list

The Flask-WTF versions (flask_wtf.file) read from request.files; see below.

Source: WTForms: Fields

Reusable Field Sets: Inheritance, Mixins, FormField and FieldList

Fields are collected from the class and its bases, so a plain mixin class holding field declarations works without any registration. FormField embeds a whole form as one field and FieldList repeats a field or a form a variable number of times, which is how a book with several authors is submitted:

from wtforms import FieldList, FormField, SelectField
from wtforms.validators import DataRequired


class TimestampedMixin:
    notes = TextAreaField("Internal notes", validators=[Optional(), Length(max=500)])


class AuthorForm(FlaskForm):
    class Meta:
        csrf = False  # nested forms must not carry their own CSRF token

    name = StringField("Name", validators=[InputRequired(), Length(max=80)])
    born = IntegerField("Born", validators=[Optional(), NumberRange(min=1000, max=2100)])


class FullBookForm(TimestampedMixin, BookForm):
    language = SelectField("Language", choices=[("en", "English"), ("es", "Spanish")])
    authors = FieldList(FormField(AuthorForm), min_entries=1, max_entries=5)


with app.test_request_context("/", method="POST"):
    print([f.name for f in FullBookForm(formdata=None, meta={"csrf": False})])
    data = MultiDict(
        {
            "title": "Good Omens", "isbn": "9780060853983", "pages": "384", "language": "en",
            "authors-0-name": "Terry Pratchett", "authors-0-born": "1948",
            "authors-1-name": "Neil Gaiman", "authors-1-born": "x",
        }
    )
    form = FullBookForm(formdata=data, meta={"csrf": False})
    print(form.validate())
    print(form.errors)
    print([a.form.name.data for a in form.authors])

Pitfalls:

  • A nested form is a subform of the outer one: only the outer form carries the CSRF field, so give the inner class csrf = False in its Meta (or use plain wtforms.Form, which has no CSRF at all).

  • FieldList names its inputs authors-0-name, authors-1-name, …​ and the browser (or your JavaScript) is responsible for numbering them. Entries are processed in index order, and max_entries is the only upper bound, so always set it; an attacker can otherwise submit thousands of entries.

  • Use prefix="a" on the form constructor when two forms share a page, so their field names do not collide.

Custom Validators, Fields and Widgets

The three extension points of WTForms are small and compose.

Custom Validators

A validator with options is a class with __init__ and __call__; one without is a function. The Bookshelf needs an ISBN-13 check-digit validator and a "title is unique on this shelf" rule that needs state:

def isbn13(form, field):
    digits = field.data or ""
    if len(digits) != 13 or not digits.isdecimal():
        raise ValidationError("An ISBN-13 has exactly 13 digits.")
    total = sum(int(d) * (1 if i % 2 == 0 else 3) for i, d in enumerate(digits[:12]))
    if (10 - total % 10) % 10 != int(digits[12]):
        raise ValidationError("The ISBN check digit is wrong.")


class UniqueIn:
    """Reject a value that is already present in the collection returned by ``source()``."""

    def __init__(self, source, message=None):
        self.source = source
        self.message = message

    def __call__(self, form, field):
        if field.data in {item["title"] for item in self.source()}:
            raise ValidationError(self.message or "This title is already on the shelf.")


class CheckedBookForm(BookForm):
    isbn = StringField("ISBN-13", validators=[InputRequired(), isbn13])
    title = StringField(
        "Title", validators=[InputRequired(), Length(max=120), UniqueIn(lambda: BOOKS)]
    )


for isbn in ("9780441172719", "9780441172710"):
    with app.test_request_context("/", method="POST"):
        form = CheckedBookForm(
            formdata=MultiDict({"title": "Dune", "isbn": isbn, "pages": "412"}), meta={"csrf": False}
        )
        print(isbn, form.validate(), form.errors)

Two details decide whether a validator is correct. Raise StopValidation when an error makes the following validators meaningless (the built-in Optional and FileRequired do), and never mutate field.data inside a validator; convert in a filters=[...] callable or in the field instead. A validator that queries the database is subject to a race (two requests can both pass the check): keep the uniqueness constraint in the database as well and handle the integrity error.

Custom Fields

A field converts between form input and Python. Override process_formdata(valuelist) to turn the list of submitted strings into data, and _value() to produce the string shown in the widget:

from wtforms import Field
from wtforms.widgets import TextInput


class TagListField(Field):
    """Comma separated text in the browser, list[str] in Python."""

    widget = TextInput()

    def _value(self):
        return ", ".join(self.data) if self.data else ""

    def process_formdata(self, valuelist):
        if valuelist:
            tags = [t.strip().lower() for t in valuelist[0].split(",")]
            self.data = sorted({t for t in tags if t})
        else:
            self.data = []


class TaggedBookForm(FlaskForm):
    tags = TagListField("Tags", validators=[Length(max=5, message="At most five tags.")])


with app.test_request_context("/", method="POST"):
    form = TaggedBookForm(formdata=MultiDict({"tags": "Sci-Fi, dune, sci-fi , classic"}), meta={"csrf": False})
    print(form.validate(), form.tags.data, form.tags())

The framework calls process_formdata only when the browser sent a value for the field. Raise ValueError from it to report a conversion error; the message is added to field.errors and Length does not run on a failed conversion.

Custom Widgets

A widget is a callable widget(field, **kwargs) that returns HTML (a Markup string, so Jinja does not escape it again). html_params builds the attribute string and escapes the values:

from markupsafe import Markup
from wtforms.widgets import html_params


class RatingWidget:
    """Render 1..max as radio buttons inside a fieldset."""

    def __init__(self, max_stars: int = 5):
        self.max_stars = max_stars

    def __call__(self, field, **kwargs):
        kwargs.setdefault("id", field.id)
        parts = [f"<fieldset {html_params(id=kwargs['id'], class_='rating')}>"]
        for value in range(1, self.max_stars + 1):
            attrs = html_params(type="radio", name=field.name, value=value, id=f"{field.id}-{value}")
            checked = " checked" if field.data == value else ""
            parts.append(f'<label><input {attrs}{checked}> {value}</label>')
        parts.append("</fieldset>")
        return Markup("".join(parts))


class StarReviewForm(FlaskForm):
    rating = IntegerField("Rating", widget=RatingWidget(), validators=[InputRequired(), NumberRange(1, 5)])


with app.test_request_context("/", method="POST"):
    form = StarReviewForm(formdata=MultiDict({"rating": "4"}), meta={"csrf": False})
    print(form.validate())
    print(form.rating())

Stock widgets (TextArea, Select, ListWidget, TableWidget, PasswordInput, HiddenInput, FileInput, the HTML5 input widgets such as NumberInput(step=..., min=..., max=...)) cover most needs. Write a custom one when the markup differs (a rating control, a date picker wrapper); do not use one to hide logic that belongs to a validator. Since WTForms 3.2 the hidden, range and select widgets no longer emit the required attribute, to follow the W3C rules.

Choices, Dynamic Choices and QuerySelectField

SelectField takes choices at declaration time, but real option lists come from the database or depend on the user, and the class body runs at import. Set the choices on the instance, in the view or in __init__; validation compares the submitted value with the choices at validation time, so they must be set before validate_on_submit():

GENRES = [(1, "Science fiction"), (2, "Fantasy"), (3, "History")]


class ShelfForm(FlaskForm):
    genre = SelectField("Genre", coerce=int, validators=[InputRequired()])
    format = SelectField("Format", choices=[("pb", "Paperback"), ("hc", "Hardcover")])

    def __init__(self, *args, genres=(), **kwargs):
        super().__init__(*args, **kwargs)
        self.genre.choices = [(g_id, label) for g_id, label in genres]


with app.test_request_context("/", method="POST"):
    ok = ShelfForm(formdata=MultiDict({"genre": "2", "format": "pb"}), genres=GENRES, meta={"csrf": False})
    bad = ShelfForm(formdata=MultiDict({"genre": "99", "format": "pb"}), genres=GENRES, meta={"csrf": False})
    print(ok.validate(), ok.genre.data, bad.validate(), bad.errors)

coerce converts the submitted string to the type of your keys (int, uuid.UUID, …​); without it a numeric key never equals the string the browser sent and "Not a valid choice" appears for a value that looks right. validate_choice=False disables the membership test and should only be used when another validator checks the value.

The cookbook this section builds on uses QuerySelectField from wtforms.ext.sqlalchemy.fields. That package was removed in WTForms 3 and the code moved to the separate wtforms-sqlalchemy distribution, whose module is wtforms_sqlalchemy. The field takes a query_factory returning the objects, builds the options from their primary keys, and returns the selected object as data:

from sqlalchemy import String, create_engine, select
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
from wtforms_sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
from wtforms_sqlalchemy.orm import model_form


class Base(DeclarativeBase):
    pass


class Genre(Base):
    __tablename__ = "genre"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(50))

    def __str__(self):
        return self.name


class Book(Base):
    __tablename__ = "book"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(120))
    pages: Mapped[int]


engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
session = Session(engine)
session.add_all([Genre(name="Science fiction"), Genre(name="Fantasy")])
session.commit()


class GenreForm(FlaskForm):
    genre = QuerySelectField(
        "Genre",
        query_factory=lambda: session.scalars(select(Genre).order_by(Genre.name)).all(),
        get_label="name",
        allow_blank=True,
        blank_text="-- choose --",
    )


with app.test_request_context("/", method="POST"):
    form = GenreForm(formdata=MultiDict({"genre": "2"}), meta={"csrf": False})
    print(form.validate(), repr(form.genre.data.name))
    print(form.genre())

BookModelForm = model_form(Book, db_session=session, base_class=FlaskForm, only=["title", "pages"])
with app.test_request_context("/", method="POST"):
    form = BookModelForm(formdata=MultiDict({"title": "Dune", "pages": "412"}), meta={"csrf": False})
    print(form.validate(), [f.name for f in form])

The query factory is called on every render and validation, so it must return a fresh list (or query) every time, and the session it uses must be the request’s session. allow_blank=True adds the empty option; the submitted value of the blank option is __None by default. model_form generates a form class from a mapped class and is good for prototypes and admin screens; for public forms prefer an explicit class, because a generated form exposes whichever columns it finds and nothing reviews that list when a column is added (restrict it with only= or exclude=). With Flask-SQLAlchemy, pass db.session; the model layer itself is covered on the Flask-SQLAlchemy page of this section.

CSRF Protection

Cross-site request forgery makes the victim’s browser send a state-changing request that the browser authenticates with its own cookies. The defence Flask-WTF implements is the synchroniser token pattern: the server stores a random value in the session and puts a signed, time-stamped version of it in every form; a forged request cannot know the token.

sequenceDiagram participant B as Browser participant S as Flask app B->>S: GET /books/new S->>S: generate_csrf stores a random value in the session S-->>B: HTML with the signed token in a hidden csrf_token field, plus the session cookie B->>S: POST /books/new with csrf_token and the session cookie S->>S: validate_csrf checks signature, age and the value stored in the session alt valid S-->>B: 302 redirect else missing, expired or mismatched S-->>B: form error, or 400 CSRFError with CSRFProtect end
Figure 2. How a CSRF token travels between browser and server

Forms: Automatic

Every FlaskForm has a csrf_token hidden field and validates it in validate(). Nothing else is needed beyond form.hidden_tag() (or {{ form.csrf_token }}) in the template. The token is bound to the session cookie, signed with WTF_CSRF_SECRET_KEY (default SECRET_KEY), and valid for WTF_CSRF_TIME_LIMIT seconds (default 3600). Because the token depends on the session, a form left open longer than the hour, or after the session was cleared, fails with "The CSRF token has expired." Users who keep forms open overnight need a longer limit (or None, meaning the token lives as long as the session).

Configuration key Default Effect

WTF_CSRF_ENABLED

True

Master switch for form CSRF fields and CSRFProtect. Turn off only in tests.

WTF_CSRF_SECRET_KEY

SECRET_KEY

Key that signs the token.

WTF_CSRF_TIME_LIMIT

3600

Token lifetime in seconds; None means the lifetime of the session.

WTF_CSRF_FIELD_NAME

"csrf_token"

Name of the form field and the session key.

WTF_CSRF_HEADERS

["X-CSRFToken", "X-CSRF-Token"]

Headers CSRFProtect accepts as an alternative to the form field.

WTF_CSRF_METHODS

{"POST", "PUT", "PATCH", "DELETE"}

Methods CSRFProtect checks.

WTF_CSRF_CHECK_DEFAULT

True

When False, CSRFProtect does not check automatically; call csrf.protect() yourself.

WTF_CSRF_SSL_STRICT

True

On HTTPS, CSRFProtect also requires a Referer header from the same origin.

WTF_CSRF_META_NAME

"csrf-token"

Name of the tag produced by csrf_meta_tag().

WTF_I18N_ENABLED

True

Use Flask-Babel’s locale for validator messages when Flask-Babel is installed.

Per form, WTForms' Meta class overrides the behaviour: meta={"csrf": False} in the constructor, or class Meta: csrf = False in the class, switches the token off for that form (for example a search form submitted by GET or a nested form). The older csrf_enabled=False constructor keyword (used by the books) no longer has any effect: FlaskForm passes unknown keyword arguments on as object data, so the form stays protected and nothing warns you. Use meta.

CSRFProtect: Non-WTForms Views, AJAX and APIs

CSRFProtect extends the same check to every unsafe-method request of the application, whether or not a FlaskForm is involved, by registering a before_request hook. It reads the token from the form field or from the X-CSRFToken or X-CSRF-Token header, so JavaScript clients can send it without a form. csrf_token() is a Jinja global, and csrf_meta_tag() (Flask-WTF 1.3) renders a <meta name="csrf-token"> tag that scripts can read:

from flask import jsonify
from flask_wtf.csrf import CSRFError, CSRFProtect, generate_csrf, validate_csrf

csrf_app = Flask("csrf_demo")
csrf_app.config["SECRET_KEY"] = os.environ.get("BOOKSHELF_SECRET_KEY") or secrets.token_hex(32)
csrf = CSRFProtect(csrf_app)

PAGE_WITH_SCRIPT = """
<!doctype html>
<head>{{ csrf_meta_tag() }}</head>
<body>
<script>
  const token = document.querySelector('meta[name="csrf-token"]').content;
  fetch("/api/like", {method: "POST", headers: {"X-CSRFToken": token, "Content-Type": "application/json"},
                      body: JSON.stringify({book: 1})});
</script>
</body>
"""


@csrf_app.get("/")
def index():
    return render_template_string(PAGE_WITH_SCRIPT)


@csrf_app.post("/api/like")
def like():
    return jsonify(liked=True)


@csrf_app.post("/webhooks/printer")
@csrf.exempt  # called by a server, authenticated with a signature instead of a cookie
def printer_webhook():
    return jsonify(ok=True)


@csrf_app.errorhandler(CSRFError)
def csrf_failed(error):
    return jsonify(error="csrf", reason=error.description), 400


with csrf_app.test_client() as client:
    html = client.get("/").get_data(as_text=True)
    meta_token = re.search(r'<meta name="csrf-token" content="([^"]+)"', html).group(1)

    print(client.post("/api/like", json={"book": 1}).get_json())
    print(client.post("/api/like", json={"book": 1}, headers={"X-CSRFToken": meta_token}).get_json())
    print(client.post("/webhooks/printer", json={}).get_json())
    print(client.post("/api/like", json={"book": 1}, headers={"X-CSRFToken": "forged"}).get_json())

How the pieces fit:

  • Order matters. Create CSRFProtect before the first request is handled (inside the application factory with csrf.init_app(app)); Flask refuses to register hooks after that.

  • Both mechanisms can coexist. When CSRFProtect has already validated a request it marks it in g, and the form’s own CSRF check is skipped, so the token is not verified twice.

  • Exemptions. @csrf.exempt takes a view function (decorator order matters: it goes below @route) or a blueprint. Exempt only endpoints that do not rely on cookies for authentication, such as machine-to-machine webhooks.

  • Error handling. A failed check raises CSRFError, a 400 Bad Request; the default page shows the reason. Register an error handler (see Error Handling) to render something friendlier or JSON.

  • Reading the token yourself. generate_csrf() returns the signed token for the current session (the same value for the whole request) and validate_csrf(data) raises wtforms.ValidationError on failure, for protocols where the token travels in an unusual place.

  • HTTPS and the referrer. With WTF_CSRF_SSL_STRICT on, an HTTPS request without a matching Referer is refused even with a valid token, which breaks clients that strip the header; the failure message is "The referrer header is missing."

  • JSON APIs that use tokens in the Authorization header are not CSRF-prone, because the browser does not attach that header on its own, so they typically exempt the API blueprint. APIs authenticated with the session cookie are prone and need the header.

  • Cookies still matter. The session cookie should be SameSite=Lax (Flask’s SESSION_COOKIE_SAMESITE, covered on the sessions page of this section) and Secure in production; SameSite reduces the attack surface but does not replace the token.

  • Do not turn it off to "make it work". In tests set WTF_CSRF_ENABLED = False in a testing configuration, or fetch a real token as the examples above do; never disable it in the production config.

File Fields

flask_wtf.file.FileField reads FileStorage objects from request.files (WTForms' own FileField does not, which is why the book’s import from wtforms needs to change). Validators check presence, extension and size, and the form must be sent as multipart/form-data:

import tempfile
from io import BytesIO
from pathlib import Path

from flask_wtf.file import FileAllowed, FileField, FileRequired, FileSize, MultipleFileField
from werkzeug.utils import secure_filename


class CoverForm(FlaskForm):
    cover = FileField(
        "Cover image",
        validators=[
            FileRequired(),
            FileAllowed(["jpg", "jpeg", "png", "webp"], "Images only."),
            FileSize(max_size=2 * 1024 * 1024, message="At most 2 MiB."),
        ],
    )
    extras = MultipleFileField("Extra pages", validators=[Optional(), FileAllowed(["pdf"])])


UPLOAD_DIR = Path(tempfile.mkdtemp())  # in a real app: a directory outside the web root

files_app = Flask("files")
files_app.config["SECRET_KEY"] = os.environ.get("BOOKSHELF_SECRET_KEY") or secrets.token_hex(32)
files_app.config["MAX_CONTENT_LENGTH"] = 4 * 1024 * 1024


@files_app.route("/books/<int:book_id>/cover", methods=["GET", "POST"])
def upload_cover(book_id):
    form = CoverForm()
    if request.method == "GET":
        return form.hidden_tag()
    if form.validate_on_submit():
        name = f"{book_id}-{secrets.token_hex(8)}-{secure_filename(form.cover.data.filename)}"
        form.cover.data.save(UPLOAD_DIR / name)
        return {"stored": name}, 201
    return {"errors": form.errors}, 422


with files_app.test_client() as client:
    token = csrf_token_from(client.get("/books/7/cover").get_data(as_text=True))
    png = (BytesIO(b"\x89PNG\r\n\x1a\n" + b"0" * 64), "../../evil name.png")
    ok = client.post("/books/7/cover", data={"csrf_token": token, "cover": png}, content_type="multipart/form-data")
    print(ok.status_code, ok.get_json()["stored"].endswith("-evil_name.png"), len(list(UPLOAD_DIR.iterdir())))
    exe = (BytesIO(b"MZ"), "setup.exe")
    bad = client.post("/books/7/cover", data={"csrf_token": token, "cover": exe}, content_type="multipart/form-data")
    print(bad.status_code, bad.get_json())
    none = client.post("/books/7/cover", data={"csrf_token": token}, content_type="multipart/form-data")
    print(none.status_code, none.get_json())
  • FileRequired fails when the browser sent an empty file input, which DataRequired or InputRequired cannot detect.

  • FileAllowed compares the lower-cased filename suffix against a list of extensions (or a Flask-Uploads UploadSet). The filename is chosen by the client, so this is not a content check; verify the content (for images, open the file with an imaging library) before trusting it.

  • FileSize(max_size, min_size=0) is in bytes and validates one or several files (since Flask-WTF 1.2.0 the three file validators also accept MultipleFileField). Combine it with MAX_CONTENT_LENGTH, the request-level limit that rejects oversized bodies with 413 before the form is built (see Request Data).

  • Always pass the client’s name through secure_filename() and add a random component, as above, or store under an identifier of your own; never use the raw name as a path. secure_filename lives in werkzeug.utils; the old from werkzeug import secure_filename in the books no longer works.

  • Storing the file, serving it with send_from_directory, size limits and image processing are the subject of the File Uploads page of this section.

The reCAPTCHA Field

Flask-WTF ships RecaptchaField, which renders Google reCAPTCHA v2 (or a compatible service) and verifies the response with the provider’s server-side API. It is mentioned here for completeness rather than recommended: bots increasingly solve challenges, and a rate limiter, honeypot field or a provider with a privacy-respecting challenge is often a better answer. The keys come from configuration, never from source:

from flask_wtf import RecaptchaField

app.config["RECAPTCHA_PUBLIC_KEY"] = os.environ.get("RECAPTCHA_PUBLIC_KEY", "")
app.config["RECAPTCHA_PRIVATE_KEY"] = os.environ.get("RECAPTCHA_PRIVATE_KEY", "")


class ContactForm(FlaskForm):
    name = StringField("Name", validators=[InputRequired()])
    captcha = RecaptchaField()


# The validator is skipped when app.testing is true or RECAPTCHA_ENABLED is false.
app.config["TESTING"] = True
with app.test_request_context("/", method="POST"):
    form = ContactForm(formdata=MultiDict({"name": "Ana"}), meta={"csrf": False})
    print(form.validate())
app.config["TESTING"] = False

RECAPTCHA_PUBLIC_KEY and RECAPTCHA_PRIVATE_KEY are required (the widget and the verification raise RuntimeError when they are missing); RECAPTCHA_PARAMETERS, RECAPTCHA_DATA_ATTRS, RECAPTCHA_SCRIPT, RECAPTCHA_DIV_CLASS, RECAPTCHA_HTML and RECAPTCHA_VERIFY_SERVER customise the script, the markup and the verification endpoint (which is how hCaptcha or Turnstile are plugged in, since Flask-WTF 1.0). Version 1.3 added a nonce argument for pages with a nonce-based Content Security Policy. The page loads a third-party script and sends the visitor’s data to the provider, so account for it in your privacy notice and your CSP.

Translating Validation Messages

WTForms ships translated validator messages. With Flask-Babel installed and WTF_I18N_ENABLED true (the default), Flask-WTF uses the locale that Flask-Babel selected for the request. Without Flask-Babel, or with the switch off, the Meta.locales list picks the language, which is also the easiest way to test it:

app.config["WTF_I18N_ENABLED"] = False


class SpanishBookForm(BookForm):
    class Meta(BookForm.Meta):
        locales = ["es"]


with app.test_request_context("/", method="POST"):
    form = SpanishBookForm(formdata=MultiDict({"title": "", "isbn": "x", "pages": "1"}), meta={"csrf": False})
    form.validate()
    print(form.errors["title"])

app.config["WTF_I18N_ENABLED"] = True

Labels and your own messages are not translated automatically; wrap them in lazy_gettext (see the internationalisation page of this section). Message strings in message= arguments are shown verbatim.

Testing Forms

Two approaches work, and both avoid weakening production configuration:

  • Unit-test the form in a test_request_context with meta={"csrf": False}, as the examples above do. It tests validators and conversions without HTTP.

  • Test the view with the test client. Either configure WTF_CSRF_ENABLED = False in the testing config, which skips every token check, or do the real round trip (GET the page, extract the token with a regular expression or an HTML parser, POST it back) to prove that the template really contains hidden_tag(). Keep at least one test of the second kind per form, since the first can hide a missing token.

Test the failure paths too: a missing token, a tampered token and an expired token (set WTF_CSRF_TIME_LIMIT to -1 inside the test to expire immediately).

Pitfalls and Security Summary

  • Missing hidden_tag() (or form.csrf_token) makes every POST fail with "The CSRF token is missing."

  • validate() returns False for unsubmitted forms. Use validate_on_submit() in views; a GET form never validates.

  • Choices must be set before validation, and coerce must match the type of the choice values.

  • DataRequired rejects 0 and False. Prefer InputRequired for numbers and use Optional deliberately.

  • Never trust HiddenField, readonly or disabled. They are rendering hints; validate on the server.

  • Allow-list, do not deny-list, for uploads, redirect targets (next parameters) and sort columns that come from forms.

  • Mass assignment. Do not copy form.data straight onto a model (Book(**form.data)); it includes the CSRF token and the submit button, and lets a future field reach an attribute you did not intend. Assign fields explicitly or with form.populate_obj(book) on a form that holds only the editable fields.

  • Re-displaying input is safe only because of autoescaping. Do not build field HTML with string formatting and |safe.

  • A SECRET_KEY that changes between restarts invalidates every open form and session; keep it stable and rotate it deliberately.

  • Cookies and CORS. CSRF protection assumes the browser sends cookies only from your own site; a permissive CORS policy with credentials defeats it (see CORS).

Recent Changes and Where the Books Are Outdated

Version Change Consequence

Flask-WTF 1.3.0 (2026-04-23)

csrf_meta_tag() and WTF_CSRF_META_NAME; nonce for RecaptchaField; a JSON body is read as form data by FlaskForm; X-CSRF-Token accepted next to X-CSRFToken; requires Python 3.10+.

Documented only in the package at the time of writing, because the documentation site still shows 1.2.x.

Flask-WTF 1.2.0 (2023-10-01)

MultipleFileField; FileRequired, FileAllowed, FileSize work on several files.

Use it instead of a loop over request.files.getlist.

Flask-WTF 1.1.0 (2023-01-15)

validate_on_submit(extra_validators=...); Flask-BabelEx no longer supported.

Use Flask-Babel.

Flask-WTF 1.0.0 (2021-11-07)

Deprecated items removed; alternative CAPTCHA services supported.

csrf_enabled= and similar old arguments are gone.

WTForms 3.2.0 (2024-10-20)

AnyOf and NoneOf validate multi-valued fields; form-level errors under the key ""; Python 3.9 dropped.

Code reading form.errors[None] must change.

WTForms 3.0

wtforms.ext.*, TextField, validators.Required and wtforms.fields.html5 removed.

Use StringField, DataRequired or InputRequired, and wtforms-sqlalchemy.

The cookbook chapter on forms is useful for its structure (a form per concern, shared field sets, a custom field, a custom widget, a file upload and CSRF with AJAX) and every one of those ideas is covered above, but its code predates the changes in the table:

  • from flask_wtf import Form and Form as the base class: flask_wtf.Form is now only a re-export of WTForms' plain Form, which has no request integration and no CSRF field. Use FlaskForm.

  • from wtforms import TextField raises ImportError; use StringField. validators.Required is likewise gone.

  • from wtforms.ext.sqlalchemy.fields import QuerySelectField fails because wtforms.ext no longer exists; import from wtforms_sqlalchemy.fields.

  • FileField must come from flask_wtf.file, and secure_filename from werkzeug.utils.

  • Form(csrf_enabled=False) was replaced by meta={"csrf": False}; the old keyword is now silently ignored.

  • Queries written as Model.query.all() in the query_factory are the legacy SQLAlchemy API; use session.scalars(select(Model)) or db.session.scalars(db.select(Model)).

  • Examples with a literal SECRET_KEY or a key committed to the repository must be replaced by a generated key held in the environment.

Summary

  • A FlaskForm declares fields and validators, reads request.form and request.files itself, and signs a CSRF token.

  • In a view: build the form, validate_on_submit(), act and redirect; otherwise render with the errors and a 422.

  • Put the token in the page with form.hidden_tag(); for AJAX and non-WTForms views use CSRFProtect and the X-CSRFToken header (or csrf_meta_tag()).

  • Custom validators are callables, custom fields override process_formdata and _value, custom widgets return Markup.

  • Set choices on the instance before validating; use wtforms-sqlalchemy for query-backed selects.

  • File fields come from flask_wtf.file, and the extension check is not a content check.