Validación de datos

Contratos de datos en Python: del objeto al pipeline

python
data-validation
pydantic
pandera
great-expectations
jsonschema
attrs
Referencia comentada de las librerías que estructuran la validación de datos en Python: contratos a nivel de objeto (pydantic), de DataFrame (pandera) y de pipeline en producción (great_expectations).

Sobre validación de datos en Python

La validación de datos en Python rara vez se resuelve con una sola librería: el problema cambia de forma según dónde lo ataques. Conviene pensarlo en tres planos jerárquicos, y elegir la herramienta adecuada para cada uno:

  • Plano del objeto (un registro, un payload de API, un mensaje de cola). Aquí el contrato describe campos, tipos y reglas semánticas sobre una entidad concreta. pydantic es el estándar actual. attrs + cattrs es la alternativa más estructural, marshmallow la opción histórica.
  • Plano del DataFrame (una tabla cargada en memoria, típicamente pandas/polars/PySpark). El contrato describe columnas, dtypes, rangos y dependencias entre columnas. pandera ocupa este nicho, pydantic no funciona naturalmente sobre tablas, y validar fila a fila con él es órdenes de magnitud más lento.
  • Plano del pipeline (un proceso recurrente que aterriza datos, con históricos, drift, alertas y documentación que sobrevive al ingeniero que lo escribió). great_expectations es la opción dominante: aporta versionado de expectativas, data docs y checkpoints, no solo validación puntual.

Tres principios transversales que conviene tener interiorizados:

  • Validación en el borde, no en cada función. Valida una vez al cruzar la frontera del sistema (API, ingesta, lectura de fichero) y trabaja con tipos confiables hacia dentro. Validar repetidamente es coste sin beneficio.
  • strict vs coerce. Las librerías modernas (pydantic v2, pandera) permiten elegir si convierten silenciosamente ("42"42) o rechazan. En pipelines internos suele preferirse strict. En APIs públicas con clientes diversos, coerce moderado.
  • Errores agregados, no fail-fast. Una buena librería de validación devuelve todos los errores de un payload o DataFrame en un solo paso, no aborta al primero. Esto cambia radicalmente la experiencia de depuración.

Esta página cataloga seis librerías que cubren los tres planos. El orden refleja la jerarquía conceptual: primero el objeto (pydantic, attrs+cattrs, marshmallow, jsonschema), después el DataFrame (pandera) y finalmente el pipeline (great_expectations).


pydantic

pydantic es el estándar de facto para validación de objetos en Python moderno. Define modelos como clases con anotaciones de tipo. La librería genera el parser, el validador y el serializador a partir de la firma. La versión 2 (octubre 2023) reescribió el núcleo en Rust (pydantic-core), multiplicando el rendimiento entre 5 y 50 veces frente a v1.

Es la pieza sobre la que se construyen FastAPI, gran parte del ecosistema de LLM agents (Instructor, LangChain estructurado), y un volumen creciente de APIs internas. Aprender bien pydantic v2 rinde mucho más que aprender cualquier otra librería de validación de Python.

Cuándo usarlo

  • Contratos de API (request/response models en FastAPI, Litestar, esquemas de eventos).
  • Configuración tipada con pydantic-settings (carga desde env vars, ficheros, secret managers).
  • Parsing de payloads JSON complejos con anidamiento, uniones discriminadas y validación cruzada entre campos.
  • Modelos de dominio internos que deben sobrevivir refactors con la ayuda del type checker.

Cuándo NO usarlo

  • DataFrames. Iterar Model.model_validate(row) sobre 10⁶ filas es órdenes de magnitud más lento que pandera. Si el contrato es columnar, usa pandera.
  • Pipelines en producción que necesitan histórico de calidad. pydantic valida puntualmente. No almacena resultados, no genera data docs, no monitoriza drift. Para eso, great_expectations.
  • Portabilidad cross-language. Si el mismo esquema lo deben consumir servicios en Go, Java o frontend TypeScript, prefiere jsonschema como fuente canónica (pydantic puede exportar a JSON Schema, pero la fuente de verdad estará en Python).

Conceptos clave

  • BaseModel es la clase base. Los campos se declaran con anotaciones de tipo estándar.
  • Modos strict vs coerce. Por defecto, pydantic coacciona ("42"42). Con model_config = ConfigDict(strict=True) o Field(strict=True) exige el tipo exacto.
  • Validadores. @field_validator para validar un campo. @model_validator(mode="after") para reglas que cruzan campos. El parámetro mode="before" permite preprocesar el input crudo antes del parsing.
  • Uniones discriminadas (Field(discriminator="type")) para parsing eficiente y mensajes de error claros sobre subtipos.
  • Errores agregados. ValidationError.errors() devuelve la lista completa de problemas (campo, código, mensaje), no solo el primero, clave para devolver respuestas útiles desde una API.
  • Async validators. v2 soporta @field_validator async, útil cuando la validación implica una llamada externa (existencia de un recurso, verificación contra DB).

Patrón mínimo

from datetime import datetime
from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field, field_validator, ValidationError


class Sample(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid")

    sample_id: Annotated[str, Field(pattern=r"^S\d{6}$")]
    collected_at: datetime
    volume_ul: Annotated[float, Field(gt=0, le=5000)]
    barcodes: list[str]

    @field_validator("barcodes")
    @classmethod
    def _unique_barcodes(cls, v: list[str]) -> list[str]:
        if len(v) != len(set(v)):
            raise ValueError("barcodes must be unique")
        return v


try:
    sample = Sample.model_validate({
        "sample_id": "S000123",
        "collected_at": "2026-04-01T09:00:00",
        "volume_ul": 250.0,
        "barcodes": ["BC01", "BC02"],
    })
except ValidationError as e:
    print(e.errors())  # lista agregada de errores

Trampas habituales

  • pydantic v1 vs v2. La API rompió en la transición: validatorfield_validator, parse_objmodel_validate, dict()model_dump(), Config (clase interna) → model_config = ConfigDict(...). Snippets antiguos compilan sin advertir y dan errores sutiles. Fija la versión en pyproject.toml.
  • coerce accidental en hot paths. Si recibes payloads JSON y todo entra como string, pydantic coacciona silenciosamente, barato individualmente, caro en bucles de millones. Activa strict=True y deja que el llamante envíe tipos correctos.
  • Mutables como default. field: list[str] = [] se comparte entre instancias en clases normales. En pydantic está controlado, pero el patrón correcto sigue siendo Field(default_factory=list) para evitar sorpresas en serialización.
  • Validación parcial. Para actualizar parcialmente un modelo (típico en PATCH), no uses el modelo original con campos opcionales, crea un PartialModel explícito o usa model_copy(update=...). Mezclar ambos enfoques produce silencios donde un campo “ausente” se confunde con None.
  • Integración con mypy. El plugin pydantic.mypy (incluido) es necesario para que el type checker entienda los modelos. Sin él, mypy --strict lanza falsos positivos sobre los constructores generados.
  • Rendimiento en bucles. model_validate reusa el schema compilado, pero crear modelos uno a uno en un bucle de millones sigue siendo costoso. Para validar listas, usa TypeAdapter(list[Model]).validate_python(items), recorre el lote con una sola entrada al núcleo Rust.

Enlaces

Relacionados en esta página

  • attrs + cattrs, alternativa más estructural, sin coerción mágica.
  • marshmallow, predecesor histórico, todavía vivo en proyectos Flask.
  • jsonschema, capa portable cuando el esquema vive fuera de Python.

pandera

pandera es pydantic para DataFrames: declara el esquema de una tabla, columnas, dtypes, checks por columna y a nivel de DataFrame, y valida un pandas.DataFrame, polars.DataFrame, o pyspark.sql.DataFrame contra él. La filosofía es la misma (contratos como código, errores agregados, integración con type checkers), pero el objetivo es tabular, no record-a-record.

Desarrollado por Niels Bantilan y mantenido activamente. La versión moderna ofrece dos APIs equivalentes: la estilo schema-as-data (DataFrameSchema(...)) y la estilo schema-as-class (pa.DataFrameModel, casi idéntica a pydantic). En proyectos serios, la API de clase suele ganar por legibilidad y reutilización.

Cuándo usarlo

  • Validación de inputs/outputs en pipelines de pandas o polars. Decorador @pa.check_input / @pa.check_output en funciones que transforman tablas.
  • Contratos entre etapas de un pipeline (bronze → silver → gold, medallion o similar). Cada DataFrame que cruza una frontera lleva su esquema.
  • Validación dentro de un cuaderno de análisis cuando quieres asegurarte de que la tabla que cargas tiene la forma que esperas, antes de gastar horas en una visualización equivocada.
  • PySpark a escala, vía la integración nativa (con caveats, ver trampas).

Cuándo NO usarlo

  • Validación record-a-record fuera de un contexto tabular (API, mensajes de cola). Ahí pydantic gana, pandera está optimizada para validar columnas enteras, no objetos sueltos.
  • Monitorización de calidad histórica y comparación entre runs. great_expectations almacena resultados. Pandera valida y aborta (o reporta), pero no construye histórico.

Conceptos clave

  • Column(dtype, checks=..., nullable=..., unique=...): definición por columna. Check.in_range, Check.isin, Check.str_matches cubren la mayoría de casos. Check(lambda s: ...) para reglas ad hoc.
  • Checks a nivel de DataFrame (pa.Check, aplicados al schema completo) para reglas que cruzan columnas, p. ej. start_date < end_date.
  • Modo lazy=True. Por defecto, validate aborta al primer error. Con lazy=True recorre todo el DataFrame y devuelve un SchemaErrors agregado. En depuración, casi siempre lo querrás activado.
  • Hipótesis estadísticas (Hypothesis): tests inferenciales como parte del schema (medias entre grupos, normalidad). Útil pero menos usado que los checks deterministas.
  • Integración con pydantic. Los esquemas de pandera pueden incluir modelos pydantic anidados, útil cuando una columna almacena diccionarios o estructuras.

Patrón mínimo

import pandas as pd
import pandera.pandas as pa
from pandera.typing import Series


class SampleSchema(pa.DataFrameModel):
    sample_id: Series[str] = pa.Field(str_matches=r"^S\d{6}$", unique=True)
    plate_id: Series[str] = pa.Field(isin=["P01", "P02", "P03"])
    volume_ul: Series[float] = pa.Field(gt=0, le=5000)
    qc_passed: Series[bool]

    class Config:
        strict = True            # no se admiten columnas extra
        coerce = False           # no convertir dtypes silenciosamente


df = pd.DataFrame({
    "sample_id": ["S000001", "S000002"],
    "plate_id":  ["P01", "P02"],
    "volume_ul": [250.0, 400.0],
    "qc_passed": [True, True],
})

# lazy=True: agrega todos los errores en lugar de abortar al primero
SampleSchema.validate(df, lazy=True)


# Decorador en una función del pipeline
@pa.check_input(SampleSchema)
@pa.check_output(SampleSchema)
def clip_volumes(df: pd.DataFrame) -> pd.DataFrame:
    df = df.copy()
    df["volume_ul"] = df["volume_ul"].clip(upper=4000)
    return df

Trampas habituales

  • coerce=True enmascara bugs. Si pones coerce=True y la columna volume_ul llega como string, pandera la convierte. Pierdes la señal de que el productor está enviando algo distinto a lo acordado. En contratos entre equipos, prefiere coerce=False y trata la coerción como tarea explícita del cargador.
  • lazy=False por defecto. Si validas en CI o en notebook, casi siempre quieres ver todos los problemas. Recuerda activar lazy=True.
  • PySpark, overhead. La integración funciona, pero validar un DataFrame distribuido implica collect() parcial o pasadas adicionales. En tablas muy grandes, considera muestreo o delega a great_expectations + Spark.
  • pa.DataFrameModel vs DataFrameSchema. Conviven dos APIs. Para una codebase nueva, elige una sola, mezclarlas hace que las reglas de validación se busquen en dos sitios distintos.
  • Pandera no es pydantic. Reutilizar tipos pydantic dentro de un DataFrameModel solo valida tipos básicos a nivel de columna. No aplica los field_validator del modelo pydantic original. Si necesitas reglas semánticas complejas, declara los checks en el schema de pandera.

Enlaces

Relacionados en esta página

  • pydantic, mismo paradigma, plano del objeto en lugar del DataFrame.
  • great_expectations, mismo problema en el plano del pipeline en producción.

great_expectations

great_expectations (GX) es el framework para calidad de datos en pipelines de producción. No es solo un validador: organiza expectativas (reglas), datasources (cómo se conecta a la tabla), checkpoints (ejecuciones recurrentes) y data docs (documentación HTML autogenerada con histórico de validaciones). La unidad de trabajo no es “validar este DataFrame ahora” sino “monitorizar este asset a lo largo del tiempo”.

Maduro y muy extendido en data engineering. La versión 1.0 (2024) rediseñó la API alrededor del concepto de Fluent Datasource, eliminando la V2 configuration-driven API, más verbosa. Snippets pre-1.0 abundan y casi siempre necesitan reescritura.

Cuándo usarlo

  • Pipelines de datos en producción (Airflow, Dagster, Prefect, dbt) con frecuencia diaria/horaria y requisito de monitorización.
  • Acuerdos de calidad entre equipos (productor de datos ↔︎ consumidor), las expectativas son el contrato versionado.
  • Detección de drift y degradación progresiva, al guardar histórico, se ven tendencias que un test puntual no captura.
  • Data lakes con múltiples consumidores que necesitan una fuente única de verdad sobre el estado de cada asset.

Cuándo NO usarlo

  • Validación puntual dentro de un script o cuaderno. GX es pesado de configurar para una sola pasada, ahí pandera o pydantic son una décima parte de código.
  • APIs y mensajes en tiempo real. El modelo de checkpoints está pensado para batch / micro-batch, no para validar una petición HTTP individual. Para eso, pydantic.
  • Equipos pequeños sin pipeline recurrente. El coste de configurar datasources, suites y checkpoints solo se amortiza con uso continuado.

Conceptos clave

  • Expectation Suite: colección de expectativas (expect_column_values_to_not_be_null, expect_column_values_to_be_in_set, etc.) sobre un asset. Versionable, exportable a JSON.
  • Datasource → Data Asset → Batch. El datasource describe la conexión (Pandas, Spark, SQL). El data asset describe el contenedor lógico (tabla, fichero, query). El batch es la instancia validada en un momento concreto.
  • Checkpoint: ejecución que ata una suite a un batch y produce un validation result. Es el objeto que invocas desde Airflow/Dagster.
  • Data Docs: documentación HTML autogenerada (expectativas, resultados, histórico). Suele alojarse en S3/GCS para consumo del equipo.
  • Plugin system. GX admite custom expectations (subclase de Expectation con la lógica de validación). Útil cuando la regla es específica del dominio y se reusa entre proyectos.
  • Validador interactivo. El flujo recomendado en 1.0+ es construir la suite interactivamente con un Validator sobre un batch real, antes de promoverla a checkpoint en producción.

Patrón mínimo

import pandas as pd
import great_expectations as gx

# Contexto efímero — en producción se persiste en filesystem o cloud store
context = gx.get_context()

# 1. Datasource y asset (Fluent API, GX 1.0+)
datasource = context.data_sources.add_pandas(name="samples_ds")
asset = datasource.add_dataframe_asset(name="samples")

df = pd.DataFrame({
    "sample_id": ["S000001", "S000002", "S000003"],
    "volume_ul": [250.0, 400.0, -10.0],
    "qc_passed": [True, True, False],
})

batch_def = asset.add_batch_definition_whole_dataframe("samples_batch")
batch = batch_def.get_batch(batch_parameters={"dataframe": df})

# 2. Expectation suite
suite = context.suites.add(gx.ExpectationSuite(name="samples_suite"))
suite.add_expectation(
    gx.expectations.ExpectColumnValuesToNotBeNull(column="sample_id")
)
suite.add_expectation(
    gx.expectations.ExpectColumnValuesToBeBetween(
        column="volume_ul", min_value=0, max_value=5000
    )
)

# 3. Validación + data docs
result = batch.validate(suite)
print(result.success)               # False → volume_ul tiene un -10.0
context.build_data_docs()           # genera HTML con histórico

Trampas habituales

  • APIs incompatibles 0.x ↔︎ 1.x. La V2 configuration-driven (YAML) y la V3 inicial son ahora legacy. Snippets de blogs antiguos no compilan en 1.0+ y dependen de clases (BatchRequest antiguo, RuntimeBatchRequest) eliminadas o renombradas. Fija la versión y referencia siempre la documentación oficial actual.
  • Sobreingeniería en proyectos pequeños. El coste de mantener suites, checkpoints y data docs solo se amortiza con pipelines recurrentes y múltiples consumidores. Para un script semanal, pandera es desproporcionadamente más simple.
  • Configuración duplicada con el orquestador. Si Airflow ya conoce los datasets y conexiones, declararlos otra vez en GX duplica la fuente de verdad. Resolver con EphemeralDataContext o pasando conexiones dinámicamente.
  • Expectativas auto-generadas (Onboarding/Profiler). Sirven como punto de partida, no como contrato final. Tienden a ser demasiado laxas (rangos enormes) o demasiado estrictas (basadas en una sola muestra). Revisar siempre antes de promover a producción.
  • Custom expectations frágiles. El plugin system es potente pero la API ha cambiado entre versiones. Documentar la versión exacta de GX contra la que se desarrolló cada custom expectation.

Enlaces

Relacionados en esta página

  • pandera, mismo problema en el plano del DataFrame, sin histórico ni data docs.
  • pydantic, plano del objeto. Complementario, no alternativo.

marshmallow

marshmallow es la librería histórica de serialización + validación de objetos en Python. Fue durante años el estándar en el ecosistema Flask (vía flask-marshmallow, marshmallow-sqlalchemy, apispec) y sigue presente en multitud de codebases consolidadas. Su diseño separa explícitamente schema (definición), load (validación + deserialización) y dump (serialización), lo que en su momento marcó la diferencia frente a alternativas más mágicas.

En 2026, pydantic v2 cubre el mismo espacio con mejor rendimiento, mejor integración con type hints y mypy, y una API más concisa. marshmallow sigue vivo y mantenido (versión 3.x estable) pero su uso en proyectos nuevos es residual.

Cuándo usarlo

  • Mantenimiento de codebases existentes basadas en Flask + marshmallow + SQLAlchemy. La transición a pydantic v2 es costosa en proyectos con muchos Schema heredados y post-load hooks personalizados.
  • Integración con apispec para generar OpenAPI desde schemas marshmallow cuando todo el resto del proyecto ya está sobre esa base.

Cuándo NO usarlo

  • Proyectos nuevos. Prefiere pydantic v2. Mejor rendimiento (~10-50× en hot paths), mejor experiencia con type checkers, mejor integración con FastAPI/Litestar, mejor documentación.
  • Validación tabular. Igual que pydantic, marshmallow está pensado para objetos, no para DataFrames.

Conceptos clave

  • Schema clase base. Campos como atributos de clase (fields.String, fields.Integer, fields.Nested).
  • schema.load(payload) valida y deserializa (puede devolver dict o, con @post_load, una dataclass/objeto de dominio). schema.dump(obj) hace el camino inverso.
  • Hooks: @pre_load, @post_load, @validates_schema para reglas cruzadas y transformaciones.
  • partial=True en load para validación parcial (típico en PATCH).
  • Sin integración nativa con type hints del nivel de pydantic, los Schema se declaran como clase, no se infieren de anotaciones.

Patrón mínimo

from marshmallow import Schema, fields, validate, validates_schema, ValidationError


class SampleSchema(Schema):
    sample_id    = fields.String(required=True, validate=validate.Regexp(r"^S\d{6}$"))
    volume_ul    = fields.Float(required=True, validate=validate.Range(min=0, max=5000))
    barcodes     = fields.List(fields.String(), required=True)

    @validates_schema
    def _unique_barcodes(self, data, **kwargs):
        bcs = data.get("barcodes", [])
        if len(bcs) != len(set(bcs)):
            raise ValidationError("barcodes must be unique", field_name="barcodes")


schema = SampleSchema()
try:
    data = schema.load({
        "sample_id": "S000123",
        "volume_ul": 250.0,
        "barcodes": ["BC01", "BC02"],
    })
except ValidationError as err:
    print(err.messages)

Trampas habituales

  • Meta.unknown. Por defecto, marshmallow ignora campos no declarados en el schema (unknown = RAISE | EXCLUDE | INCLUDE). En contratos estrictos, fija explícitamente RAISE, INCLUDE silencioso es una fuente clásica de bugs.
  • Validación parcial. partial=True exime campos required, pero también los validate que dependen de campos ausentes. Para patch con reglas cruzadas, declara explícitamente qué validaciones se aplican.
  • Migración a pydantic. El mapeo no es 1-a-1: @post_load (crea el objeto de dominio) corresponde a model_validator(mode="after") o a un factory explícito. Subestimar el coste de migración es común.
  • Falta de integración con mypy/pyright. No hay un plugin equivalente al de pydantic, los IDEs no infieren la firma de los objetos devueltos por Schema.load. En proyectos grandes esto es un coste real.

Enlaces

Relacionados en esta página

  • pydantic, sucesor de facto. Preferible en código nuevo.

jsonschema

jsonschema es la implementación de referencia del estándar JSON Schema en Python. A diferencia de pydantic o marshmallow, no genera modelos: valida un dict (típicamente leído de JSON) contra un schema declarado también como dict, el mismo schema que consume cualquier validador en JavaScript, Go, Java o el propio OpenAPI.

Su valor está justamente en esa portabilidad cross-language: cuando el contrato debe ser autoritativo a través de servicios escritos en lenguajes distintos, JSON Schema es la única opción universalmente aceptada.

Cuándo usarlo

  • Schemas compartidos entre Python y otros lenguajes (frontend TypeScript con ajv, microservicios en Go con gojsonschema).
  • Validación de configuración declarada en YAML/JSON, donde el schema vive en disco como artefacto canónico.
  • Validar payloads OpenAPI/AsyncAPI sin generar modelos, útil en gateways y validadores de eventos.
  • Storage de schemas en schema registry (Confluent, Apicurio), JSON Schema es uno de los formatos soportados junto a Avro y Protobuf.

Cuándo NO usarlo

  • Trabajo dentro de Python como única plataforma. Pierdes type hints, autocompletado, integración con mypy y mejor rendimiento. Para uso interno, pydantic siempre.
  • Validación de DataFrames. No es su problema. pandera.
  • Reglas semánticas complejas (cross-field, condicionales dependientes). JSON Schema las soporta (if/then/else, allOf, oneOf) pero la legibilidad cae rápido. En esos casos, pydantic.

Conceptos clave

  • Borradores (draft-07, 2019-09, 2020-12). La librería soporta todos los modernos. El schema debe declarar $schema para que se use el validador correcto. Mezclar borradores es fuente de errores sutiles.
  • jsonschema.validate(instance, schema) para validación puntual. Draft202012Validator(schema).iter_errors(instance) para errores agregados.
  • Referencias $ref. Permiten reusar subschemas. En proyectos serios, usa un RefResolver apuntando a un directorio o registro de schemas, no inlines manuales.
  • format: validadores opcionales para email, uri, date-time, etc., requieren instalar extras (jsonschema[format]) para que se apliquen realmente. Sin ellos, format es solo documental.
  • Generación de schemas desde código. pydantic puede emitir JSON Schema (Model.model_json_schema()), patrón útil cuando se quiere lo mejor de ambos: API Python ergonómica + contrato portable hacia fuera.

Patrón mínimo

from jsonschema import Draft202012Validator, ValidationError

schema = {
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "type": "object",
    "additionalProperties": False,
    "required": ["sample_id", "volume_ul"],
    "properties": {
        "sample_id": {"type": "string", "pattern": r"^S\d{6}$"},
        "volume_ul": {"type": "number", "exclusiveMinimum": 0, "maximum": 5000},
        "barcodes":  {"type": "array", "items": {"type": "string"}, "uniqueItems": True},
    },
}

validator = Draft202012Validator(schema)
errors = sorted(validator.iter_errors({"sample_id": "S1", "volume_ul": -5}),
                key=lambda e: e.path)

for err in errors:
    print(f"{list(err.path)}: {err.message}")

Trampas habituales

  • additionalProperties por defecto es true. Si quieres rechazar campos extra, declara "additionalProperties": false. Es el equivalente a extra="forbid" en pydantic y la fuente número uno de bugs silenciosos.
  • format sin extras. "format": "email" no valida nada si no instalas jsonschema[format] y los validadores de formato adicionales. La documentación lo señala, pero es fácil pasarlo por alto.
  • exclusiveMinimum cambió de borrador. En draft-04 era booleano ({"minimum": 0, "exclusiveMinimum": true}). En draft-06 en adelante es número ({"exclusiveMinimum": 0}). Mezclar borradores produce errores opacos.
  • Mensajes de error verbosos. ValidationError es informativo pero su mensaje crudo no siempre es presentable a usuarios finales. En APIs, normaliza con un mapper propio.
  • Performance. Compilar el Validator una vez y reusarlo entre validaciones. No llames jsonschema.validate(...) (función helper) en un bucle, recompila el schema cada vez.

Enlaces

Relacionados en esta página

  • pydantic, alternativa interna. Puede exportar a JSON Schema con model_json_schema().

attrs + cattrs

attrs define clases con campos declarativos (atributos, validadores, defaults, converters) sin la magia de pydantic. Su filosofía es minimalista: dar a Python lo que Java o Scala ofrecen vía records/case classes, sin imponer un paradigma de validación. La parte de parsing desde tipos arbitrarios (dict → instancia, instancia → dict) la cubre cattrs, librería complementaria que descompone la estructura siguiendo las anotaciones de tipo.

El combo attrs + cattrs es la opción preferida por equipos que desconfían de la coerción mágica de pydantic y quieren control explícito sobre cuándo y cómo se convierten los tipos. También es notablemente más rápido que pydantic v1 en parsing puro (cattrs en unstructure_strategy=AS_DICT), aunque pydantic v2 ya empata o supera.

Cabe mencionar que dataclasses del stdlib cubre el caso más simple, pero no tiene validación. attrs es la opción cuando necesitas validadores y converters sin saltar a pydantic.

Cuándo usarlo

  • Modelos de dominio internos donde no quieres coerción silenciosa ni dependencias pesadas (pydantic-core es ~1MB compilado).
  • Aplicaciones de larga duración (CLIs, servicios de fondo) que valoran arranque rápido, attrs es importación más ligera que pydantic v2.
  • Codebases que prefieren la composición sobre la herencia: attrs integra mejor con __slots__ y patrones funcionales.
  • Parsing de estructuras complejas (uniones de tipos algebraicos, tagged unions) donde cattrs ofrece converters registrables explícitamente.

Cuándo NO usarlo

  • APIs HTTP con FastAPI/Litestar. El ecosistema asume pydantic. Usar attrs implica adaptadores y pierdes la generación automática de OpenAPI.
  • Validación tabular o de pipeline. Mismo argumento que con pydantic, usa pandera o great_expectations.
  • Necesitas validación rica out-of-the-box. attrs ofrece validadores básicos (attrs.validators.instance_of, in_, matches_re). Para reglas complejas, escribes la función. En pydantic ya están las primitivas listas.

Conceptos clave

  • @attrs.define (estilo moderno, slotted por defecto) genera __init__, __repr__, __eq__, etc., a partir de los attrs.field(...).
  • Validadores y converters: attrs.field(validator=..., converter=...). El converter corre antes que el validator. Ambos son llamables Python normales.
  • cattrs.structure(data, Type) convierte un dict (o estructura genérica) en una instancia del tipo. cattrs.unstructure(obj) hace el camino inverso.
  • Registro de hooks (cattrs.register_structure_hook) para tipos personalizados, la flexibilidad cattrs vive aquí.
  • No hay JSON Schema generation nativo (existen librerías de terceros como cattrs-jsonschema, menos maduras).

Patrón mínimo

import re
from typing import Sequence
import attrs
import cattrs


def _valid_sample_id(instance, attribute, value):
    if not re.match(r"^S\d{6}$", value):
        raise ValueError(f"invalid sample_id: {value!r}")


@attrs.define
class Sample:
    sample_id: str = attrs.field(validator=_valid_sample_id)
    volume_ul: float = attrs.field(
        validator=[attrs.validators.gt(0), attrs.validators.le(5000)]
    )
    barcodes: tuple[str, ...] = attrs.field(converter=tuple)

    @barcodes.validator
    def _unique(self, attribute, value):
        if len(value) != len(set(value)):
            raise ValueError("barcodes must be unique")


converter = cattrs.Converter()

sample = converter.structure(
    {"sample_id": "S000123", "volume_ul": 250.0, "barcodes": ["BC01", "BC02"]},
    Sample,
)
print(converter.unstructure(sample))

Trampas habituales

  • Sin agregación de errores por defecto. attrs aborta al primer validador que falla. Para reportar todos los problemas, hay que envolver con un runner propio o usar cattrs con su transform_error y IterableValidationError.
  • converter antes que validator. Esto a veces sorprende: si el converter normaliza, el validator recibe el valor ya transformado. Documenta este orden cuando combines ambos sobre el mismo campo.
  • Mutables como default. Igual que en clases normales, usa attrs.field(factory=list), nunca attrs.field(default=[]). attrs lo detecta y lanza si pasas el segundo.
  • @attrs.define es slotted por defecto. Esto rompe el monkey-patching y la herencia múltiple naïve. Si necesitas un comportamiento más permisivo, usa @attrs.define(slots=False) o el legacy @attr.s.
  • cattrs y forward refs. Resolver tipos declarados como cadena (forward references, from __future__ import annotations) requiere pasar el cattrs.Converter localmente o llamar attrs.resolve_types(...). Olvidarlo da errores opacos en runtime.

Enlaces

Relacionados en esta página

  • pydantic, alternativa con más batteries included a cambio de magia y peso.
  • marshmallow, comparte filosofía explícita (load/dump separados), pero peor ergonomía moderna.