📝 Formularios en Flask: WTForms, validación y estilos con Bootstrap
Ya sabes crear rutas, manejar datos con JSON, hacer un CRUD completo, dominar Jinja2 con herencia de plantillas y dar estilo a tus aplicaciones con Bootstrap. Ahora toca dar el siguiente paso: crear formularios web que funcionen y sean seguros de verdad.
- ✅
forms.py– donde definiremos la claseSoporteForm. - ✅ Flask-WTF y email-validator – nuevas dependencias.
- ✅ Ruta
/soporteen el controlador (con GET y POST). - ✅ Plantilla
soporte.html– el formulario con Bootstrap 5. - ✅ Validación automática (
DataRequired,Email,Length). - ✅ Protección CSRF mediante token.
- ✅ Migración de toda la aplicación a Bootstrap 5.
En este post vas a aprender a:
- ✅ Entender la diferencia entre GET y POST en formularios.
- ✅ Crear formularios robustos con WTForms y conectarlos con Flask mediante Flask-WTF.
- ✅ Validar datos automáticamente (DataRequired, Email, Length).
- ✅ Proteger tus formularios contra ataques CSRF.
- ✅ Renderizar formularios con Bootstrap 5 y estilos personalizados.
- ✅ Procesar los datos en el controlador y mostrar mensajes de éxito/error con flash().
- ✅ Manejar errores con try/except para dar feedback al usuario.
Al terminar, tendrás un formulario de soporte profesional, validado y con mensajes de éxito/error.
🧠 ¿Qué son los formularios en Flask?
En Flask, un formulario no es más que un conjunto de campos HTML que el usuario rellena y envía al servidor. Pero hay dos conceptos clave que debes tener claros, ya que trabajamos con dos solicitudes distintas dependiendo de lo que el navegador del usuario necesita; una es cargar el sitio web donde está el formulario (cuando se carga cualquier sitio web, entre los elementos aparece el formulario) –eso es una petición GET– y la otra es cuando el usuario envía los datos que completó en ese sitio web con un formulario –eso es una petición POST. Las rutas que necesitan recibir datos pueden aceptar peticiones POST. En nuestro caso utilizamos POST porque el usuario está enviando los datos de un formulario. Recordemos:
SOLICITUDES GET vs POST
- GET: Se usa para solicitar datos del servidor. Los datos se envían en la URL (visible). Es el método por defecto al cargar una página.
- POST: Se usa para enviar datos al servidor (formularios, archivos, etc.). Los datos viajan en el cuerpo de la petición, no son visibles en la URL.

Que los datos viajen en el cuerpo de una petición POST y no aparezcan en la URL no significa que estén cifrados.
La protección de los datos durante el transporte depende de utilizar HTTPS.
POST ≠ cifrado. HTTPS = protege la comunicación en tránsito.
Cuando un usuario carga un formulario por primera vez, suele ser una petición GET. Cuando lo envía (hace clic en «Enviar»), es una petición POST. Por eso, en Flask, nuestras rutas de formularios suelen admitir ambos métodos:
@app.route('/soporte', methods=['GET', 'POST'])
def soporte():
# Si es GET, mostramos el formulario vacío.
# Si es POST, procesamos los datos.
pass
GET normalmente sirve para pedir o mostrar recursos.
POST normalmente se utiliza para enviar datos al servidor.
En nuestro formulario: GET = mostrar el formulario. POST = enviar el formulario.
🚦 ¿Qué sucederá cuando pulsen «Enviar»?
Antes de meternos en código, vamos a entender el viaje de los datos desde que el usuario pulsa «Enviar» hasta que Flask responde. Esto te ayudará a comprender cada pieza del rompecabezas.

Este es el recorrido paso a paso:
- El navegador envía una petición GET para cargar el formulario.
- El usuario o visitante completa los datos y pulsa ENVIAR.
- El navegador envía una petición POST.
- Flask (nuestro controlador) recibe la petición y crea una instancia objeto form a partir de la clase
SoporteForm. - Flask-WTF comprueba el Token CSRF (seguridad de los datos).
- WTForms aplica los validadores (
DataRequired,Email,Length). - Si hay errores, se vuelve a mostrar el formulario con los mensajes de error.
- Si todo es válido, se procesan los datos (en este caso, los mostramos por consola).
- Se genera un mensaje flash de éxito.
- Se hace un redirect a la misma página (para evitar reenvíos).
- El navegador carga la página mediante GET y muestra el mensaje.

¿Qué es WTForms y por qué usarlo con Flask?
Puedes crear formularios con HTML puro y procesarlos manualmente (como hicimos en los primeros posts de la serie). Pero a medida que tu aplicación crece, necesitas:
- Validación automática de datos (que el email sea válido, que un campo no esté vacío, etc.).
- Protección CSRF (Cross-Site Request Forgery) para evitar ataques.
- Reutilización de formularios en múltiples rutas.
- Mantenibilidad (tener toda la definición del formulario en un solo lugar).
WTForms es una librería de Python que se encarga de la definición, validación y renderizado de formularios.
Flask-WTF es la extensión que conecta WTForms con Flask, añadiendo integración con sesiones, CSRF y protección de formularios.
Juntos, te ahorran escribir código repetitivo y te protegen de errores comunes de seguridad.
CSRF (Cross-Site Request Forgery) es un ataque donde un sitio malicioso engaña al navegador del usuario para que realice acciones no deseadas en otro sitio donde está autenticado.
Flask-WTF genera un token CSRF asociado a la sesión y lo firma mediante una clave secreta. Cuando llega una petición protegida, el servidor comprueba que el token sea válido y corresponda con el que espera para esa sesión.
Un Token es una cadena de datos o un fragmento de código único que sirve como credencial digital de acceso, unidad de procesamiento de texto o representación de un valor.
Siempre que usamos librerías debemos tener en cuenta las vulnerabilidades que nos evitan y la facilidad de no tener que programar un formulario desde cero; en este caso nos sostenemos en un estándar de la librería que, recordemos, «ningún sistema es 100% seguro«, pero los desarrolladores actualizan las librerías en base a datos de la comunidad y reporte de bugs, por lo que si mantenemos nuestra versión actualizada en nuestro entorno virtual, nos aseguramos de que estamos cubiertos ante los riesgos principales existentes y reportados.
🛠️ Instalación y configuración de WTForms
Recuerda que debes tener activado tu entorno virtual:
# Linux/Mac
source venv/bin/activate# o en Windows:
venv\Scripts\activate
Ahora vamos a instalar Flask-WTF y email-validator (necesario para el validador Email()):
pip install Flask-WTF email-validator

Esto instalará WTForms junto con sus dependencias y la librería necesaria para validar direcciones de correo.
El validador Email() de WTForms necesita la librería email-validator para comprobar que la dirección introducida tenga un formato de correo válido (con @ y dominio). Sin ella, el validador no funcionará y obtendrás un error.
Ahora, en tu archivo principal (normalmente controlador.py o app.py), necesitas configurar una clave secreta (SECRET_KEY). Esa clave se usa para firmar el Token CSRF y otros mecanismos de seguridad. (tranqui, te explico bien luego).
from flask import Flask app = Flask(__name__) app.config['SECRET_KEY'] = 'una-clave-muy-secreta-y-dificil-de-adivinar'
La SECRET_KEY no es la contraseña de tu usuario. Es una clave interna de la aplicación que Flask utiliza para funciones de seguridad como firmar la sesión y, en Flask-WTF, el Token CSRF.
En desarrollo, puedes usar una clave fija. En producción (es decir, tu aplicación subida a un servidor y funcionando), nunca la pongas en el código; usa variables de entorno (lo veremos en el próximo post).
Y nunca publiques tu clave secreta en GitHub ni en ningún lado; obviamente.
📝 Creando nuestro primer formulario con WTForms
Vamos a crear un formulario de soporte para nuestra aplicación del directorio telefónico. Tendrá tres campos:
- Nombre (obligatorio)
- Email (obligatorio y con formato de email válido)
- Mensaje (obligatorio, con una longitud mínima y máxima) – no queremos que nos envíen 200 mil caracteres en un solo mensaje.
Necesitamos, y es conveniente, crear un archivo aparte para definir las «clases» de nuestro formulario y no hacerlo directamente en el controlador, porque nuestro controlador se encarga de las rutas, no lo olvides.
Es mejor siempre separar: el controlador son rutas y procesamiento; todo lo demás lo debemos importar desde otro archivo para mantener un orden y programar aplicaciones modulares y escalables. No quieres luego tener un controlador de 50 mil líneas de código con formularios y chuchería mezclada en un guiso.
En este capítulo no modificamos models.py. Nuestro modelo Persona continúa exactamente igual que en los capítulos anteriores. Solo añadimos:
forms.py- la ruta
/soporte templates/soporte.html- la actualización a Bootstrap 5 en todas las plantillas
Paso 1: En la raíz del proyecto crea el archivo forms.py

En la raíz de tu proyecto (junto a controlador.py, models.py), crea un archivo llamado forms.py. Aquí definiremos todos nuestros formularios.
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SubmitField
from wtforms.validators import DataRequired, Email, Length
class SoporteForm(FlaskForm):
nombre = StringField('Nombre', validators=[DataRequired()])
email = StringField('Email', validators=[DataRequired(), Email()])
mensaje = TextAreaField('Mensaje', validators=[
DataRequired(),
Length(min=10, max=500, message='El mensaje debe tener entre 10 y 500 caracteres.')
])
submit = SubmitField('Enviar')
Desglose:
FlaskFormes la clase base que nos da Flask-WTF (que a su vez hereda de la claseFormde WTForms).StringFieldyTextAreaFieldson los tipos de campo.StringFieldpara texto corto,TextAreaFieldpara texto largo.validatorses una lista de reglas que se aplican al campo.DataRequired()significa que el campo no puede estar vacío.Email()valida que el texto tenga formato de email (con@y dominio).Length(min, max)limita la cantidad de caracteres.SubmitFieldes el botón de envío.
No estamos creando el formulario directamente dentro de controlador.py.
Primero creamos un «molde» llamado SoporteForm.
En ese molde definimos:
✅ qué campos tendrá el formulario
✅ qué tipo de datos recibirá cada campo
✅ qué reglas debe cumplir cada dato
✅ qué botón tendrá el formulario
Después, desde controlador.py, creamos una instancia de ese molde:
form = SoporteForm()
A partir de esa instancia, Flask-WTF podrá mostrar el formulario, recibir los datos y validarlos. Revisa el siguiente paso.
🧩 Más campos de WTForms: personaliza tus formularios
WTForms dispone de una gama amplia de campos, y Flask-WTF añade además integración específica para archivos mediante FileField. Aquí tienes un resumen de los campos más comunes que vas a necesitar:
| Campo | Para qué sirve | Ejemplo |
|---|---|---|
StringField |
Texto corto | Nombre, usuario |
TextAreaField |
Texto largo | Comentario, mensaje |
PasswordField |
Contraseñas | Contraseña |
BooleanField |
Verdadero / Falso (checkbox) | Aceptar términos |
SubmitField |
Botón de envío | Enviar |
SelectField |
Seleccionar una opción | País |
SelectMultipleField |
Seleccionar varias opciones | Intereses |
RadioField |
Elegir una opción entre varias | Sexo / modalidad |
IntegerField |
Número entero | Edad |
FloatField |
Número decimal | Precio |
DateField |
Fecha | Fecha de nacimiento |
FileField |
Archivo | Foto / documento |
✅ ¿Cómo creo un checkbox con WTForms?
El BooleanField representa un valor verdadero/falso y normalmente se muestra como una casilla de verificación. Es útil para opciones como «Acepto los términos», «Quiero recibir novedades», etc.
Para hacerlo obligatorio, usa el validador DataRequired():
from wtforms import BooleanField
from wtforms.validators import DataRequired
acepto = BooleanField(
'Acepto los términos y condiciones',
validators=[DataRequired()]
)
💡 Ojo: DataRequired() para un BooleanField exige que el checkbox esté marcado. Esto funciona bien para casos como «Acepto términos», pero para otros usos (ej. «Quiero recibir novedades» que no es obligatorio) puedes omitir el validador.
✅ ¿Cómo creo un Select (desplegable) con WTForms?
El SelectField te permite mostrar un menú desplegable con opciones predefinidas:
from wtforms import SelectField
pais = SelectField(
'País',
choices=[
('ar', 'Argentina'),
('es', 'España'),
('mx', 'México')
]
)
Cada tupla en choices tiene el formato (valor_a_guardar, texto_a_mostrar).

No necesitas memorizar todos los campos de WTForms.
Cuando necesites crear un formulario diferente, consulta la documentación oficial y busca el tipo de campo que necesitas.
👉 Documentación oficial de WTForms
Aunque esté principalmente en inglés, no necesitas traducir cada palabra. Busca el nombre del campo que necesitas (StringField, BooleanField, etc.) y revisa su ejemplo de uso.
Aprender a consultar documentación es una habilidad tan importante como aprender a escribir código.
Paso 2: Importar el formulario en el controlador
Ahora sí, nos enfocamos en que nuestro controlador integre este nuevo «módulo» formulario. En tu controlador.py, importa el formulario y úsalo en la nueva ruta para Soporte. Antes de ver el código completo, fíjate en la ruta /soporte:
# controlador.py (fragmento de la ruta soporte)
@app.route('/soporte', methods=['GET', 'POST'])
def soporte():
form = SoporteForm() # Creamos una instancia del formulario
if form.validate_on_submit():
# Si todo es válido, obtenemos los datos
nombre = form.nombre.data
email = form.email.data
mensaje = form.mensaje.data
print(f"Mensaje de {nombre} ({email}): {mensaje}")
flash('¡Mensaje enviado con éxito!', 'success')
return redirect(url_for('soporte'))
# Si es GET o hay errores, mostramos el formulario con los errores
return render_template('soporte.html', form=form)
Esto es lo que ocurre:
SoporteForm()crea una instancia del formulario. Si es GET, se muestra vacío; si es POST, se rellena con los datos enviados.validate_on_submit()comprueba si la petición es POST y si todos los validadores y el CSRF pasan. Si esTrue, procesamos los datos; si esFalse, saltamos alrender_templateque mostrará el formulario con los errores (gracias aform.errors).flash()guarda un mensaje que se mostrará en la siguiente petición (gracias aget_flashed_messages()enbase.html).redirect(url_for('soporte'))redirige a la misma ruta mediante GET, evitando que el usuario reenvíe el formulario al recargar (patrón Post/Redirect/Get).
🧠 ¿Qué hace realmente validate_on_submit()?
Esta línea es el corazón del procesamiento del formulario. Básicamente pregunta:
¿El usuario ha enviado el formulario y todos los controles de seguridad y validación han pasado correctamente?

Su comportamiento es:
- Si la petición es GET, devuelve
False(no valida). - Si la petición es POST y todos los campos pasan las validaciones, devuelve
True. - Si los validadores del formulario no pasan,
validate_on_submit()devuelveFalsey puedes consultarform.errors. Además, Flask-WTF comprueba la protección CSRF; si el token falta o no es válido, la petición puede ser rechazada.
Este condicional es clave, ya que define cómo va a procesar la «petición» del usuario y si no está bien construido podrías tener errores tanto al cargar la página donde está como al enviar datos. Y si no existiera además estaríamos permitiendo procesar solicitudes POST sin condiciones claras.
Nota: validate_on_submit() es un atajo que combina request.method == 'POST' y form.validate(). Es el método recomendado para el patrón habitual de Flask-WTF.
🧠 ¿Qué contiene form.errors?
form.errors reúne los errores encontrados durante la validación de los campos. Por ejemplo:
email → formato de correo no válido mensaje → longitud incorrecta
El flujo es:
validators ↓ form.errors ↓ soporte.html ↓ is-invalid ↓ invalid-feedback
Esto conecta la validación del servidor con el renderizado visual en Bootstrap 5.
🎨 Actualizando tu proyecto a Bootstrap 5
Hasta ahora hemos estado usando Bootstrap 4 en nuestros ejemplos. Sin embargo, a partir de este post vamos a actualizar a Bootstrap 5. ¿Por qué?
- ✅ Bootstrap 5 es la versión actual y mantenida.
- ✅ Incluye mejoras de rendimiento, accesibilidad y nuevas utilidades.
- ✅ Es la versión que usarás en proyectos modernos.
- ✅ Te enseña a actualizar dependencias, una habilidad clave en el desarrollo real.
Esta migración no es el tema principal del capítulo. La hacemos ahora para que el proyecto quede en una única versión de Bootstrap antes de continuar con los siguientes capítulos.
Los cambios principales son:
| Bootstrap 4 | Bootstrap 5 | ¿Qué cambia? |
|---|---|---|
ml-auto |
ms-auto |
Margin-left → margin-start (para soporte RTL) |
mr-auto |
me-auto |
Margin-right → margin-end |
data-toggle |
data-bs-toggle |
Prefijo para atributos de datos |
data-target |
data-bs-target |
Prefijo para atributos de datos |
data-dismiss |
data-bs-dismiss |
Prefijo para atributos de datos |
form-row |
row g-3 |
Sistema de grid para formularios |
btn-block |
d-grid + w-100 o d-grid en contenedor |
Botones de ancho completo |
thead-dark |
table-dark |
Cabecera de tabla oscura |
| jQuery + Popper.js | Bundle único (bootstrap.bundle.min.js) |
Se eliminan dependencias externas |
🔗 El puente con el capítulo anterior: de # a url_for(‘soporte’)
En el post anterior, cuando mejoramos el header.html, dejamos el enlace a «Soporte» con un # porque la página todavía no existía:
<li class="nav-item">
<a class="nav-link" href="#">Soporte</a>
</li>
Ahora que hemos creado la ruta /soporte y la plantilla soporte.html, actualizamos ese enlace para que apunte a la nueva página:
<li class="nav-item">
<a class="nav-link" href="{{ url_for('soporte') }}">Soporte</a>
</li>
Este pequeño cambio cierra un ciclo: prometimos una página de soporte en el capítulo anterior, y ahora la entregamos.
📐 Mejora del layout: envolver el navbar en un container
Además de adaptar las clases a Bootstrap 5, aprovecharemos para envolver el contenido de nuestro navbar en un container, de modo que mantenga la misma alineación horizontal que el resto de la aplicación. En el post anterior el navbar estaba así:
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="{{ url_for('inicio') }}">
<img src="{{ url_for('static', filename='img/logo.png') }}" alt="Logo" height="40">
Directorio Online
</a>
...
</nav>
Ahora lo mejoramos añadiendo un div class="container" dentro del navbar:
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container">
<a class="navbar-brand" href="{{ url_for('inicio') }}">
<img src="{{ url_for('static', filename='img/logo.png') }}" alt="Logo" height="40" class="d-inline-block align-top">
Directorio Online
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
...
</ul>
</div>
</div>
</nav>
Esto garantiza que el contenido del navbar esté alineado con el resto de la página (que ya usa container en base.html), dando un aspecto más profesional y consistente.
A partir de este punto de la serie utilizaremos Bootstrap 5.
Por eso verás atributos como data-bs-toggle, data-bs-dismiss y clases como ms-auto (en lugar de ml-auto de Bootstrap 4).
No mezcles sintaxis de Bootstrap 4 con Bootstrap 5. Debes modificar en todos los archivos (base.html, header.html, index.html, galeria.html, etc.) los scripts y clases donde incluiste Bootstrap 4, para reemplazarlos por Bootstrap 5.
Nuestro base.html queda así:
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Directorio Telefónico{% endblock %}</title>
<!-- CAMBIO: Bootstrap 4 → Bootstrap 5 (CDN actualizado) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
{% include 'header.html' %}
<main class="container mt-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<!-- CAMBIO: data-dismiss → data-bs-dismiss -->
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
{% include 'footer.html' %}
<!-- CAMBIO: scripts de Bootstrap 5 (se elimina jQuery y Popper separado, se usa el bundle) -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
Resumen de cambios en base.html:
- ✅ CDN de CSS actualizado a Bootstrap 5.3.8.
- ✅ Atributo
data-dismiss→data-bs-dismiss. - ✅ Scripts: se elimina jQuery y Popper.js separados, se usa el bundle único
bootstrap.bundle.min.js. - ✅ Ahora el
containery las clases funcionan con Bootstrap 5.
También debes actualizar el resto de las plantillas que usan clases de Bootstrap 4. Aquí tienes la tabla de archivos afectados y al final del post encuentras el código completo para copiar y pegar si lo deseas:
| Archivo | Estado | Motivo |
|---|---|---|
base.html |
Modificar | Bootstrap 5 + flash + bundle |
header.html |
Modificar | Navbar: data-bs-*, ms-auto, container |
footer.html |
Revisar | Clases Bootstrap 5 |
index.html |
Modificar | thead-dark → table-dark, btn-block → d-grid |
galeria.html |
Revisar | Compatibilidad Bootstrap 5 |
editar_contacto.html |
Modificar | form-row → row g-3, btn-block → d-grid, thead-dark → table-dark |
agregar_contacto.html |
Modificar | form-row → row g-3, btn-block → d-grid, thead-dark → table-dark |
soporte.html |
Nuevo | WTForms + Bootstrap 5 |
📂 Estructura final del proyecto (con forms.py y soporte.html)
Después de todas las modificaciones, tu proyecto debería tener esta estructura:
mi_proyecto_flask/
├── controlador.py
├── models.py
├── forms.py ← NUEVO
├── DB/
│ └── base_de_datos.json
├── templates/
│ ├── base.html
│ ├── header.html
│ ├── footer.html
│ ├── index.html
│ ├── galeria.html
│ ├── editar_contacto.html
│ ├── agregar_contacto.html
│ └── soporte.html ← NUEVO
└── static/
├── css/
│ └── style.css
├── img/
│ ├── logo.png
│ ├── python-logo.png
│ ├── flask-logo.png
│ ├── bootstrap-logo.png
│ └── jinja2-logo.png
├── video/
│ └── presentacion.mp4
└── js/
Al final del artículo encontrarás el código completo del proyecto y un enlace para descargar el ZIP con todos los archivos listos para ejecutar.
Crear la plantilla templates/soporte.html
Una vez actualizado Bootstrap, vamos a crear en /templates la plantilla soporte.html. Por ahora solo copia mi código, luego te explicaré bien, y podrás llevarlo a la práctica cada vez que necesites un formulario.
La plantilla soporte.html incluirá:
- El token CSRF con
{{ form.hidden_tag() }}. - Los campos con etiquetas y clases de Bootstrap.
- Los errores de validación mostrados con la clase
is-invalidyinvalid-feedback.
⚠️ Importante: El bloque de mensajes flash ya está en base.html, por lo que no debes repetirlo aquí. Si lo incluyes, verás los mensajes duplicados. Además está en el base para que podamos incluir mensajes de error o éxito personalizados en toda la app; por ejemplo, luego podríamos modificar nuestro formulario anterior de agregar o editar contactos para mostrar éxito/fallo.
{% extends "base.html" %}
{% block title %}Soporte{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-8">
<h1 class="text-center mb-4">🛠️ Soporte Técnico</h1>
<p class="text-center text-muted">¿Tienes algún problema o duda? Completa el formulario y te responderemos lo antes posible.</p>
<form method="POST" action="{{ url_for('soporte') }}" class="mt-4">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.nombre.label(class="form-label") }}
{{ form.nombre(class="form-control" + (" is-invalid" if form.nombre.errors else "")) }}
{% if form.nombre.errors %}
<div class="invalid-feedback">
{% for error in form.nombre.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-3">
{{ form.email.label(class="form-label") }}
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else "")) }}
{% if form.email.errors %}
<div class="invalid-feedback">
{% for error in form.email.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-3">
{{ form.mensaje.label(class="form-label") }}
{{ form.mensaje(class="form-control" + (" is-invalid" if form.mensaje.errors else ""), rows=5) }}
{% if form.mensaje.errors %}
<div class="invalid-feedback">
{% for error in form.mensaje.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="d-grid">
{{ form.submit(class="btn btn-primary btn-lg") }}
</div>
</form>
</div>
</div>
{% endblock %}
Detalles importantes:
{{ form.hidden_tag() }}genera los campos ocultos necesarios para el formulario, incluido el token CSRF. Si falta, el formulario no se validará.- Usamos
form.nombre.errorspara mostrar errores de validación justo debajo de cada campo. - La clase
is-invalidse añade condicionalmente para que Bootstrap resalte los campos con errores. - Los mensajes flash ya se muestran desde
base.html, no es necesario repetirlos aquí.

Se verá algo así. Prueba iniciar el servidor en tu terminal con:
python3 controlador.py
Bien, ahora a experimentar con esta wea:
🧪 Vamos a romper el formulario a propósito
La mejor manera de entender la validación es intentar enviar datos incorrectos. Vamos a probar diferentes casos.
Prueba 1 — Todo vacío
Envía el formulario sin escribir nada en ningún campo. Deberías ver errores como:
- «Este campo es obligatorio» en Nombre.
- «Este campo es obligatorio» en Email.
- «Este campo es obligatorio» en Mensaje.
Prueba 2 — Email incorrecto

Escribe en el campo Email algo como botspammer@ (sin dominio).
Resultado: el validador Email() detectará que el formato no es válido y mostrará un error.
Prueba 3 — Mensaje demasiado corto

Escribe solo Hola en el campo Mensaje.
Resultado: el validador Length(min=10) mostrará un error personalizado: «El mensaje debe tener entre 10 y 500 caracteres.»
Prueba 4 — Datos correctos

Completa todos los campos con datos válidos y envía.
Resultado: validación OK, procesamiento, mensaje flash de éxito y redirección.

Y si recibes un error ni modo, revisa la consola de tu VSCode y deberías dar fácilmente con el error. Como último recurso puedes pedir ayuda o buscar el error en Google, también, si quieres, puedes preguntarle a la IA. Eso sí, no te acostumbres a hacer todo con IA aún, no es el momento, pero puedes permitirte una ayudita en caso de que no lo logres sol@.
🔄 ¿Qué pasa si algo falla durante el procesamiento?
Ya tenemos el formulario en forms.py, la ruta en controlador.py y la plantilla en soporte.html. Ahora vamos a profundizar en el controlador, añadiendo manejo de excepciones con try/except para que, si algo falla (por ejemplo, el envío de un email), el usuario reciba un mensaje de advertencia.
La diferencia clave es:
validate_on_submit() ↓ ¿Los datos son válidos? try/except ↓ ¿El procesamiento posterior falla?
Aunque en este post no vamos a enviar correos reales (eso lo veremos en el próximo), es importante adelantar la estructura de cómo manejaríamos los errores.
El siguiente fragmento muestra únicamente la parte dentro de if form.validate_on_submit(): donde añadimos try/except:
if form.validate_on_submit():
nombre = form.nombre.data
email = form.email.data
mensaje = form.mensaje.data
try:
# Aquí iría el envío del email (lo veremos en el próximo post)
# Por ahora simulamos que todo funciona
print(f"Enviando email desde {email} con mensaje: {mensaje}")
# Si todo sale bien, mostramos éxito
flash('¡Tu mensaje ha sido enviado correctamente!', 'success')
return redirect(url_for('soporte'))
except Exception as e:
# Si ocurre cualquier error, lo capturamos y mostramos un mensaje de advertencia
print(f"Error al enviar el mensaje: {e}")
flash('Hubo un problema al enviar tu mensaje. Por favor, inténtalo de nuevo más tarde.', 'warning')
# No redirigimos, nos quedamos en la misma página para que el usuario pueda corregir
Explicación del try/except:
- Envolvemos la lógica de envío (o cualquier operación que pueda fallar) dentro de un bloque
try. - Si todo sale bien, mostramos un mensaje de éxito con
flash('mensaje', 'success'). - Si ocurre una excepción (por ejemplo, el servidor de correo no responde), la capturamos en
except Exception as ey mostramos un mensaje de advertencia ('warning') conflash(). - Al no hacer
redirect, el usuario permanece en la misma página y puede intentarlo de nuevo.
Usamos except Exception as e aquí para aprender el concepto y mantener el ejemplo sencillo. En aplicaciones reales, conviene capturar excepciones más específicas cuando sabemos qué errores pueden producirse (por ejemplo, SMTPAuthenticationError).
Las categorías de flash() (success, warning, danger, info) se convierten automáticamente en clases de Bootstrap (alert-success, alert-warning, etc.) gracias al código que pusimos en base.html. Así, los mensajes se ven profesionales y consistentes.
El flujo completo de flash() + redirect() es:
POST ↓ procesamiento ↓ flash() ↓ redirect() ↓ GET ↓ get_flashed_messages() ↓ base.html ↓
🧪 Problemas frecuentes con WTForms y Flask
Que no te asusten los errores; sirven para aprender y mejorar, pero solo si entendemos que provoca el error y eso lo hacemos mirando la terminal, ya que en nuestra aplicación tenemos marcado el debug en True.
Aquí tienes una lista de errores comunes que puedes encontrar al trabajar con WTForms y cómo solucionarlos:
| Error | Causa | Solución |
|---|---|---|
ModuleNotFoundError: No module named 'flask_wtf' |
No has instalado Flask-WTF. | Ejecuta pip install Flask-WTF |
ModuleNotFoundError: No module named 'email_validator' |
Falta la librería para el validador Email(). |
Ejecuta pip install email-validator |
| El formulario no valida y no muestra errores | Falta el token CSRF ({{ form.hidden_tag() }}). |
Asegúrate de incluir {{ form.hidden_tag() }} dentro del formulario. |
Error: The CSRF token is missing |
Falta SECRET_KEY o el token no se está enviando. |
Verifica que app.config['SECRET_KEY'] esté definido y que {{ form.hidden_tag() }} esté en el formulario. |
validate_on_submit() devuelve False y no sé por qué. |
Puede ser que la petición no sea POST, que haya errores en form.errors (revisa CSRF y validadores), o que falte SECRET_KEY o dependencias. |
Inspecciona form.errors para ver los mensajes de error, verifica que el formulario incluya hidden_tag() y que la ruta acepte POST. |
| Los errores no se muestran en el formulario | No estás usando is-invalid y invalid-feedback. |
Asegúrate de añadir is-invalid a los campos con errores y mostrar form.campo.errors. |
❓ Preguntas frecuentes sobre formularios en Flask
🎯 Resumen y próximos pasos
Hoy has aprendido a crear formularios profesionales en Flask con WTForms. Has visto:
- ✅ La diferencia entre GET y POST.

- ✅ Cómo instalar y configurar Flask-WTF y
email-validator. - ✅ Cómo definir formularios con clases en
forms.py. - ✅ Cómo usar validadores (
DataRequired,Email,Length). - ✅ Cómo renderizar formularios con Bootstrap 5, mostrando errores y mensajes flash (¡sin duplicarlos!).
- ✅ Cómo procesar los datos en el controlador con
validate_on_submit(). - ✅ Cómo manejar errores con try/except para dar feedback al visitante de tu formulario.
- ✅ La importancia de validar en el servidor y proteger tus formularios con CSRF.
- ✅ La migración de toda la aplicación a Bootstrap 5, mejorando la estructura con
containeren el navbar.
También has visto cómo romper el formulario a propósito para entender cómo funciona la validación en la práctica, y has conocido los problemas frecuentes y sus soluciones.

En el próximo post, vamos a dar el salto definitivo: enviar correos reales desde el formulario usando Flask-Mail y una cuenta de Gmail (SMTP). Así, cuando un usuario complete el formulario de soporte, recibirás un email con su mensaje.
🗃️ Código Completo de la aplicación WTForms + Bootstrap
🐍 ¿Te ha servido este post? Déjame un comentario o compártelo con quien esté aprendiendo Flask.
📅 Última actualización: agosto 19, 2026






