Forms with Flask-WTF
Bookshelf needs a way for a user to add a new book: a title, an author, a page count, a
reading status. You could build that as plain HTML and pull values out of
request.form by hand — but Flask, true to its microframework philosophy, ships
no forms library of its own. Unlike Django, which bundles a forms system into the core
framework, Flask leaves this entirely up to you to choose. The overwhelming choice the
community has settled on is Flask-WTF.
Why Flask-WTF
Flask-WTF is a thin, Flask-friendly wrapper around WTForms, a mature, framework-agnostic Python forms library. WTForms alone can define fields and validators; Flask-WTF adds the glue that makes it feel native inside a Flask app — automatic CSRF protection (covered fully in the next lesson), easy integration with Flask's request object, and file-upload support. In practice, "using WTForms in Flask" and "using Flask-WTF" mean the same thing — it's the standard, and there's little reason to reach for anything else for server-rendered forms.
Installing it is a single pip command:
pip install flask-wtf
Defining a Form Class
A Flask-WTF form is a plain Python class that inherits from FlaskForm, with each
field declared as a class attribute. Here's Bookshelf's form for adding a new book:
# forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, IntegerField, SubmitField
from wtforms.validators import DataRequired
class AddBookForm(FlaskForm):
title = StringField('Title', validators=[DataRequired()])
pages = IntegerField('Pages')
submit = SubmitField('Add Book')
Each field is given a label string ('Title', 'Pages') — that's the
human-readable text shown next to the input when the field renders — and, optionally, a list
of validators that will run when the form is submitted. DataRequired()
is the most basic one: it rejects an empty submission. The full validators picture, including
custom per-field validation, is the subject of the next lesson,
Form Validation & CSRF.
Field Types: A Reference
WTForms ships a set of field classes covering the common HTML input types. These are the ones Bookshelf's forms lean on most:
| Field class | Renders as | Bookshelf usage |
|---|---|---|
StringField | <input type="text"> | A book's title, an author's name |
IntegerField | <input type="text">, coerced to int | A book's pages |
SelectField | <select> dropdown | A book's status — want_to_read / reading / finished |
BooleanField | <input type="checkbox"> | e.g. "Mark as a favorite" |
SubmitField | <input type="submit"> | The form's submit button |
The SelectField needs a bit more than the others — it must be told what options
to offer, via a choices argument of (value, label) tuples:
from wtforms import SelectField, BooleanField
class AddBookForm(FlaskForm):
# ...title, pages, submit as above...
status = SelectField(
'Status',
choices=[
('want_to_read', 'Want to Read'),
('reading', 'Currently Reading'),
('finished', 'Finished'),
],
)
is_favorite = BooleanField('Mark as favorite')
Rendering a Form in a Template
A form instance is passed to a template like any other variable, and each field knows how to
render its own label and its own input tag. Calling the field — form.title() —
produces the actual <input> HTML; accessing form.title.label
produces the matching <label>. Here's the full pattern for Bookshelf's
add-book form:
<!-- templates/add_book.html -->
{% extends "base.html" %}
{% block content %}
<form method="POST">
{{ form.hidden_tag() }}
{{ form.title.label }} {{ form.title() }}
{{ form.pages.label }} {{ form.pages() }}
{{ form.status.label }} {{ form.status() }}
{{ form.is_favorite.label }} {{ form.is_favorite() }}
{{ form.submit() }}
</form>
{% endblock %}
{{ form.hidden_tag() }} is the one line in this template that has nothing to do
with any field you defined — it renders a hidden CSRF-protection token that Flask-WTF adds to
every FlaskForm automatically. We'll unpack exactly what that token protects
against, and what happens without it, in the next lesson,
Form Validation & CSRF.
Handling Submission in a View
The standard Flask-WTF pattern is the same one view function handling both cases of a form's
life: a plain GET request that should show an empty form, and a POST
request carrying submitted data that needs validating and saving. The shortcut that makes this
genuinely convenient is form.validate_on_submit() — a single method call that
checks both "was this request a POST?" and "did every validator
pass?" in one go. If either check fails, it returns False and the view falls
through to re-rendering the form (with error messages already attached, as the next lesson
covers).
# app.py
from flask import Flask, render_template, redirect, url_for
from forms import AddBookForm
app = Flask(__name__)
app.config['SECRET_KEY'] = 'change-this-in-production'
@app.route('/books/add', methods=['GET', 'POST'])
def add_book():
form = AddBookForm()
if form.validate_on_submit():
# GET requests never reach here; only a valid POST does
book = Book(
title=form.title.data,
pages=form.pages.data,
status=form.status.data,
user_id=current_user_id(),
)
db.session.add(book)
db.session.commit()
return redirect(url_for('book_list'))
# GET request, or a POST that failed validation: show the (possibly re-populated) form
return render_template('add_book.html', form=form)
Notice what form.validate_on_submit() saves you from writing: no manual
if request.method == 'POST': check, no manual loop calling each validator, no
manual check of a returned boolean. Once validation succeeds, each field's cleaned, correctly
typed value is available as form.<fieldname>.data —
form.title.data is a Python str, form.pages.data is
already a Python int (WTForms performed that conversion for you as part of
validation), ready to hand straight to a new Book row.
hidden_tag() CSRF token protects Bookshelf.