Validación de datos
Contratos de datos en Python: del objeto al pipeline
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.
pydantices el estándar actual.attrs + cattrses la alternativa más estructural,marshmallowla 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.
panderaocupa 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_expectationses 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.
strictvs coerce. Las librerías modernas (pydantic v2, pandera) permiten elegir si convierten silenciosamente ("42"→42) o rechazan. En pipelines internos suele preferirsestrict. 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 quepandera. 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
jsonschemacomo fuente canónica (pydantic puede exportar a JSON Schema, pero la fuente de verdad estará en Python).
Conceptos clave
BaseModeles la clase base. Los campos se declaran con anotaciones de tipo estándar.- Modos
strictvs coerce. Por defecto, pydantic coacciona ("42"→42). Conmodel_config = ConfigDict(strict=True)oField(strict=True)exige el tipo exacto. - Validadores.
@field_validatorpara validar un campo.@model_validator(mode="after")para reglas que cruzan campos. El parámetromode="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_validatorasync, ú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 erroresTrampas habituales
- pydantic v1 vs v2. La API rompió en la transición:
validator→field_validator,parse_obj→model_validate,dict()→model_dump(),Config(clase interna) →model_config = ConfigDict(...). Snippets antiguos compilan sin advertir y dan errores sutiles. Fija la versión enpyproject.toml. coerceaccidental en hot paths. Si recibes payloads JSON y todo entra como string, pydantic coacciona silenciosamente, barato individualmente, caro en bucles de millones. Activastrict=Truey 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 siendoField(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 unPartialModelexplícito o usamodel_copy(update=...). Mezclar ambos enfoques produce silencios donde un campo “ausente” se confunde conNone. - Integración con mypy. El plugin
pydantic.mypy(incluido) es necesario para que el type checker entienda los modelos. Sin él,mypy --strictlanza falsos positivos sobre los constructores generados. - Rendimiento en bucles.
model_validatereusa el schema compilado, pero crear modelos uno a uno en un bucle de millones sigue siendo costoso. Para validar listas, usaTypeAdapter(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_outputen 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_matchescubren 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,validateaborta al primer error. Conlazy=Truerecorre todo el DataFrame y devuelve unSchemaErrorsagregado. 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 dfTrampas habituales
coerce=Trueenmascara bugs. Si ponescoerce=Truey la columnavolume_ulllega como string, pandera la convierte. Pierdes la señal de que el productor está enviando algo distinto a lo acordado. En contratos entre equipos, prefierecoerce=Falsey trata la coerción como tarea explícita del cargador.lazy=Falsepor defecto. Si validas en CI o en notebook, casi siempre quieres ver todos los problemas. Recuerda activarlazy=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.DataFrameModelvsDataFrameSchema. 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
DataFrameModelsolo valida tipos básicos a nivel de columna. No aplica losfield_validatordel 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
Expectationcon 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óricoTrampas 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 (
BatchRequestantiguo,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
EphemeralDataContexto 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
Schemaheredados y post-load hooks personalizados. - Integración con
apispecpara 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
Schemaclase 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_schemapara reglas cruzadas y transformaciones. partial=Trueenloadpara validación parcial (típico enPATCH).- Sin integración nativa con type hints del nivel de pydantic, los
Schemase 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ícitamenteRAISE,INCLUDEsilencioso es una fuente clásica de bugs.- Validación parcial.
partial=Trueexime camposrequired, pero también losvalidateque 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 amodel_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 congojsonschema). - 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$schemapara 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 unRefResolverapuntando a un directorio o registro de schemas, no inlines manuales. format: validadores opcionales paraemail,uri,date-time, etc., requieren instalar extras (jsonschema[format]) para que se apliquen realmente. Sin ellos,formates 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
additionalPropertiespor defecto estrue. Si quieres rechazar campos extra, declara"additionalProperties": false. Es el equivalente aextra="forbid"en pydantic y la fuente número uno de bugs silenciosos.formatsin extras."format": "email"no valida nada si no instalasjsonschema[format]y los validadores de formato adicionales. La documentación lo señala, pero es fácil pasarlo por alto.exclusiveMinimumcambió 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.
ValidationErrores informativo pero su mensaje crudo no siempre es presentable a usuarios finales. En APIs, normaliza con un mapper propio. - Performance. Compilar el
Validatoruna vez y reusarlo entre validaciones. No llamesjsonschema.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 conmodel_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,
attrses importación más ligera que pydantic v2. - Codebases que prefieren la composición sobre la herencia:
attrsintegra mejor con__slots__y patrones funcionales. - Parsing de estructuras complejas (uniones de tipos algebraicos, tagged unions) donde
cattrsofrece 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.
attrsofrece 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 losattrs.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.
attrsaborta al primer validador que falla. Para reportar todos los problemas, hay que envolver con un runner propio o usarcattrscon sutransform_erroryIterableValidationError. converterantes quevalidator. 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), nuncaattrs.field(default=[]).attrslo detecta y lanza si pasas el segundo. @attrs.definees 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.cattrsy forward refs. Resolver tipos declarados como cadena (forward references,from __future__ import annotations) requiere pasar elcattrs.Converterlocalmente o llamarattrs.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.