Pydantic Validation
Field, constraints y validators
Descripción de la cápsula
Saber que year es int evita "abc", pero no evita -5000 o 9999. Saber que title es str no evita strings vacíos. Field() agrega restricciones de valor: longitud mínima y máxima, rangos numéricos, patrones regex. @field_validator te permite validaciones personalizadas que Field no cubre: formatos de email, lógica de negocio, valores calculados.
Esta cápsula profundiza en cómo expresar reglas de validación declarativas y cuándo usar validadores personalizados. Los mensajes de error que Pydantic genera aparecen en las respuestas 422 — entenderlos te ayuda a depurar y a mejorar la UX de tu API.
Field() — Constraints declarativos
Field() envuelve el valor por defecto y agrega metadata de validación y documentación:
from pydantic import BaseModel, Field
class Book(BaseModel):
title: str = Field(min_length=1, max_length=200, description="Título del libro")
author: str = Field(min_length=1, max_length=100, description="Nombre del autor")
year: int = Field(ge=1000, le=2030, description="Año de publicación")
genre: str = Field(min_length=1, max_length=50, description="Género literario")
available: bool = Field(default=True, description="¿Disponible para préstamo?")
Referencia de constraints
| Constraint | Aplica a | Significado | Ejemplo |
|---|---|---|---|
min_length | str, list | Longitud mínima | Field(min_length=1) → no vacío |
max_length | str, list | Longitud máxima | Field(max_length=200) |
ge | int, float | Greater or equal (≥) | Field(ge=0) |
gt | int, float | Greater than (>) | Field(gt=0) → estrictamente positivo |
le | int, float | Less or equal (≤) | Field(le=2030) |
lt | int, float | Less than (<) | Field(lt=100) |
pattern | str | Regex | Field(pattern=r"^\d{3}-\d+$") |
default | Todos | Valor por defecto | Field(default=True) |
description | Todos | Aparece en /docs | Documentación en Swagger |
examples | Todos | Ejemplos para /docs | Pre-llena el Try it out |
ge vs gt, le vs lt
ge(greater or equal): el valor puede ser igual al límitegt(greater than): el valor debe ser estrictamente mayorle(less or equal): el valor puede ser igual al límitelt(less than): el valor debe ser estrictamente menor
price: float = Field(ge=0) # 0 permitido
price: float = Field(gt=0) # 0 NO permitido, solo > 0
Ejemplos con Field
Strings: min_length y max_length
from pydantic import BaseModel, Field, ValidationError
class User(BaseModel):
username: str = Field(min_length=3, max_length=50)
email: str = Field(min_length=5, max_length=100)
try:
User(username="ab", email="test@example.com")
except ValidationError as e:
print(e)
# username — String should have at least 3 characters [type=string_too_short]
try:
User(username="valid_user", email="x")
except ValidationError as e:
print(e)
# email — String should have at least 5 characters
Números: ge, le, gt, lt
class Product(BaseModel):
name: str = Field(min_length=1)
price: float = Field(ge=0, description="Precio no negativo")
quantity: int = Field(gt=0, description="Cantidad estrictamente positiva")
discount: float = Field(ge=0, le=1, description="Descuento entre 0 y 1 (ej: 0.2 = 20%)")
pattern para regex
class Contact(BaseModel):
phone: str = Field(pattern=r"^\+?[\d\s\-]{9,}$", description="Teléfono válido")
postal_code: str = Field(pattern=r"^\d{5}$", description="Código postal 5 dígitos")
Validación en acción: respuesta 422
Cuando un cliente envía datos que violan los constraints:
curl -s -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "", "author": "X", "year": 3000, "genre": "Ficción"}' | python -m json.tool
{
"detail": [
{
"type": "string_too_short",
"loc": ["body", "title"],
"msg": "String should have at least 1 character",
"ctx": {"min_length": 1},
"input": ""
},
{
"type": "less_than_equal",
"loc": ["body", "year"],
"msg": "Input should be less than or equal to 2030",
"ctx": {"le": 2030},
"input": 3000
}
]
}
Cada error incluye: type, loc (ubicación), msg, ctx (contexto como el valor del constraint), e input (lo que envió el cliente).
@field_validator — Validadores personalizados
Cuando Field no basta, defines validadores con el decorador @field_validator:
from pydantic import BaseModel, Field, field_validator
class User(BaseModel):
email: str = Field(min_length=5)
username: str = Field(min_length=3)
@field_validator("email")
@classmethod
def email_must_contain_at(cls, v: str) -> str:
if "@" not in v:
raise ValueError("El email debe contener @")
return v
@field_validator("username")
@classmethod
def username_alphanumeric(cls, v: str) -> str:
if not v.isalnum():
raise ValueError("El username solo puede tener letras y números")
return v.lower()
Puntos clave:
- El decorador recibe el nombre del campo
- Debe ser
@classmethod - Recibe el valor, retorna el valor (puedes transformarlo)
- Lanza
ValueErrorcon el mensaje que quieres mostrar al cliente
field_validator: modos (mode="before" vs "after")
Por defecto Pydantic ejecuta el validador después de la conversión de tipo. Si necesitas validar el valor antes (por ejemplo cuando el tipo puede ser str o algo más):
from pydantic import BaseModel, field_validator
class Config(BaseModel):
port: int = Field(ge=1, le=65535)
@field_validator("port", mode="before")
@classmethod
def parse_port(cls, v):
if isinstance(v, str) and v.isdigit():
return int(v)
return v
mode="after"(default): el valor ya fue convertido al tipo declaradomode="before": el valor es el raw input, puedes pre-procesarlo antes de la conversión
En la mayoría de casos, mode="after" es suficiente.
Varios validadores para el mismo campo
Puedes definir múltiples validadores; se ejecutan en orden:
from pydantic import BaseModel, Field, field_validator
class Password(BaseModel):
value: str = Field(min_length=8)
@field_validator("value")
@classmethod
def no_ espacios(cls, v: str) -> str:
if " " in v:
raise ValueError("La contraseña no puede contener espacios")
return v
@field_validator("value")
@classmethod
def must_have_digit(cls, v: str) -> str:
if not any(c.isdigit() for c in v):
raise ValueError("La contraseña debe contener al menos un dígito")
return v
model_validator — Validación a nivel de modelo
Cuando la validación involucra varios campos a la vez:
from pydantic import BaseModel, Field, model_validator
class DateRange(BaseModel):
start: str = Field(pattern=r"^\d{4}-\d{2}-\d{2}$")
end: str = Field(pattern=r"^\d{4}-\d{2}-\d{2}$")
@model_validator(mode="after")
def start_before_end(self):
if self.start > self.end:
raise ValueError("start debe ser anterior a end")
return self
mode="after" significa que el modelo ya fue construido; tienes acceso a self y a todos los campos.
Customizar mensajes de error
Pydantic v2 permite customizar mensajes con ValidationError y ErrorDetail, pero la forma más práctica es usar ValueError con un mensaje claro en tu validador — ese mensaje aparece en el msg del error 422:
@field_validator("email")
@classmethod
def validate_email(cls, v: str) -> str:
if "@" not in v or "." not in v.split("@")[-1]:
raise ValueError("Formato de email inválido. Debe ser algo@dominio.com")
return v
El cliente verá ese mensaje en detail[].msg.
API completa con Field y validators
from fastapi import FastAPI
from pydantic import BaseModel, Field, field_validator
class Book(BaseModel):
title: str = Field(min_length=1, max_length=200, description="Título del libro")
author: str = Field(min_length=1, max_length=100, description="Nombre del autor")
year: int = Field(ge=1000, le=2030, description="Año de publicación")
genre: str = Field(min_length=1, max_length=50, description="Género literario")
available: bool = Field(default=True, description="¿Disponible?")
@field_validator("title", "author")
@classmethod
def strip_whitespace(cls, v: str) -> str:
return v.strip() if v else v
app = FastAPI(title="Books API", version="1.0.0")
books_db = []
@app.post("/books", status_code=201)
def create_book(book: Book):
book_dict = book.model_dump()
book_dict["id"] = len(books_db) + 1
books_db.append(book_dict)
return book_dict
@app.get("/books")
def list_books():
return books_db
El validador strip_whitespace limpia espacios al inicio y final de title y author antes de guardar. Observa que puedes pasar varios nombres de campo al decorador.
model_config: str_strip_whitespace
En lugar de un validador manual, puedes activar el stripping global:
class Book(BaseModel):
model_config = {"str_strip_whitespace": True}
title: str = Field(min_length=1)
author: str = Field(min_length=1)
year: int = Field(ge=1000, le=2030)
genre: str = Field(min_length=1)
Todos los strings se les quitan espacios al inicio y final antes de validar.
Descripción y ejemplos en /docs
description y examples en Field mejoran la documentación:
class Book(BaseModel):
title: str = Field(
min_length=1,
max_length=200,
description="Título del libro",
examples=["Cien Años de Soledad"]
)
genre: str = Field(
min_length=1,
examples=["Novela", "Cuentos", "Ensayo"]
)
En Swagger UI, esos ejemplos aparecen en el esquema y pueden pre-llenar el body.
Errores comunes
1. Firma incorrecta del validador
El validador debe recibir cls (como classmethod) y el valor, y retornar el valor. Si olvidas algún parámetro o el tipo de retorno, Pydantic fallará:
# ❌ Incorrecto: falta cls, no es classmethod
@field_validator("email")
def validate_email(v: str) -> str:
if "@" not in v:
raise ValueError("Email inválido")
return v
# ✅ Correcto
@field_validator("email")
@classmethod
def validate_email(cls, v: str) -> str:
if "@" not in v:
raise ValueError("Email inválido")
return v
2. Olvidar @classmethod
En Pydantic v2, los @field_validator deben ser métodos de clase. Sin @classmethod, obtendrás un error al ejecutar:
# ❌ TypeError: field_validator() missing 1 required positional argument: 'cls'
@field_validator("email")
def validate_email(cls, v: str) -> str:
return v
# ✅ Siempre usa @classmethod
@field_validator("email")
@classmethod
def validate_email(cls, v: str) -> str:
return v
3. Field vs field_validator: cuándo usar cada uno
- Field(): para constraints declarativos (min_length, max_length, ge, le, pattern). Úsalo cuando la regla sea simple y no requiera lógica compleja.
- @field_validator: para lógica personalizada (formatos custom, normalización, validación cruzada). No dupliques: si
Field(pattern=r"^\d+$")basta, no agregues un validador que haga lo mismo.
# ❌ Redundante: Field ya valida el rango
year: int = Field(ge=1000, le=2030)
@field_validator("year")
@classmethod
def check_year(cls, v: int) -> int:
if v < 1000 or v > 2030:
raise ValueError("Año inválido")
return v
# ✅ Usa Field para rangos, validator solo para lógica extra
year: int = Field(ge=1000, le=2030)
4. mode="before" vs "after": cuándo usar cada uno
- mode="after" (default): el valor ya fue convertido al tipo declarado. Úsalo cuando solo necesites validar o transformar un valor ya tipado.
- mode="before": el valor es el raw input (puede ser str, int, etc.). Úsalo cuando quieras normalizar antes de la conversión (ej. convertir
"8080"a8080).
Si usas mode="after" y el cliente envía un tipo incorrecto, la conversión falla antes de que tu validador se ejecute. Si necesitas aceptar múltiples tipos de entrada, usa mode="before".
5. Sintaxis Pydantic v1 vs v2
Si migras código antiguo, ten en cuenta:
| v1 | v2 |
|---|---|
@validator("campo") | @field_validator("campo") |
@root_validator | @model_validator |
values en validator | Acceso a self en model_validator(mode="after") |
.dict() | .model_dump() |
schema() | model_json_schema() |
No mezcles decoradores de v1 y v2 en el mismo proyecto.
Otras trampas frecuentes
min_length=0 para strings opcionales
Si quieres un string opcional que puede estar vacío:
notes: str = Field(default="", max_length=500)
O con Optional:
notes: Optional[str] = Field(default=None, max_length=500)
Confundir ge/gt con le/lt
ge= "mayor o igual"gt= "estrictamente mayor"le= "menor o igual"lt= "estrictamente menor"
Para precios: Field(ge=0) permite 0 (gratis). Field(gt=0) no permite 0.
Validador que no retorna
El validador debe retornar el valor (transformado o no):
@field_validator("email")
@classmethod
def validate_email(cls, v: str) -> str:
if "@" not in v:
raise ValueError("Email inválido")
return v # ← Obligatorio
Validar campos que dependen de otros
Usa @model_validator(mode="after") cuando necesites comparar varios campos.
Troubleshooting
"Input should be a valid string"
El cliente envió algo que no es string (ej. un número). Revisa el tipo en el JSON. Si quieres aceptar números y convertirlos, usa mode="before" en el validador para normalizar.
El validador no se ejecuta
Verifica que el nombre del campo en @field_validator("campo") coincida exactamente con el atributo del modelo. Es case-sensitive.
ValueError con mensaje genérico
El mensaje que pones en raise ValueError("...") es lo que verá el cliente. Sé específico: "El email debe contener @" en lugar de "Invalid".
pattern no coincide
Prueba tu regex en Python primero: import re; re.match(r"tu_pattern", "valor"). Asegúrate de que el patrón esté como raw string r"..." si usas backslashes.
ValidationError al inicializar desde JSON
Si parseas JSON manualmente y pasas el resultado a un modelo, asegúrate de que la estructura coincida. Por ejemplo, si el JSON tiene "year": "2020" y tu modelo espera int, Pydantic intentará convertir; si falla, obtendrás 422. Usa mode="before" si quieres aceptar strings numéricos y convertirlos explícitamente.
El constraint no se aplica a listas vacías
Field(min_length=1) en un list solo se evalúa si la lista existe. Si el campo es opcional y el cliente no envía el campo, Pydantic no valida. Para listas requeridas que no pueden estar vacías, usa list[T] = Field(min_length=1) sin default.
Ejercicios
Ejercicio 1: Constraints básicos (Fácil)
Define un modelo Product con: name (str, min 1, max 100), price (float, ge 0), stock (int, ge 0). Prueba crear uno válido y otro con price=-10. ¿Qué error obtienes?
Ver solución
from pydantic import BaseModel, Field, ValidationError
class Product(BaseModel):
name: str = Field(min_length=1, max_length=100)
price: float = Field(ge=0)
stock: int = Field(ge=0)
# Válido
Product(name="Laptop", price=999.99, stock=5)
# Inválido
try:
Product(name="Laptop", price=-10, stock=5)
except ValidationError as e:
print(e)
# price — Input should be greater than or equal to 0 [type=greater_than_equal]
Ejercicio 2: @field_validator para email (Medio)
Crea un modelo User con email: str y un validador que verifique que contenga @ y que después del @ haya al menos un . (dominio). Lanza ValueError con mensaje claro si falla.
Ver solución
from pydantic import BaseModel, field_validator
class User(BaseModel):
email: str
@field_validator("email")
@classmethod
def email_format(cls, v: str) -> str:
if "@" not in v:
raise ValueError("El email debe contener @")
parts = v.split("@")
if len(parts) != 2 or "." not in parts[1]:
raise ValueError("Formato inválido. Debe ser algo@dominio.com")
return v.lower()
Ejercicio 3: pattern para teléfono (Medio)
Define un campo phone que acepte formatos como +34 612 345 678, 612-345-678, 612345678. Usa un regex con Field(pattern=...).
Ver solución
from pydantic import BaseModel, Field
class Contact(BaseModel):
phone: str = Field(pattern=r"^\+?[\d\s\-]{9,15}$")
# Acepta: +34 612 345 678, 612-345-678, 612345678
# Rechaza: abc, 12 (muy corto)
El regex permite: + opcional al inicio, luego dígitos, espacios o guiones, entre 9 y 15 caracteres.
Ejercicio 4: model_validator (Medio)
Crea un modelo PasswordChange con password y password_confirm. Usa @model_validator para verificar que ambos sean iguales. Si no lo son, lanza ValueError.
Ver solución
from pydantic import BaseModel, Field, model_validator
class PasswordChange(BaseModel):
password: str = Field(min_length=8)
password_confirm: str = Field(min_length=8)
@model_validator(mode="after")
def passwords_match(self):
if self.password != self.password_confirm:
raise ValueError("Las contraseñas no coinciden")
return self
Ejercicio 5: strip en validador (Fácil)
Agrega un validador a un modelo con campo name que elimine espacios al inicio y final del string antes de validar min_length. ¿Qué mode usas?
Ver solución
@field_validator("name", mode="before")
@classmethod
def strip_name(cls, v):
if isinstance(v, str):
return v.strip()
return v
mode="before" permite transformar el valor antes de que Pydantic aplique el resto de validaciones (incluyendo min_length). Alternativamente, usa model_config = {"str_strip_whitespace": True} para todos los strings.
Ejercicio 6: Descripción en /docs (Fácil)
Agrega description y examples a dos campos de tu modelo Book. Levanta la API, abre /docs, y verifica que aparezcan en el schema del body.
Ver solución
class Book(BaseModel):
title: str = Field(
min_length=1,
description="Título del libro",
examples=["Cien Años de Soledad"]
)
year: int = Field(
ge=1000,
le=2030,
description="Año de publicación",
examples=[1967]
)
En /docs, expande el schema del POST /books y verás las descripciones y ejemplos junto a cada campo.
Resumen
- ✅
Field()agrega constraints: min_length, max_length, ge, le, gt, lt, pattern - ✅
@field_validator("campo")para validaciones personalizadas; debe retornar el valor - ✅
@model_validator(mode="after")para validar varios campos juntos - ✅
ValueErroren validadores → mensaje aparece en 422 - ✅
descriptionyexamplesen Field mejoran la documentación en /docs - ✅
model_config = {"str_strip_whitespace": True}para strip global de strings
Próxima cápsula: Modelos anidados y listas — Address dentro de User, listas de Items, estructuras complejas.
Recursos Adicionales
- Pydantic - Field - Documentación de Field
- Pydantic - Validators - field_validator y model_validator
- Pydantic - Validation Errors - Estructura de errores
- Pydantic - Migration (v1 to v2) - Migrar de v1 a v2
- FastAPI - Request Validation - Cómo FastAPI maneja ValidationError
- Pydantic - JSON Schema - Generación de esquemas para OpenAPI
Módulo 4, Cápsula 03 — FastAPI Fundamentals Guide