Pydantic v2: validar datos de entrada en Python

1. Descripción general

Los datos de peticiones HTTP, archivos JSON o message queues suelen tener tipos incorrectos, campos faltantes o valores sin sentido. Si validas con if/else disperso por todo el código, la lógica rápidamente se vuelve un desorden. Pydantic v2 te permite describir la “forma” de los datos usando type hints, y luego automáticamente parsea, convierte tipos y reporta errores detallados.

Este artículo recorre las herramientas principales de Pydantic v2: BaseModel, Field, field_validator, model_validator, ConfigDict, TypeAdapter. La última parte trata cómo escribir pruebas con pytest. Después de leerlo, podrás construir tus propios modelos que validen la entrada de forma segura para tu aplicación.

2. Instalación

Este artículo usa Python 3.11+ y Pydantic 2.x:

pip install "pydantic>=2.7" pytest

Si necesitas el tipo EmailStr, instala el extra:

pip install "pydantic[email]"

3. Modelos básicos con BaseModel

3.1. Declaración y conversión de tipos

Un model es una clase que hereda de BaseModel. Cada atributo con type hint es un field:

from pydantic import BaseModel

class Customer(BaseModel):
    id: int
    name: str
    email: str
    is_active: bool = True

customer = Customer(id="42", name="An", email="an@example.com")
print(customer)
print(customer.id, type(customer.id))
id=42 name='An' email='an@example.com' is_active=True
42 <class 'int'>

Pasas id="42" como una cadena, pero Pydantic automáticamente la convierte a int. Este es el lax mode, el modo por defecto: Pydantic convierte tipos si la transformación es razonable. El field is_active tiene un valor por defecto, así que no es obligatorio proporcionarlo.

3.2. Manejo de ValidationError

Cuando los datos son incorrectos, Pydantic lanza ValidationError y reúne todos los errores a la vez, sin detenerse en el primero:

from pydantic import ValidationError

try:
    Customer(id="abc", name="An")
except ValidationError as e:
    print(e.error_count())
    for err in e.errors():
        print(err["loc"], err["type"], err["msg"])
2
('id',) int_parsing Input should be a valid integer, unable to parse string as an integer
('email',) missing Field required

Cada error es un dict con loc (ubicación del field), type (código de error estable) y msg (mensaje para lectores). Al devolver errores al cliente, deberías confiar en loc y type. El contenido de msg puede cambiar entre versiones.

4. Restricciones de valores con Field y Annotated

Los type hints solo verifican tipos. Para restringir valores, usas Field:

from decimal import Decimal
from typing import Annotated
from pydantic import BaseModel, Field

Quantity = Annotated[int, Field(gt=0, le=100)]

class OrderItem(BaseModel):
    sku: str = Field(pattern=r"^[A-Z]{3}-\d{4}$")
    quantity: Quantity
    unit_price: Decimal = Field(gt=0, max_digits=10, decimal_places=2)

item = OrderItem(sku="ABC-1234", quantity=3, unit_price="19.90")
print(item)
sku='ABC-1234' quantity=3 unit_price=Decimal('19.90')

Explicación:

  • pattern requiere que sku coincida con una regex, por ejemplo ABC-1234.
  • Quantity es un tipo reutilizable gracias a Annotated. Cualquier field declarado como Quantity debe estar en el rango (0, 100].
  • unit_price usa Decimal para evitar errores de punto flotante. El valor debe ser positivo y tener como máximo 2 decimales.

Intenta pasar datos incorrectos:

try:
    OrderItem(sku="abc", quantity=0, unit_price="10")
except ValidationError as e:
    for err in e.errors():
        print(err["loc"], err["type"])
('sku',) string_pattern_mismatch
('quantity',) greater_than

Otras restricciones comúnmente usadas: min_length/max_length para strings y listas, ge/lt para números, multiple_of para múltiplos.

5. Validadores personalizados con field_validator

Cuando las restricciones son más complejas que regex o rangos de valores, escribes validadores personalizados:

from pydantic import BaseModel, Field, field_validator

class Contact(BaseModel):
    name: str
    phone: str = Field(pattern=r"^0\d{9}$")

    @field_validator("name")
    @classmethod
    def clean_name(cls, v: str) -> str:
        v = v.strip()
        if not v:
            raise ValueError("name no puede estar vacío")
        return v.title()

    @field_validator("phone", mode="before")
    @classmethod
    def normalize_phone(cls, v):
        if isinstance(v, str):
            v = v.replace(" ", "").replace(".", "")
            if v.startswith("+84"):
                v = "0" + v[3:]
        return v

contact = Contact(name="  nguyễn văn an ", phone="+84 912 345 678")
print(contact)
name='Nguyễn Văn An' phone='0912345678'

Hay dos modos que debes diferenciar:

  • mode="after" (por defecto), como clean_name: se ejecuta después de que Pydantic ha verificado el tipo, así que v es definitivamente str.
  • mode="before", como normalize_phone: se ejecuta antes de la verificación de tipos y pattern. v puede ser cualquier cosa, así que debes revisar isinstance. Aquí normalizamos +84 912 345 678 a 0912345678, luego se aplica el pattern.

El validador debe retornar un valor. El valor retornado es lo que se almacena en el field. Para reportar un error, lanzas ValueError o AssertionError. Pydantic lo envuelve en un error value_error con el mensaje "Value error, name no puede estar vacío".

Nota: El orden de los decoradores es muy importante. @field_validator debe estar encima de @classmethod. Si los inviertes, obtendrás un error confuso al definir la clase.

6. Validar múltiples fields a la vez con model_validator

Algunas reglas involucran múltiples fields, por ejemplo, la fecha de checkout debe ser posterior a la de check-in. Para esto necesitas model_validator:

from datetime import date
from typing import Self
from pydantic import BaseModel, ValidationError, model_validator

class Booking(BaseModel):
    room_id: int
    check_in: date
    check_out: date

    @model_validator(mode="after")
    def check_dates(self) -> Self:
        if self.check_out <= self.check_in:
            raise ValueError("check_out debe ser posterior a check_in")
        return self

try:
    Booking(room_id=101, check_in="2026-10-20", check_out="2026-10-18")
except ValidationError as e:
    err = e.errors()[0]
    print(err["loc"], err["msg"])
() Value error, check_out debe ser posterior a check_in

Con mode="after", el validador recibe una instancia que ya es válida field por field. Accedes a self.check_in como un atributo normal y debes return self. El error tiene loc vacío () porque pertenece al modelo completo, no a un field específico. La cadena "2026-10-20" también se parsea automáticamente a date.

7. Configurar modelos con ConfigDict

model_config controla el comportamiento general del modelo. Esta es una configuración común para datos sensibles como pagos:

from pydantic import BaseModel, ConfigDict, ValidationError

class PaymentRequest(BaseModel):
    model_config = ConfigDict(
        extra="forbid",
        strict=True,
        str_strip_whitespace=True,
        frozen=True,
    )

    order_id: int
    amount: int
    currency: str

try:
    PaymentRequest(order_id="1", amount=50000, currency="VND", coupon="FREE")
except ValidationError as e:
    for err in e.errors():
        print(err["loc"], err["type"])
('order_id',) int_type
('coupon',) extra_forbidden

Significado de cada opción:

  • extra="forbid": rechaza fields desconocidos como coupon. Por defecto es "ignore", es decir, Pydantic ignora silenciosamente fields adicionales. Esto puede ocultar errores de tipografía en los nombres de fields del cliente.
  • strict=True: desactiva la conversión de tipos. "1" ya no es aceptado para int.
  • str_strip_whitespace=True: automáticamente elimina espacios en blanco al inicio y final de cadenas.
  • frozen=True: la instancia es inmutable. Asignar payment.amount = 0 lanzará un error.

Si solo quieres strict para algunos fields, usas Field(strict=True) o tipos como StrictInt, StrictStr.

Otra opción digna de mención es validate_assignment=True. Por defecto, Pydantic no valida cuando reasignas un atributo después de crear el objeto. Activa esta opción para mantener el modelo siempre válido.

8. Validar desde dict, JSON y tipos arbitrarios

En la práctica, los datos suelen llegar como dict o cadenas JSON. Pydantic v2 proporciona dos métodos de clase:

raw_json = '{"id": 7, "name": "Bình", "email": "binh@example.com"}'
customer = Customer.model_validate_json(raw_json)

raw_dict = {"id": 8, "name": "Chi", "email": "chi@example.com", "is_active": False}
customer2 = Customer.model_validate(raw_dict)

print(customer.model_dump())
print(customer2.model_dump_json())
{'id': 7, 'name': 'Bình', 'email': 'binh@example.com', 'is_active': True}
{"id":8,"name":"Chi","email":"chi@example.com","is_active":false}

Si la entrada es una cadena JSON, debes usar model_validate_json en lugar de json.loads y luego model_validate. Esto es más rápido porque el núcleo en Rust de Pydantic parsea y valida en un solo paso. En la dirección opuesta, model_dump() retorna un dict, mientras que model_dump_json() retorna una cadena JSON.

Cuando necesitas validar un tipo que no es un modelo, por ejemplo list[OrderItem], usas TypeAdapter:

from pydantic import TypeAdapter

items_adapter = TypeAdapter(list[OrderItem])

items = items_adapter.validate_python([
    {"sku": "ABC-0001", "quantity": 2, "unit_price": "5.00"},
    {"sku": "XYZ-0002", "quantity": 1, "unit_price": "12.50"},
])
print(len(items), items[1].unit_price)
2 12.50

Deberías crear TypeAdapter una vez a nivel de módulo y reutilizarlo. La inicialización tiene un costo de construcción de schema, no debería repetirse en cada request.

9. Escribir pruebas con pytest

La validación es lógica de negocio, así que debe ser probada. Supongamos que los modelos anteriores están en el archivo models.py:

from decimal import Decimal

import pytest
from pydantic import ValidationError

from models import Booking, Contact, OrderItem


def test_order_item_coerces_valid_input():
    item = OrderItem(sku="ABC-1234", quantity="3", unit_price="19.90")

    assert item.quantity == 3
    assert item.unit_price == Decimal("19.90")


@pytest.mark.parametrize("quantity", [0, -1, 101])
def test_order_item_rejects_out_of_range_quantity(quantity):
    with pytest.raises(ValidationError) as exc_info:
        OrderItem(sku="ABC-1234", quantity=quantity, unit_price="10")

    error = exc_info.value.errors()[0]
    assert error["loc"] == ("quantity",)
    assert error["type"] in {"greater_than", "less_than_equal"}


def test_contact_normalizes_phone():
    contact = Contact(name="an", phone="+84 912.345.678")

    assert contact.phone == "0912345678"
    assert contact.name == "An"


def test_booking_rejects_checkout_before_checkin():
    with pytest.raises(ValidationError, match="check_out debe ser posterior a check_in"):
        Booking(room_id=1, check_in="2026-10-20", check_out="2026-10-20")

Ejecuta las pruebas:

pytest -q
.....                                                    [100%]
5 passed

Nota que las pruebas usan assert en loc y type, no comparan el msg literalmente. De esta forma, las pruebas no se rompen si Pydantic cambia el texto del mensaje en una versión nueva. parametrize te ayuda a cubrir ambos límites 0 y 101 con una sola función.

10. Errores comunes y cuándo no usar Pydantic

Usar la API de v1 por error. Muchos documentos antiguos todavía usan la sintaxis v1. Tabla de referencia rápida:

v1                         v2
@validator                 @field_validator
@root_validator            @model_validator
class Config: ...          model_config = ConfigDict(...)
.dict() / .json()          .model_dump() / .model_dump_json()
.parse_obj() / .parse_raw()  .model_validate() / .model_validate_json()

La API antigua aún funciona pero emite DeprecationWarning. Deberías migrar completamente a la API nueva.

Sorpresas con lax mode. El lax mode acepta "yes" como True y 1.0 como 1. Con datos financieros o IDs, este comportamiento podría no ser deseado. En ese caso, activa strict.

Olvidar return en el validador. Un field_validator sin return hará que el field reciba None. Un model_validator(mode="after") sin return self también causa error.

Saltarse la validación sin darte cuenta. model_construct() crea una instancia sin pasar por validación. Este método es solo para datos en los que confías absolutamente, por ejemplo cuando lees desde tu propia base de datos.

Nota: No deberías usar Pydantic para todos los objetos internos. En bucles calientes que procesan millones de objetos limpios, el costo de validación es innecesario. En esos casos, dataclass o NamedTuple son más apropiados. Coloca Pydantic en los límites de tu sistema: APIs, archivos de configuración, mensajes de otros sistemas.

11. Conclusión

Pydantic v2 transforma la validación de datos de entrada de imperativa a declarativa. Describes tipos con type hints, añades restricciones con Field y Annotated, escribes reglas personalizadas con field_validator y model_validator. Los comportamientos comunes como prohibir fields adicionales o desactivar conversión de tipos se controlan vía ConfigDict.

Tres puntos para recordar: usa model_validate_json para entrada JSON, activa extra="forbid" con datos críticos, y prueba la validación basándote en loc/type en lugar del contenido del mensaje. Aplicado correctamente, en los límites de tu sistema, Pydantic ayuda a que el núcleo de tu aplicación solo tenga que trabajar con datos limpios y de tipo correcto.

pythonpydanticvalidaciónpytest