The End-to-End Guide to Handling Forms in Flask

The subtle art of consenually capturing personal data

Happy Tuesday everybody! To start this week hot, let's talk about something that everybody hates: forms. The only thing more painful than filling out a web form is creating one, much less a functional one with feedback. Listen, if you're into creating pleasant form UI experiences, you're probably into some freaky shit. Call me.

Flask's youth is an advantage in one regard, in that there are only so many libraries available to handle any given task. In this case, there's only one: the aptly named WTForms.

If you don't have an immediate pressing need to create a Flask form and feel like ditching this post to check out Instagram, be my guest, but know this: handling form authentication, data submission, and session management is the pinnacle of app development. This weird data collection traditional we experience every day actually touches on nearly all aspects of app development. You could argue that he who creates forms is a harbinger of a golden age: a hero who brings us to the pinnacle of Western technology. Then again, there's always Instagram.

The Gist of it All

Before laying down some code snippets for you to mindlessly copy+paste, it helps to understand conceptually what we're about to throw down.

At a minimum, creating a form has us working routes, form models, and templates. Since you already understand the concept of MVC, that entire last sentence was probably redundant, and I should probably just delete it as opposed to continuing to write this second sentence contemplating my own redundancy. Oh well.

We'll keep our routes in app.py for a compact little app.

myproject
├─ app.py
├─ config.py
├─ forms.py
├─ db.py
├─ /static
│  └─ js
│  └─ less
│  └─ img
└─ /templates
   └─ layout.html
   └─ index.html
   └─ form.html

Here's the gameplan: our form, with all its fields, labels, and potential error messages, will live in forms.py. app.py will contain the logic of not only routing to the page serving the form, but also validating user input, which covers anything from error checking to session creation.

form.html will be the presentation layer template which will get loaded into index.html in this case. Both of those templates are wrapped by layout.html which is basically just metadata and shit you already know - we've been through this. Let's start off by creating our form.py.

What The Form

WTForms has a nice little monopoly over the Python form handling industry dating back to Django, so at lest we aren't burdened with choices here. Set up your environment and let's install everything we need:

pipenv shell
pip3 install Gunicorn Flask WTForms Flask-Login

That'll hold us over for now. In forms.py, we're going to do two imports:

from wtforms import Form, StringField, PasswordField, validators, SubmitField, SelectField

from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length

Forms really boil down into these two things: types of input, and validation of said input. There are plenty more fields and validators available, but this is what we'd need for a user signup form. Guess what we're making.

Classy as Form

Forms in your app will always be defined 1-to-1 with a Python class declaration. It's kind of like working with models, except they're forms. Just wait until your forms work with models.

from wtforms import Form, StringField, PasswordField, validators, SubmitField, SelectField
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length


class SignupForm(Form):
    """User Signup."""

    name = StringField('Name', [
        validators.DataRequired(message=('Don\'t be shy!'))
    ])
    email = StringField('Email', [
        Length(min=6, message=(u'Little short for an email address?')),
        Email(message=('That\'s not a valid email address.')),
        DataRequired(message=('That\'s not a valid email address.'))
    ])
    password = PasswordField('Password', validators=[
        DataRequired(message="Please enter a password."),
    ])
    confirm = PasswordField('Repeat Password', validators=[
            EqualTo(password, message='Passwords must match.')
            ])
    website = StringField('Website')
    submit = SubmitField('Register')

    def validate_email(self, email):
        """Email validation."""
        user = User.query.filter_by(email=email.data).first()
        if user is not None:
            raise ValidationError('Please use a different email address.')

As expected, every variable child of our class is a field. Field types are declared immediately (such as StringField()) and accept a label (name of the field) as well as validators, which are conditions which must be met for the field to be considered valid. A single field can accept an infinite number of validators, which would come in handy if you're a stickler for password security. As well as setting the validators, we also set the error message to pass to the user for that particular field if they are to submit invalid information. Fields accept other parameters as well, such as placeholder text, if you're interested in that side of things.

Some validators are more sophisticated enough to handle heavy-lifting: note how Email() is a validator in itself which handles the nonsense of looking for an '@' symbol or whatever it is PHP guys did in the 90s. There's even a regex validator in there.

Hardcore Validation XXX

You'll notice the example provided contains a validate_email function to check the database for a user record match. We can basically write any custom logic we want to validate forms this way, in case the event that the standard validators just don't cut it.

Forming the Actual Form

Alright, it's that time. Jinja's form handling isn't as daunting as this next wall of text might seem, once you get in the groove of things:

{% block content %}
<div class="formwrapper">
  <form method=post>
    <div class="name">
      {{ form.name(placeholder='Joe Blah') }} {{ form.name.label }}
      {% if form.name.errors %}
        <ul class="errors">{% for error in form.name.errors %}<li>{{ error }}</li>{% endfor %}</ul>
      {% endif %}
    </div>
    <div class="email">
      {{ form.email }} {{ form.email.label }}
      {% if form.email.errors %}
        <ul class="errors">{% for error in form.email.errors %}<li>{{ error }}</li>{% endfor %}</ul>
      {% endif %}
    </div>
    <div class="password">
      {{ form.password }} {{ form.password.label }}
      {% if form.password.errors %}
        <ul class="errors">{% for error in form.password.errors %}<li>{{ error }}</li>{% endfor %}</ul>
      {% endif %}
    </div>
    <div class="confirm">
      {{ form.confirm }} {{ form.confirm.label }}
      {% if form.confirm.errors %}
        <ul class="errors">{% for error in form.password.errors %}<li>{{ error }}</li>{% endfor %}</ul>
      {% endif %}
    </div>
    <div class="website">
      {{ form.website(placeholder='http://example.com') }} {{ form.website.label }}
    </div>
    <div class="submitbutton">
      <input id="submit" type="submit" value="Submit">
    </div>
  </form>
</div>

{% for message in get_flashed_messages() %}
<div class="alert alert-warning">
    <button type="button" class="close" data-dismiss="alert">&times;</button>
    {{ message }}
</div>
{% endfor %}

Notice that our form contains a method, but neither a destination nor an action. More on that late.

Each form field is pulling in three dynamic assets: the form itself, the display name, and a space reserved for error handling. This general layout is robust enough to handle returning multiple errors per field, which we obviously would prefer to keep inline with the offending fields for UI purposes.

Another way of handling errors is by utilizing Flask's flash module. A 'flash' is a temporary modal telling the user what they did wrong, which can be closed or simply expire after a number of seconds. We're using both forms of error handling here for educational purposes, but you probably won't need to most of the time.

Drop Some Logic on These Fools

app.py contains the route to the form, with allows for both GET and POST methods. Submitting a form in Flask cleverly routes the user to the page they're already on. Depending on what the logic decides, the user will experience either:

  • Instant inline errors, with no visible change of page.
  • A successful redirect to wherever they hoped the form would take them.
from flask import Flask, url_for, render_template, Markup, redirect, request, flash
from flask import session as login_session
from forms import SignupForm
import config
from models import User, users, login_manager
from db import users_col
import logging
import sys
import json


app = Flask(__name__, static_url_path='', static_folder="static", template_folder="templates")
compress = FlaskStaticCompress(app)
app.config.from_object('config.Config')


@app.route('/signup', methods=['GET', 'POST'])
def signup():
    """Signup Form."""
    signup_form = SignupForm()
    if request.method == 'POST':
        if signup_form.validate():
            flash('Logged in successfully.')
            return render_template('/dashboard.html', template="dashbord-template")
    return render_template('/signup.html', form=signup_form, template="form-page")

You'll recognize SignupForm() as our good old buddy from forms.py which has been imported here. Because the user submitted their form, they will experience the page with everything that lives within if request.method == 'POST': this time around. Determining validation is as simple as the next line: if signup_form.validate():. This one-liner will validate each field individually, and deliver the proper errors to the offending fields. This level of black magic saves us a huge headache and actually means that creating additional forms in the future won't be all that different from simply adjusting the class and template we already made. All that negative form talk I dropped earlier was a test. You passed.

What happens next?

As you might infer from the conditional statements, the user will either successfully complete the form and move on to the next page, or they might find themselves in a Ancient Greek version of hell where they find themselves incorrectly filling ou the same form forever. Sucks to suck.

If this were a real signup form, we'd handle user creation and database interaction here as well. As great as that sounds, I'll save your time as I know you still have an Instagram to check. Hats off to anybody who has made it through this rambling nonsense  - you deserve it.

Author image
New York City Website
Product manager turned engineer with an ongoing identity crisis. Breaks everything before learning best practices. Completely normal and emotionally stable.

Product manager turned engineer with an ongoing identity crisis. Breaks everything before learning best practices. Completely normal and emotionally stable.