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

ConstraintAplica aSignificadoEjemplo
min_lengthstr, listLongitud mínimaField(min_length=1) → no vacío
max_lengthstr, listLongitud máximaField(max_length=200)
geint, floatGreater or equal (≥)Field(ge=0)
gtint, floatGreater than (>)Field(gt=0) → estrictamente positivo
leint, floatLess or equal (≤)Field(le=2030)
ltint, floatLess than (<)Field(lt=100)
patternstrRegexField(pattern=r"^\d{3}-\d+$")
defaultTodosValor por defectoField(default=True)
descriptionTodosAparece en /docsDocumentación en Swagger
examplesTodosEjemplos para /docsPre-llena el Try it out

ge vs gt, le vs lt

  • ge (greater or equal): el valor puede ser igual al límite
  • gt (greater than): el valor debe ser estrictamente mayor
  • le (less or equal): el valor puede ser igual al límite
  • lt (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 ValueError con 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 declarado
  • mode="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" a 8080).

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:

v1v2
@validator("campo")@field_validator("campo")
@root_validator@model_validator
values en validatorAcceso 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
  • ValueError en validadores → mensaje aparece en 422
  • description y examples en 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

  1. Pydantic - Field - Documentación de Field
  2. Pydantic - Validators - field_validator y model_validator
  3. Pydantic - Validation Errors - Estructura de errores
  4. Pydantic - Migration (v1 to v2) - Migrar de v1 a v2
  5. FastAPI - Request Validation - Cómo FastAPI maneja ValidationError
  6. Pydantic - JSON Schema - Generación de esquemas para OpenAPI

Módulo 4, Cápsula 03 — FastAPI Fundamentals Guide