Introduction to Web Forms
Before we dive into Flask-WTF, let's understand the role of forms in web applications:
Forms are the primary way users interact with web applications, enabling activities like user registration, content creation, search, and more. A well-designed form system should:
- Provide a clear and intuitive user interface
- Validate user input both on the client and server side
- Protect against common security vulnerabilities
- Handle errors gracefully with informative feedback
- Process and store submitted data correctly
Handling forms without a framework is cumbersome and requires a lot of repetitive code:
@app.route('/register', methods=['GET', 'POST'])
def register_without_framework():
errors = {}
# Default values
username = ''
email = ''
if request.method == 'POST':
# Get form data
username = request.form.get('username', '')
email = request.form.get('email', '')
password = request.form.get('password', '')
confirm = request.form.get('confirm', '')
# Validate data
if not username:
errors['username'] = 'Username is required'
elif len(username) < 3:
errors['username'] = 'Username must be at least 3 characters'
if not email:
errors['email'] = 'Email is required'
elif '@' not in email:
errors['email'] = 'Invalid email format'
if not password:
errors['password'] = 'Password is required'
elif len(password) < 8:
errors['password'] = 'Password must be at least 8 characters'
if password != confirm:
errors['confirm'] = 'Passwords do not match'
# If no errors, process the registration
if not errors:
# Create user, save to database, etc.
flash('Registration successful!')
return redirect(url_for('login'))
# Render template with form
return render_template('register.html',
username=username,
email=email,
errors=errors)
This approach is tedious, error-prone, and hard to maintain. Flask-WTF provides a more elegant solution.
What is Flask-WTF?
Flask-WTF is an extension that simplifies form handling in Flask by integrating WTForms, a flexible form validation and rendering library for Python.
Key features of Flask-WTF include:
- Integration with WTForms: Leverages the power of WTForms within Flask
- CSRF protection: Built-in security against Cross-Site Request Forgery attacks
- File uploads: Easy handling of file uploads with secure validation
- reCAPTCHA support: Simple integration with Google's reCAPTCHA
- Internationalization: Support for translating form labels and error messages
The Restaurant Order Form Analogy
Think of Flask-WTF as a sophisticated restaurant order system. Instead of waiters having to manually check each order for validity (e.g., "Did the customer specify how they want their steak cooked?", "Is this menu item actually available?"), the system validates orders automatically, provides standardized forms that ensure all necessary information is collected, and protects against errors like double-ordering or ordering from the wrong menu.
Setting Up Flask-WTF
To get started with Flask-WTF, you need to install it and configure it in your Flask application:
Installation
pip install Flask-WTF
Basic Configuration
from flask import Flask
from flask_wtf.csrf import CSRFProtect
app = Flask(__name__)
# Secret key for form security
app.config['SECRET_KEY'] = 'your-secret-key-here' # Use a strong, random key in production!
# Optional: Enable CSRF protection globally
csrf = CSRFProtect(app)
# Or, if you prefer to enable CSRF only on specific views:
# csrf = CSRFProtect()
# csrf.init_app(app)
The SECRET_KEY is crucial for security—it's used to cryptographically sign cookies and generate CSRF tokens. In production, use a strong, random key and keep it confidential.
Real-World Security Tip
Never hard-code your secret key in your code, especially if it's stored in version control. Instead, use environment variables or a configuration file that's excluded from version control. This is a standard security practice to prevent exposure of sensitive keys.
# Using environment variables
import os
app.config['SECRET_KEY'] = os.environ.get('FLASK_SECRET_KEY')
# Or using a configuration file
import json
with open('/path/to/config.json', 'r') as f:
config = json.load(f)
app.config['SECRET_KEY'] = config['secret_key']
Creating Your First Form
With Flask-WTF, you define forms as Python classes that inherit from FlaskForm:
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, Length, EqualTo
class RegistrationForm(FlaskForm):
username = StringField('Username',
validators=[DataRequired(), Length(min=3, max=20)])
email = StringField('Email',
validators=[DataRequired(), Email()])
password = PasswordField('Password',
validators=[DataRequired(), Length(min=8)])
confirm_password = PasswordField('Confirm Password',
validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Sign Up')
This class defines a registration form with four fields, each with appropriate validators:
DataRequired(): Field cannot be emptyLength(min=3, max=20): Field must have 3-20 charactersEmail(): Field must contain a valid email addressEqualTo('password'): Field must match the 'password' field
Using Forms in Views
Here's how to use the form in a Flask view function:
from flask import Flask, render_template, flash, redirect, url_for
from forms import RegistrationForm
app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'
@app.route('/register', methods=['GET', 'POST'])
def register():
form = RegistrationForm()
if form.validate_on_submit():
# Form has been submitted and is valid
# Process the data
username = form.username.data
email = form.email.data
password = form.password.data
# Create user account, save to database, etc.
flash(f'Account created for {username}!', 'success')
return redirect(url_for('login'))
# Either the request is GET or the form validation failed
return render_template('register.html', form=form)
The validate_on_submit() method checks if:
- The request method is POST (i.e., the form was submitted)
- All validators pass (the form data is valid)
If both conditions are met, the form is considered valid, and you can process the data. Otherwise, the form is re-rendered with validation error messages.
Rendering Forms in Templates
Flask-WTF makes no assumptions about how you render forms in templates, giving you complete flexibility. Here are different approaches:
Manual Rendering
<form method="POST" action="{{ url_for('register') }}">
{{ form.hidden_tag() }}
<div class="form-group">
{{ form.username.label }}
{{ form.username(class="form-control") }}
{% if form.username.errors %}
<div class="text-danger">
{% for error in form.username.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="form-group">
{{ form.email.label }}
{{ form.email(class="form-control") }}
{% if form.email.errors %}
<div class="text-danger">
{% for error in form.email.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="form-group">
{{ form.password.label }}
{{ form.password(class="form-control") }}
{% if form.password.errors %}
<div class="text-danger">
{% for error in form.password.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="form-group">
{{ form.confirm_password.label }}
{{ form.confirm_password(class="form-control") }}
{% if form.confirm_password.errors %}
<div class="text-danger">
{% for error in form.confirm_password.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
The form.hidden_tag() function is crucial—it includes any hidden fields, particularly the CSRF token that protects against cross-site request forgery attacks.
Using Form Field Rendering with Macros
To avoid repetition, you can create Jinja2 macros for form fields:
{% macro render_field(field) %}
<div class="form-group">
{{ field.label }}
{{ field(class="form-control") }}
{% if field.errors %}
<div class="text-danger">
{% for error in field.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
{% endmacro %}
<form method="POST" action="{{ url_for('register') }}">
{{ form.hidden_tag() }}
{{ render_field(form.username) }}
{{ render_field(form.email) }}
{{ render_field(form.password) }}
{{ render_field(form.confirm_password) }}
{{ form.submit(class="btn btn-primary") }}
</form>
Bootstrap Integration Example
If you're using Bootstrap for your UI, you might want to integrate your forms with Bootstrap styles:
{% macro render_bootstrap_field(field) %}
<div class="mb-3">
{{ field.label(class="form-label") }}
{% if field.type == 'BooleanField' %}
<div class="form-check">
{{ field(class="form-check-input") }}
</div>
{% elif field.type == 'RadioField' %}
<div class="form-check">
{{ field(class="form-check-input") }}
</div>
{% elif field.type == 'FileField' %}
{{ field(class="form-control-file") }}
{% else %}
{{ field(class="form-control") }}
{% endif %}
{% if field.errors %}
<div class="invalid-feedback d-block">
{% for error in field.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
{% if field.description %}
<small class="form-text text-muted">{{ field.description }}</small>
{% endif %}
</div>
{% endmacro %}
Common Form Field Types
WTForms provides a wide variety of field types to handle different types of data:
| Field Type | HTML Equivalent | Common Use Cases |
|---|---|---|
StringField |
<input type="text"> |
Names, titles, short text |
TextAreaField |
<textarea> |
Long text, descriptions |
PasswordField |
<input type="password"> |
Passwords, sensitive data |
BooleanField |
<input type="checkbox"> |
Yes/no options, agreements |
DateField |
<input type="date"> |
Birth dates, appointment dates |
EmailField |
<input type="email"> |
Email addresses |
FileField |
<input type="file"> |
File uploads |
IntegerField |
<input type="number"> |
Numeric values without decimals |
FloatField |
<input type="number" step="any"> |
Decimal numbers |
SelectField |
<select> |
Dropdown lists, option selection |
RadioField |
<input type="radio"> |
Single-choice from multiple options |
MultipleFileField |
<input type="file" multiple> |
Multiple file uploads |
Example with Multiple Field Types
from flask_wtf import FlaskForm
from wtforms import (StringField, TextAreaField, BooleanField, SelectField,
DateField, IntegerField, DecimalField, SubmitField)
from wtforms.validators import DataRequired, NumberRange, Length
class ProductForm(FlaskForm):
name = StringField('Product Name', validators=[DataRequired(), Length(max=100)])
description = TextAreaField('Description', validators=[Length(max=500)])
price = DecimalField('Price ($)', places=2, validators=[DataRequired(), NumberRange(min=0)])
quantity = IntegerField('Quantity in Stock', validators=[DataRequired(), NumberRange(min=0)])
category = SelectField('Category', choices=[
('electronics', 'Electronics'),
('clothing', 'Clothing & Apparel'),
('home', 'Home & Kitchen'),
('books', 'Books & Media'),
('other', 'Other')
])
release_date = DateField('Release Date', format='%Y-%m-%d')
featured = BooleanField('Featured Product')
submit = SubmitField('Save Product')
Form Validation
WTForms provides a rich set of validators to ensure data integrity:
Common Validators
| Validator | Purpose | Example |
|---|---|---|
DataRequired |
Field cannot be empty | DataRequired(message='This field is required') |
Email |
Must be a valid email address | Email(message='Invalid email address') |
Length |
Enforces text length | Length(min=8, max=50) |
NumberRange |
Numeric value within range | NumberRange(min=18, max=100) |
URL |
Must be a valid URL | URL(message='Invalid URL') |
EqualTo |
Field must match another field | EqualTo('password', message='Passwords must match') |
Regexp |
Field must match pattern | Regexp(r'^[A-Za-z]+$', message='Letters only') |
Optional |
Field is optional but validated if provided | Optional() |
InputRequired |
Input is required (vs. data) | InputRequired(message='Input is required') |
Custom Validators
You can create custom validators for specific requirements:
from wtforms.validators import ValidationError
# As a function
def validate_username(form, field):
if field.data.lower() in ['admin', 'superuser', 'administrator']:
raise ValidationError('This username is reserved')
class RegistrationForm(FlaskForm):
username = StringField('Username', validators=[
DataRequired(),
Length(min=3, max=20),
validate_username # Apply the custom validator
])
# As a class
class ProhibitedWords:
def __init__(self, prohibited_words, message=None):
self.prohibited_words = prohibited_words
self.message = message or 'This field contains prohibited words'
def __call__(self, form, field):
for word in self.prohibited_words:
if word.lower() in field.data.lower():
raise ValidationError(self.message)
class CommentForm(FlaskForm):
content = TextAreaField('Comment', validators=[
DataRequired(),
ProhibitedWords(['spam', 'advertise', 'free trial'],
'No promotional content allowed')
])
Field-Level Validation
You can also add custom validation at the field level by overriding validate_<fieldname> methods:
class UserForm(FlaskForm):
username = StringField('Username', validators=[DataRequired()])
email = StringField('Email', validators=[DataRequired(), Email()])
def validate_username(self, username):
user = User.query.filter_by(username=username.data).first()
if user:
raise ValidationError('That username is already taken')
def validate_email(self, email):
user = User.query.filter_by(email=email.data).first()
if user:
raise ValidationError('That email is already registered')
File Uploads with Flask-WTF
Flask-WTF simplifies file uploads with form validation:
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileRequired, FileAllowed
from wtforms import SubmitField
class PhotoUploadForm(FlaskForm):
photo = FileField('Upload Photo', validators=[
FileRequired(),
FileAllowed(['jpg', 'png', 'gif'], 'Images only!')
])
submit = SubmitField('Upload')
To process the uploaded file in your view:
@app.route('/upload', methods=['GET', 'POST'])
def upload():
form = PhotoUploadForm()
if form.validate_on_submit():
filename = secure_filename(form.photo.data.filename)
form.photo.data.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
flash('Photo uploaded successfully!', 'success')
return redirect(url_for('index'))
return render_template('upload.html', form=form)
Real-World Security Considerations for File Uploads
File uploads present significant security risks. Here are some essential precautions:
- Always validate file types and extensions using
FileAllowed - Use
secure_filename()from Werkzeug to sanitize filenames - Store uploaded files outside the web root if possible
- Consider using cloud storage services for production
- Set maximum file size limits
- Scan uploads for malware if handling sensitive systems
Advanced Form Features
Dynamic Form Fields
Sometimes you need to create forms with a variable number of fields or dynamically generated choices:
class DynamicForm(FlaskForm):
# The field will be created dynamically
def __init__(self, *args, categories=None, **kwargs):
super(DynamicForm, self).__init__(*args, **kwargs)
# Create a dynamic category field based on database values
if categories is not None:
self.category = SelectField(
'Category',
choices=[(c.id, c.name) for c in categories],
coerce=int
)
# In your view
@app.route('/create-post', methods=['GET', 'POST'])
def create_post():
# Get categories from database
categories = Category.query.all()
# Create form with dynamic categories
form = DynamicForm(categories=categories)
if form.validate_on_submit():
# Process form
pass
return render_template('create_post.html', form=form)
Form Inheritance
You can use inheritance to create related forms that share common fields:
class BaseUserForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=20)])
email = StringField('Email', validators=[DataRequired(), Email()])
class RegistrationForm(BaseUserForm):
password = PasswordField('Password', validators=[DataRequired(), Length(min=8)])
confirm_password = PasswordField('Confirm Password',
validators=[DataRequired(), EqualTo('password')])
accept_tos = BooleanField('I accept the Terms of Service', validators=[DataRequired()])
submit = SubmitField('Register')
class ProfileUpdateForm(BaseUserForm):
bio = TextAreaField('Bio', validators=[Length(max=200)])
website = StringField('Website', validators=[Optional(), URL()])
submit = SubmitField('Update Profile')
Multi-Step Forms
For complex registration or workflows, you might want to split forms across multiple steps:
from flask import session
# Step 1: Personal Details
@app.route('/register/step1', methods=['GET', 'POST'])
def register_step1():
form = PersonalDetailsForm()
if form.validate_on_submit():
# Store form data in session
session['registration_data'] = {
'first_name': form.first_name.data,
'last_name': form.last_name.data,
'email': form.email.data
}
return redirect(url_for('register_step2'))
return render_template('register_step1.html', form=form)
# Step 2: Account Details
@app.route('/register/step2', methods=['GET', 'POST'])
def register_step2():
# Ensure step 1 was completed
if 'registration_data' not in session:
return redirect(url_for('register_step1'))
form = AccountDetailsForm()
if form.validate_on_submit():
# Get data from previous step
registration_data = session['registration_data']
# Add new data
registration_data.update({
'username': form.username.data,
'password': form.password.data
})
# Create user account with all the data
create_user(registration_data)
# Clear session data
session.pop('registration_data')
flash('Registration successful!', 'success')
return redirect(url_for('login'))
return render_template('register_step2.html', form=form)
Real-World Example: Contact Form with reCAPTCHA
Let's implement a more comprehensive example: a contact form with reCAPTCHA protection against spam.
Form Definition
from flask_wtf import FlaskForm, RecaptchaField
from wtforms import StringField, TextAreaField, SelectField, SubmitField
from wtforms.validators import DataRequired, Email, Length
class ContactForm(FlaskForm):
name = StringField('Your Name', validators=[
DataRequired(),
Length(min=2, max=50)
])
email = StringField('Your Email', validators=[
DataRequired(),
Email(message='Please enter a valid email address')
])
subject = SelectField('Subject', choices=[
('general', 'General Inquiry'),
('support', 'Technical Support'),
('billing', 'Billing Question'),
('feedback', 'Feedback'),
('other', 'Other')
])
message = TextAreaField('Message', validators=[
DataRequired(),
Length(min=10, max=1000, message='Your message must be between 10 and 1000 characters')
])
# Add reCAPTCHA field
recaptcha = RecaptchaField()
submit = SubmitField('Send Message')
Configuration Setup
# In app.py or config.py
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'development-key')
app.config['RECAPTCHA_PUBLIC_KEY'] = os.environ.get('RECAPTCHA_PUBLIC_KEY')
app.config['RECAPTCHA_PRIVATE_KEY'] = os.environ.get('RECAPTCHA_PRIVATE_KEY')
# Optional configurations
app.config['RECAPTCHA_OPTIONS'] = {'theme': 'light'}
View Function
@app.route('/contact', methods=['GET', 'POST'])
def contact():
form = ContactForm()
if form.validate_on_submit():
# Create a record of the contact submission
contact_submission = ContactSubmission(
name=form.name.data,
email=form.email.data,
subject=form.subject.data,
message=form.message.data,
ip_address=request.remote_addr,
submitted_at=datetime.utcnow()
)
db.session.add(contact_submission)
# Send email notification
send_contact_email(form.email.data, form.name.data,
form.subject.data, form.message.data)
# Commit database changes
db.session.commit()
flash('Your message has been sent! We will respond shortly.', 'success')
return redirect(url_for('contact_thank_you'))
return render_template('contact.html', form=form,
title='Contact Us')
Template Implementation
{% extends "base.html" %}
{% macro render_field(field) %}
<div class="form-group {% if field.errors %}has-error{% endif %}">
{{ field.label(class="form-label") }}
{% if field.type == 'RecaptchaField' %}
{{ field }}
{% else %}
{{ field(class="form-control") }}
{% endif %}
{% if field.errors %}
<div class="invalid-feedback d-block">
{% for error in field.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
{% if field.description %}
<small class="form-text text-muted">{{ field.description }}</small>
{% endif %}
</div>
{% endmacro %}
{% block content %}
<div class="container py-5">
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card shadow">
<div class="card-header bg-primary text-white">
<h3 class="mb-0">Contact Us</h3>
</div>
<div class="card-body">
<p class="lead">We'd love to hear from you! Fill out the form below to get in touch.</p>
<form method="POST" action="{{ url_for('contact') }}">
{{ form.hidden_tag() }}
<div class="row">
<div class="col-md-6">
{{ render_field(form.name) }}
</div>
<div class="col-md-6">
{{ render_field(form.email) }}
</div>
</div>
{{ render_field(form.subject) }}
{{ render_field(form.message) }}
{{ render_field(form.recaptcha) }}
<div class="text-center mt-4">
{{ form.submit(class="btn btn-primary btn-lg") }}
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
Practice Activity
Create a registration form for a fictional online learning platform with the following requirements:
- Basic user information:
- Username (3-20 characters, required)
- Email (valid format, required)
- Password (minimum 8 characters, required)
- Confirm Password (must match password)
- Profile information:
- Full Name (required)
- Age (must be at least 16)
- Education Level (dropdown with options like High School, Bachelor's, Master's, etc.)
- Areas of Interest (checkboxes for multiple selections)
- Additional features:
- Profile picture upload (only allow image files)
- Terms of Service agreement checkbox (required)
- Newsletter subscription option
- Custom validation:
- Check if username is already taken (you can simulate this)
- Password strength requirements (at least one uppercase, one lowercase, one digit)
Implement this form with Flask-WTF and create templates to render it with appropriate error handling and styling.
Further Topics to Explore
- Form AJAX submission with JavaScript
- Creating dynamic forms with JavaScript
- Integration with form UI libraries like Select2 or Chosen
- Form versioning and schema management
- Advanced form architectures for complex applications
- Internationalization of form labels and error messages