import re
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
class SignupValidator:
def __init__(self, form, email_exists=None):
self.raw = form
self.email_exists = email_exists or (lambda e: False)
self.errors = {}
self.cleaned = {}
def add(self, field, message):
if field not in self.errors:
self.errors[field] = message
def validate(self):
self._check_email()
self._check_password()
self._check_confirm()
return self
def _check_email(self):
email = (self.raw.get("email") or "").strip().lower()
self.cleaned["email"] = email
if not email:
self.add("email", "Email is required.")
elif not EMAIL_RE.match(email):
self.add("email", "Enter a valid email address.")
elif self.email_exists(email):
self.add("email", "That email is already registered.")
def _check_password(self):
pw = self.raw.get("password") or ""
if len(pw) < 8:
self.add("password", "Password must be at least 8 characters.")
elif not any(c.isdigit() for c in pw):
self.add("password", "Password must contain a number.")
def _check_confirm(self):
pw = self.raw.get("password") or ""
confirm = self.raw.get("confirm") or ""
if pw and confirm != pw:
self.add("confirm", "Passwords do not match.")
def is_valid(self):
return not self.errors
from flask import Blueprint, render_template, request, redirect, url_for, flash
from werkzeug.security import generate_password_hash
from .models import db, User
from .validators import SignupValidator
bp = Blueprint("auth", __name__)
@bp.route("/signup", methods=["GET", "POST"])
def signup():
if request.method == "GET":
return render_template("signup.html", errors={}, form={})
form = request.form
validator = SignupValidator(
form,
email_exists=lambda e: db.session.query(
User.query.filter_by(email=e).exists()
).scalar(),
).validate()
if not validator.is_valid():
return (
render_template("signup.html", errors=validator.errors, form=form),
400,
)
user = User(
email=validator.cleaned["email"],
password_hash=generate_password_hash(form["password"]),
)
db.session.add(user)
db.session.commit()
flash("Account created. Please log in.")
return redirect(url_for("auth.login"))
from datetime import datetime
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
class User(db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(255), nullable=False, unique=True, index=True)
password_hash = db.Column(db.String(255), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
def __repr__(self):
return "<User {}>".format(self.email)
<form method="post" action="{{ url_for('auth.signup') }}" novalidate>
<div class="field">
<label for="email">Email</label>
<input id="email" name="email" type="email"
value="{{ form.get('email', '') }}">
{% if errors.email %}<p class="error">{{ errors.email }}</p>{% endif %}
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" name="password" type="password">
{% if errors.password %}<p class="error">{{ errors.password }}</p>{% endif %}
</div>
<div class="field">
<label for="confirm">Confirm password</label>
<input id="confirm" name="confirm" type="password">
{% if errors.confirm %}<p class="error">{{ errors.confirm }}</p>{% endif %}
</div>
<button type="submit">Create account</button>
</form>
This snippet shows a lightweight, dependency-free way to validate a signup form on the server and return a dictionary of per-field errors that a template can render inline next to each input. It avoids pulling in a full form library and instead builds a small, explicit validator that any Flask project can drop in.
In validators.py, the core abstraction is SignupValidator, which takes the raw form mapping and accumulates errors into self.errors, a dict keyed by field name. Each _check_* method appends a message only when a rule fails, and helper add guards against overwriting an existing message so the first, most relevant error per field wins. Email is checked with a deliberately conservative regex; the goal is to reject obvious garbage, not to fully implement RFC 5322, because strict email parsing belongs to actual delivery. The password rule enforces length and a digit, and _check_confirm cross-validates two fields, illustrating why collecting all errors at once beats raising on the first failure — the user sees every problem in a single round trip.
The is_valid method returns a boolean derived from whether errors is empty, and cleaned exposes normalized values (trimmed, lowercased email) so the view never re-derives them. This separation keeps the validator pure and testable: it touches no request context and no database, apart from an injected email_exists callback for the uniqueness check.
In auth.py, the signup view wires this together. On GET it renders the empty form; on POST it constructs the validator, passing a small lambda that queries User for uniqueness. When is_valid fails, the view re-renders signup.html with errors and the original form data so fields repopulate, and it sets a 400 status so the response is not cached as a success. Only on success does it create the User, commit, and redirect following the Post/Redirect/Get pattern to prevent duplicate submissions on refresh.
The trade-off is manual wiring versus the automatic binding of WTForms, but for a handful of fields the explicitness is easier to read and to unit test. A common pitfall this design handles is preserving user input on failure; another is the uniqueness race, which the unique index in models.py ultimately enforces even if two requests pass validation concurrently.
Related snips
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
import { Application } from "@hotwired/stimulus"
import FormSubmitController from "./controllers/form_submit_controller"
const application = Application.start()
application.debug = false
Disable submit button while Turbo form is submitting
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
url: String,
delay: { type: Number, default: 800 },
Stimulus: autosave draft with Turbo-friendly requests
import axios from 'axios';
export type NormalizedErrors = {
fields: Record<string, string>;
formLevel: string | null;
};
Frontend: normalize and display server validation errors
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
Laravel form requests for validation
Share this code
Here's the card — post it anywhere.