Skip to main content

Flask Form Validation with Flask-WTF

Flask Form Validation with Flask-WTF

Flask-WTF, an extension for Flask, simplifies form validation by integrating the WTForms library, allowing developers to define, validate, and process web forms securely and efficiently. Form validation ensures user inputs meet specified criteria before processing, which is critical for data-driven applications like dashboards or machine learning (ML) interfaces using Pandas. This guide covers Flask form validation, including setup, validators, custom validation, error handling, integration with Jinja2 templates, best practices, and practical examples, with a focus on data-driven use cases.


01. Overview of Flask Form Validation

Form validation in Flask-WTF involves defining form fields with rules (validators) to ensure user inputs are valid and secure. It includes built-in validators, custom validation logic, and error handling to provide feedback to users. Flask-WTF also ensures security through CSRF protection.

  • Purpose: Validate user inputs to ensure data integrity and security.
  • Key Components: FlaskForm, validators, validate_on_submit(), and error handling.
  • Use Cases: Validating ML model inputs, filtering Pandas DataFrames, or processing user registrations.

1.1 Key Concepts

  • Validators: Rules applied to form fields (e.g., DataRequired, Email).
  • Form Classes: Defined with FlaskForm to specify fields and validation logic.
  • CSRF Protection: Automatically included to prevent cross-site request forgery.
  • Error Handling: Displays validation errors in templates for user feedback.

02. Setting Up Flask-WTF for Validation

2.1 Installation

Install Flask-WTF using pip:

pip install flask-wtf

2.2 Project Structure

project/
├── app.py
├── static/
│   ├── css/
│   │   └── style.css
├── templates/
│   ├── base.html
│   ├── form.html
│   └── result.html
└── uploads/

2.3 Basic Configuration

Flask-WTF requires a secret key for CSRF protection and session management.

Example: Basic Form Validation

File: app.py

from flask import Flask, render_template
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired, Length

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'  # Required for CSRF

class UserForm(FlaskForm):
    username = StringField('Username', validators=[DataRequired(), Length(min=3, max=20)])
    submit = SubmitField('Submit')

@app.route('/form', methods=['GET', 'POST'])
def form():
    form = UserForm()
    if form.validate_on_submit():
        username = form.username.data
        return render_template('result.html', username=username)
    return render_template('form.html', form=form)

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

File: templates/base.html

<!DOCTYPE html>
<html>
<head>
    <title>{% block title %}Flask-WTF Validation{% endblock %}</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
    <div class="container mt-3">
        {% block content %}{% endblock %}
    </div>
</body>
</html>

File: static/css/style.css

body {
    font-family: Arial, sans-serif;
}
.form-group {
    margin-bottom: 15px;
}
.error {
    color: red;
    font-size: 0.9em;
}

File: templates/form.html

{% extends 'base.html' %}

{% block title %}User Form{% endblock %}

{% block content %}
    <h1>Enter Username</h1>
    <form method="post" novalidate>
        {{ form.hidden_tag() }}
        <div class="form-group">
            {{ form.username.label }}
            {{ form.username(class="form-control") }}
            {% for error in form.username.errors %}
                <span class="error">{{ error | escape }}</span>
            {% endfor %}
        </div>
        {{ form.submit(class="btn btn-primary") }}
    </form>
{% endblock %}

File: templates/result.html

{% extends 'base.html' %}

{% block title %}Result{% endblock %}

{% block content %}
    <h1>Welcome, {{ username | title | escape }}!</h1>
    <a href="{{ url_for('form') }}" class="btn btn-secondary">Back</a>
{% endblock %}

Output (/form):

  • GET: Displays a Bootstrap-styled form.
  • POST (valid username): Redirects to a result page with the username.
  • POST (empty or invalid username): Shows errors like "This field is required" or "Field must be between 3 and 20 characters long."

Explanation:

  • SECRET_KEY: Enables CSRF protection.
  • DataRequired, Length: Validate the username field.
  • form.validate_on_submit(): Checks form validity and submission.
  • form.hidden_tag(): Includes the CSRF token.
  • form.username.errors: Displays validation errors.

03. Common Validators in Flask-WTF

WTForms provides a variety of built-in validators to enforce input constraints.

Validator Description Example
DataRequired Ensures the field is not empty. DataRequired(message='Required')
Length Restricts string length. Length(min=2, max=50)
NumberRange Restricts numeric range. NumberRange(min=0, max=100)
Email Validates email format. Email()
EqualTo Ensures two fields match. EqualTo('password')
Regexp Matches a regular expression. Regexp(r'^[A-Za-z]+$')
FileAllowed Restricts file extensions. FileAllowed(['csv'])

3.1 Example with Multiple Validators

Example: Form with Multiple Validators

File: app.py

from flask import Flask, render_template
from flask_wtf import FlaskForm
from wtforms import StringField, IntegerField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Length, NumberRange, Email, EqualTo

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'

class RegistrationForm(FlaskForm):
    email = StringField('Email', validators=[DataRequired(), Email()])
    age = IntegerField('Age', validators=[DataRequired(), NumberRange(min=18, max=100)])
    password = PasswordField('Password', validators=[DataRequired(), Length(min=8)])
    confirm_password = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')])
    submit = SubmitField('Register')

@app.route('/register', methods=['GET', 'POST'])
def register():
    form = RegistrationForm()
    if form.validate_on_submit():
        return render_template('result.html', email=form.email.data, age=form.age.data)
    return render_template('register.html', form=form)

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

File: templates/register.html

{% extends 'base.html' %}

{% block title %}Register{% endblock %}

{% block content %}
    <h1>Register</h1>
    <form method="post" novalidate>
        {{ form.hidden_tag() }}
        <div class="form-group">
            {{ form.email.label }}
            {{ form.email(class="form-control") }}
            {% for error in form.email.errors %}
                <span class="error">{{ error | escape }}</span>
            {% endfor %}
        </div>
        <div class="form-group">
            {{ form.age.label }}
            {{ form.age(class="form-control") }}
            {% for error in form.age.errors %}
                <span class="error">{{ error | escape }}</span>
            {% endfor %}
        </div>
        <div class="form-group">
            {{ form.password.label }}
            {{ form.password(class="form-control") }}
            {% for error in form.password.errors %}
                <span class="error">{{ error | escape }}</span>
            {% endfor %}
        </div>
        <div class="form-group">
            {{ form.confirm_password.label }}
            {{ form.confirm_password(class="form-control") }}
            {% for error in form.confirm_password.errors %}
                <span class="error">{{ error | escape }}</span>
            {% endfor %}
        </div>
        {{ form.submit(class="btn btn-primary") }}
    </form>
{% endblock %}

File: templates/result.html

{% extends 'base.html' %}

{% block title %}Registration Success{% endblock %}

{% block content %}
    <h1>Registration Successful</h1>
    <p>Email: {{ email | escape }}</p>
    <p>Age: {{ age | escape }}</p>
    <a href="{{ url_for('register') }}" class="btn btn-secondary">Back</a>
{% endblock %}

Output (/register):

  • GET: Displays a form with email, age, password, and confirm password fields.
  • POST (valid): Shows the submitted email and age.
  • POST (invalid): Displays errors like "Invalid email address" or "Passwords must match."

Explanation:

  • Email: Ensures valid email format.
  • NumberRange: Restricts age to 18–100.
  • EqualTo: Ensures passwords match.

04. Custom Validation

Custom validators can be defined to enforce specific rules not covered by built-in validators.

Example: Custom Validator for Username

File: app.py

from flask import Flask, render_template
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired, Length, ValidationError

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'

class CustomForm(FlaskForm):
    username = StringField('Username', validators=[DataRequired(), Length(min=3, max=20)])

    def validate_username(self, field):
        forbidden = ['admin', 'root']
        if field.data.lower() in forbidden:
            raise ValidationError('This username is not allowed.')

    submit = SubmitField('Submit')

@app.route('/custom', methods=['GET', 'POST'])
def custom():
    form = CustomForm()
    if form.validate_on_submit():
        return render_template('result.html', username=form.username.data)
    return render_template('custom.html', form=form)

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

File: templates/custom.html

{% extends 'base.html' %}

{% block title %}Custom Validation{% endblock %}

{% block content %}
    <h1>Custom Username Form</h1>
    <form method="post" novalidate>
        {{ form.hidden_tag() }}
        <div class="form-group">
            {{ form.username.label }}
            {{ form.username(class="form-control") }}
            {% for error in form.username.errors %}
                <span class="error">{{ error | escape }}</span>
            {% endfor %}
        </div>
        {{ form.submit(class="btn btn-primary") }}
    </form>
{% endblock %}

Output (/custom):

  • POST (username="admin"): Shows "This username is not allowed."
  • POST (username="john"): Redirects to the result page.

Explanation:

  • validate_username: Custom method to check forbidden usernames.
  • ValidationError: Raises an error displayed in the template.

05. Form Validation in Data-Driven Applications

Form validation is crucial for data-driven applications, ensuring inputs for ML models or data processing are valid.

Example: ML Prediction Form with Validation

File: app.py

from flask import Flask, render_template
from flask_wtf import FlaskForm
from wtforms import FloatField, SubmitField
from wtforms.validators import DataRequired, NumberRange
import pandas as pd

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'

@app.template_filter('probability')
def probability_filter(value):
    try:
        return f"{float(value) * 100:.1f}%"
    except (ValueError, TypeError):
        return value

class PredictionForm(FlaskForm):
    feature1 = FloatField('Feature 1', validators=[DataRequired(), NumberRange(min=0, max=1, message='Must be between 0 and 1')])
    feature2 = FloatField('Feature 2', validators=[DataRequired(), NumberRange(min=0, max=1, message='Must be between 0 and 1')])
    submit = SubmitField('Predict')

@app.route('/dashboard', methods=['GET', 'POST'])
def dashboard():
    form = PredictionForm()
    df = pd.DataFrame({
        'Name': ['Alice', 'Bob', 'Charlie'],
        'Age': [25, 30, 35],
        'Salary': [50000, 60000, 55000]
    })
    prediction = None
    if form.validate_on_submit():
        feature1 = form.feature1.data
        feature2 = form.feature2.data
        prediction = (feature1 + feature2) / 2  # Mock ML prediction
    return render_template('dashboard.html', form=form, data=df.to_dict(orient='records'), prediction=prediction)

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

File: templates/dashboard.html

{% extends 'base.html' %}

{% block title %}ML Dashboard{% endblock %}

{% block content %}
    <h1>ML Prediction Dashboard</h1>
    <table class="table table-striped">
        <thead>
            <tr>
                <th>Name</th>
                <th>Age</th>
                <th>Salary</th>
            </tr>
        </thead>
        <tbody>
            {% for row in data %}
                <tr>
                    <td>{{ row.Name | title }}</td>
                    <td>{{ row.Age }}</td>
                    <td>${{ row.Salary | float | round(2) }}</td>
                </tr>
            {% endfor %}
        </tbody>
    </table>
    <h2>Make a Prediction</h2>
    <form method="post" novalidate>
        {{ form.hidden_tag() }}
        <div class="form-group">
            {{ form.feature1.label }}
            {{ form.feature1(class="form-control") }}
            {% for error in form.feature1.errors %}
                <span class="error">{{ error | escape }}</span>
            {% endfor %}
        </div>
        <div class="form-group">
            {{ form.feature2.label }}
            {{ form.feature2(class="form-control") }}
            {% for error in form.feature2.errors %}
                <span class="error">{{ error | escape }}</span>
            {% endfor %}
        </div>
        {{ form.submit(class="btn btn-primary") }}
    </form>
    {% if prediction is not none %}
        <p>Prediction Probability: {{ prediction | probability }}</p>
    {% endif %}
{% endblock %}

Output (/dashboard):

  • GET: Displays a Pandas DataFrame table and a prediction form.
  • POST (feature1=0.8, feature2=0.6): Shows the table and Prediction Probability: 70.0%.
  • POST (feature1=2): Shows "Must be between 0 and 1."

Explanation:

  • NumberRange: Ensures ML input features are between 0 and 1.
  • Pandas: Displays a static dataset alongside the form.
  • | probability: Formats the prediction output.

06. Handling File Upload Validation

Flask-WTF supports file upload validation using FileField and validators like FileAllowed and FileRequired.

Example: Validating CSV Upload

File: app.py

from flask import Flask, render_template
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed, FileRequired
from wtforms import SubmitField
import pandas as pd
import os

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'
app.config['UPLOAD_FOLDER'] = 'uploads'

class UploadForm(FlaskForm):
    file = FileField('CSV File', validators=[FileRequired(), FileAllowed(['csv'], 'CSV files only')])
    submit = SubmitField('Upload')

@app.route('/upload', methods=['GET', 'POST'])
def upload():
    form = UploadForm()
    if form.validate_on_submit():
        file = form.file.data
        file_path = os.path.join(app.config['UPLOAD_FOLDER'], file.filename)
        file.save(file_path)
        df = pd.read_csv(file_path)
        return render_template('upload.html', form=form, data=df.to_dict(orient='records'))
    return render_template('upload.html', form=form)

if __name__ == '__main__':
    os.makedirs('uploads', exist_ok=True)
    app.run(debug=True)

File: templates/upload.html

{% extends 'base.html' %}

{% block title %}Upload CSV{% endblock %}

{% block content %}
    <h1>Upload CSV File</h1>
    <form method="post" enctype="multipart/form-data" novalidate>
        {{ form.hidden_tag() }}
        <div class="form-group">
            {{ form.file.label }}
            {{ form.file(class="form-control") }}
            {% for error in form.file.errors %}
                <span class="error">{{ error | escape }}</span>
            {% endfor %}
        </div>
        {{ form.submit(class="btn btn-primary") }}
    </form>
    {% if data %}
        <h2>Data Preview</h2>
        <table class="table table-striped mt-3">
            <thead>
                <tr>
                    {% for key in data[0].keys() %}
                        <th>{{ key | title }}</th>
                    {% endfor %}
                </tr>
            </thead>
            <tbody>
                {% for row in data %}
                    <tr>
                        {% for value in row.values() %}
                            <td>{{ value | escape }}</td>
                        {% endfor %}
                    </tr>
                {% endfor %}
            </tbody>
        </table>
    {% endif %}
{% endblock %}

Output (/upload):

  • GET: Displays a file upload form.
  • POST (valid CSV): Shows a table of the CSV data.
  • POST (non-CSV or no file): Shows errors like "CSV files only" or "This field is required."

Explanation:

  • FileRequired: Ensures a file is uploaded.
  • FileAllowed: Restricts to CSV files.
  • Pandas: Reads and displays the uploaded CSV.

07. Best Practices for Form Validation

7.1 Recommended Practices

  • Use Specific Validators: Apply validators like DataRequired, NumberRange, or Email for precise input control.
  • Display Errors: Show field-specific errors in templates for clear user feedback.
  • Secure Outputs: Use | escape for user inputs in templates to prevent XSS.
  • Include CSRF Protection: Always use form.hidden_tag() for security.
  • Use Custom Validators: Implement custom logic for application-specific rules.
  • Style Forms: Use Bootstrap or custom CSS for a polished user experience.

7.2 Security Considerations

  • CSRF Protection: Ensure SECRET_KEY is set and form.hidden_tag() is included.
  • Sanitize Inputs: Use libraries like bleach for additional cleaning if needed.
  • File Validation: Restrict file types and sizes, and scan uploads for security.

Example: Insecure Validation

File: app.py

from flask import Flask, render_template
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'

class InsecureForm(FlaskForm):
    comment = StringField('Comment')  # No validators
    submit = SubmitField('Submit')

@app.route('/insecure', methods=['GET', 'POST'])
def insecure():
    form = InsecureForm()
    if form.validate_on_submit():
        return render_template('result.html', comment=form.comment.data)
    return render_template('insecure.html', form=form)

File: templates/insecure.html

{% extends 'base.html' %}

{% block content %}
    <form method="post" novalidate>
        {{ form.hidden_tag() }}
        <div class="form-group">
            {{ form.comment.label }}
            {{ form.comment(class="form-control") }}
        </div>
        {{ form.submit(class="btn btn-primary") }}
    </form>
{% endblock %}

Issue: No validators allow empty or malicious inputs.

Correct:

from wtforms.validators import DataRequired, Length

class SecureForm(FlaskForm):
    comment = StringField('Comment', validators=[DataRequired(), Length(max=200)])
    submit = SubmitField('Submit')

Output: Enforces non-empty comments with a maximum length.

Explanation:

  • Insecure: Lack of validators risks invalid data.
  • Correct: Validators ensure data integrity.

7.3 Practices to Avoid

  • Avoid Skipping Validators: Always apply appropriate validators.
  • Avoid Manual CSRF: Use form.hidden_tag() instead of custom tokens.
  • Avoid Unstyled Errors: Style error messages for clarity.

08. Conclusion

Flask form validation with Flask-WTF ensures secure, reliable, and user-friendly input handling in web applications, particularly for data-driven tasks. Key takeaways:

  • Use FlaskForm with built-in and custom validators for robust validation.
  • Render forms with Jinja2 and Bootstrap, displaying errors for user feedback.
  • Support ML and data applications with validated inputs and file uploads.
  • Follow best practices like CSRF protection, secure outputs, and clear error handling.

By mastering Flask-WTF form validation, you can build secure, efficient, and polished forms that enhance user interaction and data integrity in Flask applications!

Comments