Flask-WTF and WTForms in Flask 3: A Complete Guide to Forms

- Andrés Cruz - ES En español

Video thumbnail

Forms are a crucial part of modern web applications: they are the primary mechanism for capturing user data. Although forms are purely HTML and can be used directly in Flask, the problem arises when validations need to be applied on both the client and—most importantly—the server, to ensure that the received data is completely valid before processing or storing it.

We pick up where we left off, now that we know how to use sessions in Flask.

In my first projects, when I was starting out with frameworks like Flask, I remember how frustrating it was to maintain duplicate validations: a required attribute in HTML and another block of code in Python. Flask-WTF came to solve exactly that, and in this guide, I explain how to use it correctly in Flask 3, with real-world examples and best practices.

Another common issue is the mapping between form fields and database models. Usually, these fields have a direct relationship with tables: if we are going to create a module to manage posts whose fields are title, description, and content, those same fields must exist in the database. Without a proper tool, we end up defining the same structure twice.

Why are forms essential in Flask?

A form does not only capture data: it must also validate, protect, and process it before saving it to the database. In pure Flask, you could handle everything with HTML and request.form, but you will soon discover that this involves repeating the same logic in two different places, and keeping them in sync is a constant source of errors.

With WTForms, we save ourselves that "double work": fields and their validations are defined only once in Python, and the library takes care of rendering them in HTML and validating incoming data on the server side. The syntax is clean, expressive, and very easy to maintain.

What is WTForms and how does it work with Flask 3?

WTForms is an independent library for handling server-side forms and validations. Flask, being a minimalist microframework, delegates this type of functionality to external packages; WTForms is one of the most established in the ecosystem.

Flask-WTF is the official integration of WTForms with Flask, adding native support for Jinja2 templates, automatic CSRF protection, and helpers that simplify the entire form workflow.

The duplication issue occurs with any server-side technology: if we want a text field for a required name, in HTML we would have something like:

<input type="text" name="task" required>

And on the server, using pure Flask, it would be something like:

task = request.form.get('task')
if task is not None:
    # TO DO

If at any point we need to change or expand that validation, we would have to do it on both sides. Flask-WTF eliminates that problem by centralizing all validation logic into a single Python class.

With WTForms, we define fields and their validations as properties of a class that inherits from FlaskForm. A typical class looks like this:

from flask_wtf import FlaskForm
from wtforms import StringField
from wtforms.validators import InputRequired

class Task(FlaskForm):
    name = StringField('Name', validators=[InputRequired()])

Validations are mainly applied on the server side, which is the most critical. In addition, depending on the defined validation, WTForms can automatically add HTML attributes to the rendered field. For example, InputRequired generates:

<input id="name" name="name" required type="text" value="">

WTForms: Step-by-step installation and form creation

Let's start by installing the package with pip:

$ pip install Flask-WTF

This command installs two packages: the core WTForms library:

WTForms==***

And the official connector for Flask:

Flask-WTF==***

Throughout this guide, we will use the terms WTForms, Flask-WTF, and Flask WTF interchangeably to refer to the same set of tools.

Main WTForms fields

WTForms provides different types of fields that we can use to build our forms. The most common ones are:

  • StringField: Represents a text field (input type="text").
  • FileField: Represents a file input field (input type="file").
  • DateField and DateTimeField: Represent date or datetime fields in Python. They accept an optional format parameter to define the date pattern; by default, it is format='%Y-%m-%d'. If you need a native date picker in the browser, consider DateTimeLocalField imported from wtforms.fields.
  • IntegerField: Represents an integer field.
  • FloatField: Represents a float field.
  • PasswordField: Represents a password field (input type="password"). For security reasons, the value is never repopulated when re-rendering the form.
  • RadioField: Represents radio buttons in HTML. It is configured using the choices parameter, which accepts a Python list or tuple of available options.
  • SelectField: Represents a select field (<select>). Like RadioField, it receives a choices parameter with the list of options.

You can check the complete list of available fields in the official documentation: https://wtforms.readthedocs.io/en/master/fields/

Validations available in WTForms

In addition to defining fields, we can add validations by importing them from wtforms.validators:

from wtforms.validators import ***

Among the most commonly used are:

  • InputRequired: Validates that the field is not empty upon submission. Unlike DataRequired, it also captures whitespace strings.
  • DataRequired: Indicates that the field is required and validates that the value is "truthy" in Python (discards None, empty strings, and 0).
  • NumberRange: Sets a minimum and maximum value for numeric fields. Accepts min and max parameters.
  • Length: Sets a minimum and maximum length for text fields. Accepts min and max parameters.
  • Regexp: Validates the field value against a regular expression.
  • Email: Validates that the value has a valid email address format.
  • URL: Validates that the value is a valid URL.
  • EqualTo: Compares the value of one field with another; useful for password confirmation fields.

You can check the complete list of validators in the official documentation: https://wtforms.readthedocs.io/en/master/validators/

From the controller, we verify whether the form is valid before performing any operation. The key method is validate_on_submit(), which returns True only when the request is POST and all validations pass successfully:

@app.route('/submit', methods=['GET', 'POST'])
def submit():
    form = MyForm()
    if form.validate_on_submit():
        # TO DO: save data, redirect, etc.

From the Jinja2 template, we can display errors for a specific field, for example, the name field:

{% if form.name.errors %}
    <ul class="errors">
    {% for error in form.name.errors %}
        <li>{{ error }}</li>
    {% endfor %}
    </ul>
{% endif %}

Or iterate over the errors of all fields at once with form.errors:

{% if form.errors %}
    <ul class="errors">
    {% for field, errors in form.errors.items() %}
        {% for error in errors %}
            <li>{{ field }}: {{ error }}</li>
        {% endfor %}
    {% endfor %}
    </ul>
{% endif %}

Custom validations

Many times we need more complex rules than those offered by predefined validators: querying the database, applying business logic, or cross-checking values between fields. In those cases, we can define a method with the pattern validate_<field_name> inside the form class. WTForms automatically detects it and executes it when validate_on_submit() is called.

For example, to add a custom validation to the name field in the my_app/tasks/forms.py file:

from wtforms.validators import ValidationError

class Task(FlaskForm):
    # ... other fields ...

    def validate_name(form, field):
        if len(field.data) > 2:
            raise ValidationError('Name must be less than 2 characters')

By raising a ValidationError exception, the error becomes available in form.name.errors to be displayed in the template.

CSRF Protection

When using Flask-WTF for the first time without configuring a secret key, you will see the following error:

RuntimeError: A secret key is required to use CSRF.

Flask-WTF includes automatic CSRF (Cross-Site Request Forgery) protection on all forms that inherit from FlaskForm. This protection generates a unique token per session that is validated on every submission, preventing external sites from submitting forms on behalf of the user without their consent.

To enable it, simply define SECRET_KEY in the configuration file my_app/config.py:

class Config(object):
    SQLALCHEMY_DATABASE_URI = "mysql+pymysql://root:@localhost:3306/test_flask"
    SECRET_KEY = "SECRETKEY"
    # ...

In production, always use a long, random value stored in an environment variable; never include secret keys directly in the source code.

In the template, the CSRF token is included using the form.hidden_tag() helper, which automatically generates the required hidden field:

<form method="POST">
    {{ form.hidden_tag() }}
    {# rest of the form #}
</form>

In my early attempts, I forgot to configure the SECRET_KEY, and the "a secret key is required to use CSRF" error appeared immediately. Since then, it has been one of my first steps when starting any Flask project.

Creating a task form with WTForms

With all of the above clear, we implement the form for our task application. We start by defining the class in my_app/tasks/forms.py:

from flask_wtf import FlaskForm
from wtforms import StringField
from wtforms.validators import InputRequired

class Task(FlaskForm):
    name = StringField('Name', validators=[InputRequired()])

The name field is a text field (StringField) and is marked as required using InputRequired. That is how concise and expressive the definition is with WTForms.

From the controller my_app/tasks/controllers.py, we instantiate the form and check its validity:

from my_app.tasks import forms

@taskRoute.route('/create', methods=('GET', 'POST'))
def create():
    form = forms.Task()
    if form.validate_on_submit():
        print("listo")
        # return redirect('/success')
    return render_template('dashboard/task/create.html', form=form)

When accessed via GET, the form is rendered empty. When submitted via POST, validate_on_submit() executes all defined validations, and if they pass, we can proceed to save the data.

Best practices and common pitfalls with Flask-WTF

  • ✅ Always validate on the server side. Client-side validation (HTML5 or JavaScript) can be bypassed; server-side validation cannot.
  • Avoid duplicating validations between HTML and Python. WTForms automatically adds certain HTML attributes (such as required), so trust it.
  • Separate concerns: the form validates, the controller processes, and the model saves. Mixing these layers makes maintenance difficult.
  • Use custom error messages to improve user experience. For example: InputRequired(message='This field is required.')
  • Never expose your SECRET_KEY in the repository. Use environment variables and python-dotenv to manage them securely.

In my experience, these small adjustments make a big difference in real projects, especially as forms grow in complexity.

Complete example: Task form in Flask 3

Form — forms.py

from flask_wtf import FlaskForm
from wtforms import StringField
from wtforms.validators import InputRequired

class Task(FlaskForm):
    name = StringField('Name', validators=[InputRequired()])

Controller — controllers.py

@app.route('/create', methods=['GET', 'POST'])
def create():
    form = Task()
    if form.validate_on_submit():
        print("Tarea creada correctamente")
    return render_template('task_form.html', form=form)

Template — task_form.html

<form method="POST">
    {{ form.hidden_tag() }}
    {{ form.name.label }} {{ form.name() }}
    {% for error in form.name.errors %}
        <div class="error">{{ error }}</div>
    {% endfor %}
    <button type="submit">Submit</button>
</form>

This example captures the foundation of any functional form in Flask 3: clear definition in Python, automatic CSRF protection via form.hidden_tag(), server-side validation with validate_on_submit(), and error rendering in the template.

Conclusion

Using WTForms in Flask 3 not only simplifies form validation but also significantly improves code security and maintainability. Centralizing logic in Python classes eliminates duplication between client and server, and built-in CSRF support in Flask-WTF protects your forms without additional configuration.

If you are developing with Flask 3, integrating Flask-WTF is practically mandatory: it gives you control, clarity, and a solid foundation for any professional project.

❓ Frequently Asked Questions about WTForms and Flask-WTF

Are WTForms and Flask-WTF the same thing?
Not exactly. WTForms is the standalone base library, independent of any framework. Flask-WTF is its official integration with Flask, adding CSRF support, Jinja2 integration, and specific helpers for the Flask ecosystem.

What changes in Flask 3 compared to previous versions?
Flask 3 brings better compatibility with Python 3.12+, a more modular structure, and updated support for ecosystem extensions, including Flask-WTF. If you migrated from Flask 2.x, your form code will likely work without changes.

What is the difference between InputRequired and DataRequired?
InputRequired validates that the field was submitted in the request. DataRequired additionally validates that the value is "truthy" in Python, which discards empty strings and the value 0. For most cases, InputRequired is the most predictable choice.

How do I handle custom validations?
Define methods following the validate_<field_name> pattern inside your FlaskForm class and raise a ValidationError when the condition is not met. WTForms runs them automatically.

Why does the error "RuntimeError: A secret key is required to use CSRF" appear?
Because Flask-WTF enables CSRF protection by default and requires a SECRET_KEY defined in the application configuration to generate and validate tokens.

Next step: generate test data with Flask Seeder.

Learn how to use Flask-WTF and WTForms in Flask 3: fields, validators, CSRF protection, and real-world examples, step-by-step. A practical guide with code included.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

I agree to receive announcements of interest about this Blog.