PythonMastery
intermediate 22 min read · lesson 2 of 12 in Web Frameworks

Flask Forms: WTForms, CSRF, and File Uploads

1 · The lesson

read

Forms are the oldest kind of web interaction. A user types something, the browser POSTs it, and the server reads it, validates it, stores it and redirects. In 1995 you parsed it from application/x-www-form-urlencoded. In 2026 you still do. The protocol hasn't changed, but the libraries are now much easier to use.

This lesson covers server-side forms in Flask: parsing raw input, validating with Flask-WTF, CSRF protection, flash messages, file uploads, and inline error rendering. By the end you'll know when server-rendered forms are the right choice, and when to use a JSON API instead.

Run locally with pip install flask flask-wtf email-validator and flask run. Sessions and CSRF only work after you set SECRET_KEY in your app config. The examples assume you have set it.


1. GET vs POST: Why It Matters

The same data can be sent either way, but the two methods mean very different things:

GETPOST
Where data goesQuery string in URLRequest body
CacheableYesNo
BookmarkableYesNo (refresh prompts re-submission)
IdempotentYes: same call twice = one effectNo: creates a new thing each time
Visible in logsYes: full URL in access logsBody usually isn't logged
Size limitPractical limit ~2 KBMany MB
Use forSearches, filters, navigationCreating, updating, anything with a side effect

GET /search?q=python is correct, because searching only reads data and the URL can be bookmarked. POST /users is correct, because creating a user changes state. If you mix them up (GET /delete?id=42), you will find out the hard way that link prefetchers and web crawlers send GET requests to every URL they find.


2. The Bare-Bones Form Handler

Without any form library, parsing a POST is two lines:

python
@app.route("/contact", methods=["GET", "POST"])
def contact():
    if request.method == "POST":
        name = request.form.get("name", "").strip()
        email = request.form.get("email", "").strip()
        message = request.form.get("message", "").strip()

        # Validation, by hand
        errors = {}
        if not name:
            errors["name"] = "Required"
        if "@" not in email:
            errors["email"] = "Looks invalid"
        if len(message) < 10:
            errors["message"] = "Too short"

        if errors:
            return render_template("contact.html", errors=errors,
                                   form_data={"name": name, "email": email, "message": message})

        save_message(name, email, message)
        return redirect(url_for("thanks"))

    return render_template("contact.html", errors={}, form_data={})
+ setup added so this can run · defines render_template, app, request, save_message, redirect, url_for
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def render_template(*_a, **_kw):
    print('-> render_template() called')
    return _AutoMock('render_template()')
app = _AutoMock('app')
request = _AutoMock('request')
def save_message(*_a, **_kw):
    print('-> save_message() called')
    return _AutoMock('save_message()')
def redirect(*_a, **_kw):
    print('-> redirect() called')
    return _AutoMock('redirect()')
def url_for(*_a, **_kw):
    print('-> url_for() called')
    return _AutoMock('url_for()')

This works. It also has three problems:

  • No CSRF protection: any other site can submit this form on your user's behalf (Section 4).
  • Validation is duplicated in every view, which is repetitive, error-prone and hard to test.
  • Re-rendering with errors is fiddly: you have to pass errors and form_data to the template every time.

After three or four forms, this becomes messy. This is where a library helps.


3. Flask-WTF: Forms as Classes

bash
pip install flask-wtf email-validator      # Email() needs email-validator

Flask-WTF is the Flask integration for WTForms. You define a form as a class:

python
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, TextAreaField, SubmitField
from wtforms.validators import DataRequired, Email, Length

class ContactForm(FlaskForm):
    name = StringField("Name", validators=[DataRequired(), Length(min=2, max=80)])
    email = StringField("Email", validators=[DataRequired(), Email()])
    message = TextAreaField("Message", validators=[DataRequired(), Length(min=10, max=5000)])
    submit = SubmitField("Send")

The view becomes:

python
from flask import Flask, render_template, redirect, url_for, flash

app = Flask(__name__)
app.config["SECRET_KEY"] = "change-me-from-env-in-production"

@app.route("/contact", methods=["GET", "POST"])
def contact():
    form = ContactForm()
    if form.validate_on_submit():               # True only on a valid POST
        save_message(form.name.data, form.email.data, form.message.data)
        flash("Thanks! We'll get back to you.", "success")
        return redirect(url_for("contact"))
    return render_template("contact.html", form=form)
+ setup added so this can run · defines ContactForm, save_message
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def ContactForm(*_a, **_kw):
    print('-> ContactForm() called')
    return _AutoMock('ContactForm()')
def save_message(*_a, **_kw):
    print('-> save_message() called')
    return _AutoMock('save_message()')

Three lines, three responsibilities:

  • form = ContactForm() reads the incoming request.form automatically. On a GET, the fields are empty. On a POST, they're populated.
  • form.validate_on_submit() returns True only when the request is a POST and all validators pass. CSRF is checked here too.
  • form.<field>.data is the typed Python value (str for text, int for IntegerField, etc.).

4. CSRF: What It Is and Why You Need It

Cross-Site Request Forgery: another site tricks your logged-in user's browser into submitting a form to your site. The browser automatically attaches your session cookie. Your server sees a valid logged-in session and a POST that it cannot tell apart from a real one. The result: the attacker's site has transferred money, changed a password or deleted an account on your user's behalf.

The fix is a per-session token that the attacker can't read (Same-Origin Policy blocks cross-origin reads). The form embeds it, and the server verifies it. Flask-WTF does this automatically. You only have to render the token:

html
<form method="post">
    {{ form.csrf_token }}              {# this line adds the hidden CSRF token field #}
    {{ form.name.label }} {{ form.name() }}
    {{ form.email.label }} {{ form.email() }}
    {{ form.message.label }} {{ form.message() }}
    {{ form.submit() }}
</form>

form.csrf_token renders as <input type="hidden" name="csrf_token" value="...">. On submit, WTForms checks it. If it's missing or wrong, validate_on_submit() returns False and the form's errors dict shows the CSRF failure.

Two requirements:

  • app.config["SECRET_KEY"] must be set. CSRF tokens are signed with it. Load it from an environment variable in production. See devops-secrets.
  • The form template must render form.csrf_token. If you forget it, every submit fails validation.

For JSON APIs (no browser session), CSRF doesn't apply, because you authenticate with tokens, not cookies. See auth-jwt.


5. Rendering the Form

WTForms field objects are callables that emit HTML:

html
{{ form.name() }}                                       {# <input type="text" name="name"> #}
{{ form.name(class_="form-input", placeholder="Your name") }}
{{ form.name.label }}                                   {# <label for="name">Name</label> #}
{{ form.message(rows=8, cols=40) }}                     {# extra HTML attributes as keyword arguments #}

Use the class_= keyword (with the underscore) because class is a Python reserved word. The underscore is stripped on output.

The full form template:

html
{% extends "base.html" %}
{% block content %}
    <h1>Contact us</h1>

    {# Flash messages, if any #}
    {% with messages = get_flashed_messages(with_categories=true) %}
        {% for category, msg in messages %}
            <div class="flash flash-{{ category }}">{{ msg }}</div>
        {% endfor %}
    {% endwith %}

    <form method="post" novalidate>
        {{ form.csrf_token }}

        <div>
            {{ form.name.label }}
            {{ form.name(class_="form-input") }}
            {% for error in form.name.errors %}
                <span class="error">{{ error }}</span>
            {% endfor %}
        </div>

        <div>
            {{ form.email.label }}
            {{ form.email(class_="form-input") }}
            {% for error in form.email.errors %}
                <span class="error">{{ error }}</span>
            {% endfor %}
        </div>

        <div>
            {{ form.message.label }}
            {{ form.message(rows=6, class_="form-input") }}
            {% for error in form.message.errors %}
                <span class="error">{{ error }}</span>
            {% endfor %}
        </div>

        {{ form.submit(class_="btn") }}
    </form>
{% endblock %}

The novalidate attribute on <form> turns off browser-side HTML5 validation so you can test your server-side validators without the browser stopping the submit. Remove it in production once you trust both layers.


6. Built-in Validators

The ones you'll use most often:

python
from wtforms.validators import (
    DataRequired,           # value is truthy (rejects "", 0, None)
    InputRequired,          # raw input is not empty (use it instead of DataRequired for booleans and 0)
    Length,                 # min/max length on strings
    NumberRange,            # min/max on numeric fields
    Email,                  # basic email format check
    URL,                    # http/https URL
    Regexp,                 # regex match
    EqualTo,                # matches another field's value (password confirm)
    Optional,               # allows a blank value, and then skips the other validators
)

class RegisterForm(FlaskForm):
    username = StringField("Username", validators=[
        DataRequired(),
        Length(min=3, max=30),
        Regexp(r"^[a-zA-Z0-9_]+$", message="Letters, digits, and underscore only"),
    ])
    password = PasswordField("Password", validators=[
        DataRequired(),
        Length(min=8),
    ])
    confirm = PasswordField("Confirm password", validators=[
        DataRequired(),
        EqualTo("password", message="Passwords must match"),
    ])
+ setup added so this can run · defines FlaskForm, StringField, PasswordField
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

FlaskForm = _AutoMock('FlaskForm')
def StringField(*_a, **_kw):
    print('-> StringField() called')
    return _AutoMock('StringField()')
def PasswordField(*_a, **_kw):
    print('-> PasswordField() called')
    return _AutoMock('PasswordField()')

Email() only checks the format of the address, not whether email can be delivered to it. To really verify an address, send a confirmation email. See auth-passwords.


7. Custom Validators

When the built-ins aren't enough, write a function. Two patterns:

Field-level, by naming convention: validate_<fieldname>

python
class RegisterForm(FlaskForm):
    username = StringField("Username", validators=[DataRequired()])

    def validate_username(self, field):
        # Runs after the standard validators on `username`
        if User.query.filter_by(username=field.data).first():
            raise ValidationError("Username already taken")
+ setup added so this can run · defines FlaskForm, StringField, ValidationError, DataRequired, User
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

FlaskForm = _AutoMock('FlaskForm')
def StringField(*_a, **_kw):
    print('-> StringField() called')
    return _AutoMock('StringField()')
def ValidationError(*_a, **_kw):
    print('-> ValidationError() called')
    return _AutoMock('ValidationError()')
def DataRequired(*_a, **_kw):
    print('-> DataRequired() called')
    return _AutoMock('DataRequired()')
User = _AutoMock('User')

Reusable validator function

python
from wtforms.validators import ValidationError

def must_be_unique(model, column):
    def _check(form, field):
        if model.query.filter(getattr(model, column) == field.data).first():
            raise ValidationError(f"{column} already in use")
    return _check

class RegisterForm(FlaskForm):
    username = StringField("Username", validators=[
        DataRequired(),
        must_be_unique(User, "username"),
    ])
    email = StringField("Email", validators=[
        DataRequired(),
        Email(),
        must_be_unique(User, "email"),
    ])
+ setup added so this can run · defines FlaskForm, StringField, DataRequired, User, Email
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

FlaskForm = _AutoMock('FlaskForm')
def StringField(*_a, **_kw):
    print('-> StringField() called')
    return _AutoMock('StringField()')
def DataRequired(*_a, **_kw):
    print('-> DataRequired() called')
    return _AutoMock('DataRequired()')
User = _AutoMock('User')
def Email(*_a, **_kw):
    print('-> Email() called')
    return _AutoMock('Email()')

A validator raises ValidationError when the value is invalid, and returns normally when it is valid. The error message ends up in field.errors.


8. Flash Messages

After a successful POST, you redirect (POST-redirect-GET). The next page needs to say "saved!" without keeping the data in the URL. Flash stores a message in the session that is shown only once:

python
from flask import flash

@app.route("/contact", methods=["GET", "POST"])
def contact():
    form = ContactForm()
    if form.validate_on_submit():
        save_message(...)
        flash("Thanks! We'll get back to you.", "success")
        return redirect(url_for("contact"))
    if form.errors:
        flash("Please fix the errors below.", "error")
    return render_template("contact.html", form=form)
+ setup added so this can run · defines ContactForm, render_template, app, save_message, redirect, url_for
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def ContactForm(*_a, **_kw):
    print('-> ContactForm() called')
    return _AutoMock('ContactForm()')
def render_template(*_a, **_kw):
    print('-> render_template() called')
    return _AutoMock('render_template()')
app = _AutoMock('app')
def save_message(*_a, **_kw):
    print('-> save_message() called')
    return _AutoMock('save_message()')
def redirect(*_a, **_kw):
    print('-> redirect() called')
    return _AutoMock('redirect()')
def url_for(*_a, **_kw):
    print('-> url_for() called')
    return _AutoMock('url_for()')

Read flashed messages in the template. Each message is read once, and then it is removed:

html
{% with messages = get_flashed_messages(with_categories=true) %}
    {% for category, msg in messages %}
        <div class="flash flash-{{ category }}">{{ msg }}</div>
    {% endfor %}
{% endwith %}

Categories let you style different message types: success, error, info, warning. They're plain strings, so choose a set of names and use the same ones across the app.

Flash requires sessions, which require SECRET_KEY. You already set it for CSRF, so nothing more is needed.


9. File Uploads

File inputs need three things: a multipart form, a FileField, and a place to put the file on disk.

python
import os
from flask_wtf.file import FileField, FileRequired, FileAllowed
from werkzeug.utils import secure_filename

class UploadForm(FlaskForm):
    photo = FileField("Photo", validators=[
        FileRequired(),
        FileAllowed(["jpg", "jpeg", "png", "webp"], "Images only"),
    ])
    submit = SubmitField("Upload")

UPLOAD_DIR = "uploads"
app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024   # 5 MB cap on entire request

@app.route("/upload", methods=["GET", "POST"])
def upload():
    form = UploadForm()
    if form.validate_on_submit():
        f = form.photo.data
        safe = secure_filename(f.filename)              # removes path parts such as ../ and unsafe characters
        if not safe:
            flash("Invalid filename", "error")
            return redirect(url_for("upload"))
        os.makedirs(UPLOAD_DIR, exist_ok=True)
        f.save(os.path.join(UPLOAD_DIR, safe))
        flash("Uploaded.", "success")
        return redirect(url_for("upload"))
    return render_template("upload.html", form=form)
+ setup added so this can run · defines FlaskForm, SubmitField, app, render_template, flash, redirect, url_for
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

FlaskForm = _AutoMock('FlaskForm')
def SubmitField(*_a, **_kw):
    print('-> SubmitField() called')
    return _AutoMock('SubmitField()')
app = _AutoMock('app')
def render_template(*_a, **_kw):
    print('-> render_template() called')
    return _AutoMock('render_template()')
def flash(*_a, **_kw):
    print('-> flash() called')
    return _AutoMock('flash()')
def redirect(*_a, **_kw):
    print('-> redirect() called')
    return _AutoMock('redirect()')
def url_for(*_a, **_kw):
    print('-> url_for() called')
    return _AutoMock('url_for()')

In the template, the form tag must have enctype="multipart/form-data". Otherwise the file isn't sent:

html
<form method="post" enctype="multipart/form-data">
    {{ form.csrf_token }}
    {{ form.photo() }}
    {{ form.submit() }}
</form>

Three things to secure:

  • MAX_CONTENT_LENGTH: Flask stops the request with 413 Request Entity Too Large before reading the full body. Without it, a 10 GB upload can use up your server's memory or disk.
  • secure_filename: removes ../, slashes, and unusual characters. Never trust f.filename directly.
  • Extension and content-type checks: FileAllowed only looks at the filename, and an attacker can just rename .exe to .png. For untrusted uploads in production, also check the file's bytes (python-magic) and store the files outside the web root.

10. Rendering Field Errors Inline

The pattern from Section 5 ({% for error in form.<field>.errors %}) works one field at a time. For a "show all errors at the top" summary:

html
{% if form.errors %}
    <div class="flash flash-error">
        <ul>
        {% for field_name, errs in form.errors.items() %}
            {% for err in errs %}
                <li>{{ form[field_name].label.text }}: {{ err }}</li>
            {% endfor %}
        {% endfor %}
        </ul>
    </div>
{% endif %}

form.errors is a dict of {field_name: [error_strings]}. Empty until you call form.validate_on_submit() (or form.validate()).


11. AJAX Form Submission

For a smoother user experience, submit with fetch and respond with JSON instead of a redirect:

python
@app.post("/contact/ajax")
def contact_ajax():
    form = ContactForm()
    if form.validate_on_submit():
        save_message(form.name.data, form.email.data, form.message.data)
        return {"ok": True}
    return {"ok": False, "errors": form.errors}, 400
+ setup added so this can run · defines ContactForm, app, save_message
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def ContactForm(*_a, **_kw):
    print('-> ContactForm() called')
    return _AutoMock('ContactForm()')
app = _AutoMock('app')
def save_message(*_a, **_kw):
    print('-> save_message() called')
    return _AutoMock('save_message()')

Client-side:

javascript
const form = document.querySelector("#contact-form");
form.addEventListener("submit", async (e) => {
    e.preventDefault();
    const data = new FormData(form);
    const res = await fetch(form.action, { method: "POST", body: data });
    const body = await res.json();
    if (body.ok) {
        showSuccess("Thanks!");
    } else {
        showErrors(body.errors);     // {field_name: ["msg", ...]}
    }
});

FormData(form) includes the CSRF token field automatically because it's part of the form. You don't need to add anything.


12. A Note on SPA Frontends

If your frontend is React, Vue, Svelte, or any other SPA framework, you usually skip server-rendered forms entirely. The flow becomes:

1. SPA collects input client-side.
2. Sends JSON to a REST or GraphQL endpoint.
3. Server validates with Pydantic (in FastAPI), Marshmallow, or DRF serializers (Django).
4. Returns JSON, and the SPA renders the result.

In that setup, CSRF is usually avoided by using token-based auth (a JWT in Authorization: Bearer) instead of cookies. Server-rendered forms are still the right choice for traditional multi-page apps, admin panels, and most internal tools. They need less code, are more accessible by default, and don't need a JavaScript build pipeline.

See fastapi-basics for the JSON-API counterpart, and django-rest for Django's serializer-driven approach.


13. Common Mistakes

1. Missing CSRF token in the template

form.validate_on_submit() returns False with no visible error, and you spend an hour finding out why "the form never saves". Always render {{ form.csrf_token }} inside the <form>. The error will be in form.errors["csrf_token"] if you ever need to debug it.

2. Not escaping user input

Jinja auto-escapes {{ user_input }} by default. {{ user_input|safe }} and {% autoescape false %} turn it off. Use them only for HTML you produced yourself (e.g. markdown you sanitised through bleach). This is where stored XSS vulnerabilities come from. See security-checklist.

3. Accepting uploads without limits

No MAX_CONTENT_LENGTH, no FileAllowed, no secure_filename: that's three different vulnerabilities (memory exhaustion, accepting malware, and path traversal). Set all three for any file upload.

4. DataRequired on boolean / numeric fields

DataRequired checks truthiness, so it rejects 0 and False. For a numeric field where 0 is a valid value, use InputRequired instead. It checks that something was submitted, not that the value is truthy.

5. Using GET for state-changing actions

GET /delete?id=42 runs every time a link prefetcher, crawler, or browser pre-fetch hits the URL. Use POST for anything that changes state. Add a confirmation step for destructive actions: a CSRF-protected form, not a plain link.

6. Storing uploaded files in the static directory

Files in static/ are served to anyone who knows the URL. Uploaded user content should go in a separate directory served by an explicit view that checks authorisation, or behind nginx with an internal location and X-Accel-Redirect.

7. Validating on the client side only

Browser-side validation (required, type=email, pattern=...) only makes the form easier to use. Anyone can disable it with DevTools, or send a request without a browser. Only server-side validation protects your app.


🎯 Your Turn: A Contact Form with WTForms

Build a contact form at /contact with:

  • Fields: name (3-80 chars), email (valid format), message (10-2000 chars).
  • CSRF protection (just render the token, and Flask-WTF does the rest).
  • Server-side validation, with inline errors rendered next to each field.
  • Flash a success message on successful submit, then redirect to /contact (POST-redirect-GET).
  • A simple save_message(name, email, message) function. Appending to a list is fine.

Skeleton:

python
# app.py
from flask import Flask, render_template, redirect, url_for, flash
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SubmitField
from wtforms.validators import DataRequired, Email, Length

app = Flask(__name__)
app.config["SECRET_KEY"] = "dev-only-change-me"

messages = []   # in-memory store

class ContactForm(FlaskForm):
    # TODO 1: define name, email, message, submit with validators
    pass

def save_message(name, email, message):
    messages.append({"name": name, "email": email, "message": message})

@app.route("/contact", methods=["GET", "POST"])
def contact():
    form = ContactForm()
    # TODO 2: if form.validate_on_submit():
    #             save it, flash a success message, redirect to /contact
    # TODO 3: otherwise render contact.html with form
    pass

Template skeleton (templates/contact.html):

html
{% extends "base.html" %}
{% block content %}
    {# TODO 4: render flash messages with categories #}
    <form method="post" novalidate>
        {# TODO 5: csrf token, each field with label, input, inline errors #}
    </form>
{% endblock %}
Hint 1: Defining the form class Inherit from FlaskForm. Each field is a class attribute: name = StringField("Name", validators=[DataRequired(), Length(min=3, max=80)]). email uses Email(), message uses TextAreaField with Length(min=10, max=2000). Finish with submit = SubmitField("Send").
Hint 2: POST-redirect-GET After save_message(...) and flash(...), return redirect(url_for("contact")). The redirect makes the browser load the page again as a GET, so a refresh doesn't submit the form again. Don't return render_template(...) on the success path. That breaks the pattern, and a refresh would submit the form again.
Show full solution
python
# app.py
from flask import Flask, render_template, redirect, url_for, flash
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SubmitField
from wtforms.validators import DataRequired, Email, Length

app = Flask(__name__)
app.config["SECRET_KEY"] = "dev-only-change-me"

messages = []


class ContactForm(FlaskForm):
    name = StringField("Name", validators=[
        DataRequired(),
        Length(min=3, max=80),
    ])
    email = StringField("Email", validators=[
        DataRequired(),
        Email(message="Must be a valid email address"),
    ])
    message = TextAreaField("Message", validators=[
        DataRequired(),
        Length(min=10, max=2000),
    ])
    submit = SubmitField("Send")


def save_message(name, email, message):
    messages.append({"name": name, "email": email, "message": message})


@app.route("/contact", methods=["GET", "POST"])
def contact():
    form = ContactForm()
    if form.validate_on_submit():
        save_message(form.name.data, form.email.data, form.message.data)
        flash("Thanks! We'll get back to you soon.", "success")
        return redirect(url_for("contact"))
    if form.errors:
        flash("Please fix the errors below.", "error")
    return render_template("contact.html", form=form)


if __name__ == "__main__":
    app.run(debug=True)

templates/base.html:

html
<!doctype html>
<html>
<head>
    <title>Contact</title>
    <style>
        .flash { padding: .5em; margin: .5em 0; }
        .flash-success { background: #e8f5e9; }
        .flash-error   { background: #ffebee; }
        .error         { color: #c62828; font-size: 90%; }
        label          { display: block; margin-top: 1em; }
        input, textarea { width: 100%; max-width: 400px; }
    </style>
</head>
<body>{% block content %}{% endblock %}</body>
</html>

templates/contact.html:

html
{% extends "base.html" %}
{% block content %}
    <h1>Contact us</h1>

    {% with msgs = get_flashed_messages(with_categories=true) %}
        {% for cat, msg in msgs %}
            <div class="flash flash-{{ cat }}">{{ msg }}</div>
        {% endfor %}
    {% endwith %}

    <form method="post" novalidate>
        {{ form.csrf_token }}

        <div>
            {{ form.name.label }}
            {{ form.name() }}
            {% for err in form.name.errors %}<span class="error">{{ err }}</span>{% endfor %}
        </div>

        <div>
            {{ form.email.label }}
            {{ form.email() }}
            {% for err in form.email.errors %}<span class="error">{{ err }}</span>{% endfor %}
        </div>

        <div>
            {{ form.message.label }}
            {{ form.message(rows=6) }}
            {% for err in form.message.errors %}<span class="error">{{ err }}</span>{% endfor %}
        </div>

        {{ form.submit() }}
    </form>
{% endblock %}

What this gets right:

  • CSRF: {{ form.csrf_token }} is rendered and SECRET_KEY is set.
  • Validation in one place: the form class. Every view that uses ContactForm gets identical rules.
  • POST-redirect-GET: refreshing after a submit doesn't send the form again.
  • Flash messages with categories: success and error messages are styled differently.
  • Inline field errors: the user sees exactly which field is wrong, without scrolling to a list.
  • Auto-escaping: even if a user submits <script> in their message, it is displayed as harmless text when you show the list of messages.

What's missing for production:

  • Real persistence: messages is in memory. Replace with the DB layer from flask-database.
  • Rate limiting: without it, anyone can spam the form. Flask-Limiter handles this.
  • Email delivery: send the message to the team by email. Flask-Mail or a transactional provider (SendGrid, Postmark).
  • Honeypot or hCaptcha: bots automatically fill in forms they find.

What You Learned

  • GET reads, POST writes. Use the right method, because link prefetchers request every URL they find.
  • Flask-WTF wraps WTForms. You define forms as classes and get validation, CSRF protection, and rendering helpers.
  • form.validate_on_submit() is True only on a valid POST. It checks the method, the CSRF token and the validators.
  • CSRF is automatic with Flask-WTF: render {{ form.csrf_token }} and set SECRET_KEY. If you forget either one, submits fail.
  • Field rendering: call the field (form.name()), pass HTML attrs as kwargs, use class_= for class.
  • Validators: DataRequired, Length, Email, Regexp, EqualTo, Optional. Add custom ones via validate_<field> methods or reusable functions raising ValidationError.
  • Flash messages + POST-redirect-GET: show a success message after a submit, without posting again on refresh.
  • File uploads: FileField, enctype="multipart/form-data", MAX_CONTENT_LENGTH, secure_filename, FileAllowed. You need all of them.
  • Server-side validation is the only validation. Client-side validation helps the user, but it does not protect your app.

Next: flask-database: replace the in-memory list with SQLAlchemy models, real persistence, and migrations.

Found this useful? Share it with a friend who’s learning Python.