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 original web interaction. A user types something, the browser POSTs it, the server reads it, validates it, stores it, 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 finally pleasant.

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 answer — and when to skip them for a JSON API.

Run locally with pip install flask flask-wtf and flask run. Set SECRET_KEY in your app config before sessions or CSRF will work — examples assume you have.


1. GET vs POST — Why It Matters

Same data, very different semantics:

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 — searching is read-only and bookmarkable. POST /users is correct — creating a user changes state. Mix them up (GET /delete?id=42) and you'll discover the hard way that link-prefetchers and web crawlers happily issue GET requests at every URL they see.


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 duplicated in every view — boring, error-prone, untestable.
  • Re-rendering with errors is finicky — you have to thread errors and form_data through the template.

Past three or four forms, this gets ugly fast. Time for a library.


3. Flask-WTF — Forms as Classes

bash
pip install flask-wtf

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() — binds 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 — 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 dutifully attaches your session cookie. Your server sees a valid logged-in session and a POST it has no way to distinguish from a legitimate one. Result: the attacker's site just transferred money / changed passwords / deleted 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; the server verifies it. Flask-WTF does this automatically — you just have to render the token:

html
<form method="post">
    {{ form.csrf_token }}              {# THIS — 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. Forget it and every submit fails validation.

For JSON APIs (no browser session), CSRF doesn't apply — you authenticate via 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 attrs as kwargs #}

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 blocking the submit. Remove it in production once you trust both layers.


6. Built-in Validators

The ones you'll use 90% of the time:

python
from wtforms.validators import (
    DataRequired,           # value is truthy (rejects "", 0, None)
    InputRequired,          # raw input is non-empty (use over DataRequired for booleans/0)
    Length,                 # min/max length on strings
    NumberRange,            # min/max on numeric fields
    Email,                  # rudimentary email format
    URL,                    # http/https URL
    Regexp,                 # regex match
    EqualTo,                # matches another field's value (password confirm)
    Optional,               # allows blank — skips other validators if blank
)

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() does a syntactic check, not deliverability. For real verification you send a confirmation email — see auth-passwords.


7. Custom Validators

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

Field-level — 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 (or returns silently). 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 one-shot message in the session:

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 flashes in the template — once per message, then they're gone:

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; pick conventions and stick to them across the app.

Flash requires sessions, which require SECRET_KEY. You already set that for CSRF, so it's covered.


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)              # strips path traversal, weird chars
        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 lock down:

  • MAX_CONTENT_LENGTH — Flask aborts the request with 413 Request Entity Too Large before reading the full body. Without it, a 10-GB upload exhausts your server's memory or disk.
  • secure_filename — strips ../, slashes, and unusual characters. Never trust f.filename directly.
  • Extension and content-type checks — FileAllowed looks at the filename. Determined attackers rename .exe to .png. For untrusted uploads in production, also sniff the bytes (python-magic) and store outside the web root.

10. Rendering Field Errors Inline

The pattern from Section 5 — {% for error in form.<field>.errors %} — works field-by-field. 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 richer UX, submit via 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. No extra wiring needed.


12. The Honest Note — 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; SPA renders the result.

CSRF protection in that world uses token-based auth (JWT in Authorization: Bearer) rather than cookies. Server-rendered forms are still the right answer for traditional multi-page apps, admin panels, and most internal tools — they're shorter, more accessible by default, and don't require 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() silently returns False and you spend an hour debugging 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). Stored XSS lives in this gap. See security-checklist.

3. Accepting uploads without limits

No MAX_CONTENT_LENGTH, no FileAllowed, no secure_filename — that's three different vulnerabilities in three lines: memory exhaustion, malware ingestion, path traversal. Set all three for any file upload.

4. DataRequired on boolean / numeric fields

DataRequired checks truthiness — it rejects 0 and False. For a numeric field where 0 is a valid value, use InputRequired instead (checks that something was submitted, not that it's 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 bare 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. Re-validating client-side only

Browser-side validation (required, type=email, pattern=...) is a UX nicety — anyone can disable it with DevTools or skip the browser entirely. Server-side validation is the only validation. Client-side is decoration.


🎯 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 — Flask-WTF does the rest).
  • Server-side validation; render inline errors next to each field.
  • Flash a success message on successful submit, then redirect to /contact (POST-redirect-GET).
  • A trivial save_message(name, email, message) function — append 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 fetch the page fresh as a GET, so a refresh doesn't resubmit. Don't return render_template(...) on the success path — that breaks the pattern and re-submits on refresh.
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 }} rendered, SECRET_KEY set.
  • Validation in one place — the form class. Every view that uses ContactForm gets identical rules.
  • POST-redirect-GET — refresh after submit doesn't re-send.
  • Flash messages with categories — success and error styled differently.
  • Inline field errors — user sees exactly which field is wrong without scrolling to a list.
  • Auto-escaping — even if a user submits <script> in their message, it renders harmlessly when you display the messages list back.

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 — actually email the team. Flask-Mail or a transactional provider (SendGrid, Postmark).
  • Honeypot or hCaptcha — bots fill every form they find.

What You Learned

  • GET reads, POST writes. Use the right verb — link prefetchers will hit every URL they see.
  • Flask-WTF wraps WTForms — define forms as classes, get validation, CSRF protection, and rendering helpers.
  • form.validate_on_submit() is True only on a valid POST. CSRF, validators, and method check are all rolled into it.
  • CSRF is automatic with Flask-WTF — render {{ form.csrf_token }} and set SECRET_KEY. Forget either and submits fail silently.
  • 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 success after submit without re-posting on refresh.
  • File uploads — FileField, enctype="multipart/form-data", MAX_CONTENT_LENGTH, secure_filename, FileAllowed. All four are mandatory.
  • Server-side validation is the only validation. Client-side is UX, not security.

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