lección 6
Contratos de datos: definir qué esperas ANTES de que lleguen
El concepto de contrato entre productor y consumidor de datos. Schemas, versioning, breaking changes y cómo evitar el caos.
⏱ 55 min
### El problema: "alguien cambió el campo y no me avisó"
Viernes, 17:45. Tu pipeline de facturación falla. Investigas y descubres que el equipo de backend cambió el campo "total_amount" a "amount_total" en la API. Sin avisar. Sin documentar. Sin versionar. Tu pipeline esperaba un campo que ya no existe. El dashboard de finanzas lleva 4 horas sin actualizarse. El CFO está preguntando.
Este escenario es TAN común que tiene nombre: "acoplamiento implícito entre sistemas". Tú dependes de datos que produce otro equipo, pero no hay ningún ACUERDO FORMAL sobre qué forma tienen esos datos, cuándo cambian y cómo te enterarás. Es como si tu proveedor de materiales cambiara las dimensiones de las piezas sin avisarte — tu fábrica se para.
La solución: contratos de datos. Un contrato de datos es un ACUERDO EXPLÍCITO entre quien produce datos (el productor) y quien los consume (el consumidor) sobre la estructura, calidad y disponibilidad de esos datos. Es como un contrato de alquiler: ambas partes saben qué esperar y qué pasa si alguien incumple.
### Anatomía de un contrato de datos
Un contrato de datos no es un documento legal de 50 páginas. Es un archivo técnico (YAML, JSON o Python) que especifica de forma clara y verificable qué forma tienen los datos. Incluye:
- 01.Schema: qué columnas existen, de qué tipo son, cuáles son obligatorias
- 02.Constraints: reglas que los datos deben cumplir (rangos, formatos, conjuntos válidos)
- 03.SLA de frescura: cada cuánto se actualizan los datos y cuál es el máximo retraso aceptable
- 04.Ownership: quién es el productor responsable y cómo contactarle
- 05.Semántica: qué SIGNIFICA cada campo (no solo su tipo sino su definición de negocio)
- 06.Versionado: cómo se gestionan los cambios al contrato
1# Ejemplo de contrato de datos en Python (como diccionario/YAML)2contrato_pedidos = {3 "nombre": "pedidos_diarios",4 "version": "2.1.0",5 "productor": {6 "equipo": "backend-ecommerce",7 "contacto": "#canal-backend-datos",8 "sla_actualizacion_horas": 1,9 },10 "consumidores": [11 {"equipo": "data-engineering", "uso": "pipeline de métricas"},12 {"equipo": "finance", "uso": "facturación"},13 ],14 "schema": {15 "pedido_id": {"tipo": "string", "obligatorio": True, "unico": True, "formato": "PED-\\d{6}"},16 "cliente_id": {"tipo": "integer", "obligatorio": True, "min": 1},17 "importe_total": {"tipo": "float", "obligatorio": True, "min": 0.01, "max": 100000},18 "estado": {"tipo": "string", "obligatorio": True, "valores_validos": ["pendiente", "pagado", "enviado", "entregado", "cancelado"]},19 "fecha_pedido": {"tipo": "datetime", "obligatorio": True, "formato": "ISO8601"},20 "ciudad": {"tipo": "string", "obligatorio": False, "max_nulos_pct": 5},21 "descuento_pct": {"tipo": "float", "obligatorio": False, "min": 0, "max": 100},22 },23 "volumen": {24 "min_registros_diarios": 500,25 "max_registros_diarios": 50000,26 },27 "freshness": {28 "frecuencia": "cada hora",29 "max_retraso_minutos": 90,30 },31}
Un contrato de datos legible que ambas partes (productor y consumidor) pueden verificar automáticamente
### La analogía del restaurante
Piensa en un restaurante. El menú es el CONTRATO entre el restaurante (productor) y el cliente (consumidor). El menú dice qué platos hay, qué ingredientes llevan, el precio y el tiempo estimado de preparación. Si pides una paella y te traen una pizza, el contrato se ha roto. Si el menú dice "sin gluten" y el plato lleva gluten, el contrato se ha roto. Y lo más importante: si el restaurante quiere cambiar el menú, no lo hace a mitad de servicio sin avisar.
En datos es exactamente igual. Tu pipeline es el "cliente" que "pide" datos al "restaurante" (el sistema upstream). El contrato es el "menú" que garantiza qué vas a recibir. Si el productor cambia el "menú" sin avisar, tu pipeline se rompe.
### Implementar contratos en Python
Un contrato no es útil si no se puede VERIFICAR automáticamente. Vamos a implementar un sistema simple pero funcional para definir contratos y validarlos contra datos reales:
1from dataclasses import dataclass, field2import pandas as pd3import re4from typing import Any56@dataclass7class CampoContrato:8 """Especificación de un campo en el contrato."""9 nombre: str10 tipo: str # 'string', 'integer', 'float', 'datetime'11 obligatorio: bool = True12 unico: bool = False13 min_valor: float | None = None14 max_valor: float | None = None15 valores_validos: list[str] | None = None16 formato_regex: str | None = None17 max_nulos_pct: float = 0.0 # % máximo de nulos permitido1819@dataclass20class ContratoSchema:21 """Contrato completo para un dataset."""22 nombre: str23 version: str24 productor: str25 campos: list[CampoContrato]26 min_registros: int = 027 max_registros: int | None = None2829 def validar(self, df: pd.DataFrame) -> dict:30 """Valida un DataFrame contra este contrato."""31 violaciones = []3233 # Verificar columnas esperadas34 campos_esperados = {c.nombre for c in self.campos if c.obligatorio}35 campos_presentes = set(df.columns)36 faltantes = campos_esperados - campos_presentes37 if faltantes:38 violaciones.append(f"Columnas faltantes: {faltantes}")3940 # Verificar volumen41 if len(df) < self.min_registros:42 violaciones.append(f"Volumen insuficiente: {len(df)} < {self.min_registros}")43 if self.max_registros and len(df) > self.max_registros:44 violaciones.append(f"Volumen excesivo: {len(df)} > {self.max_registros}")4546 # Verificar cada campo47 for campo in self.campos:48 if campo.nombre not in df.columns:49 continue5051 col = df[campo.nombre]5253 # Nulos54 pct_nulos = col.isna().mean() * 10055 if pct_nulos > campo.max_nulos_pct:56 violaciones.append(57 f"{campo.nombre}: {pct_nulos:.1f}% nulos (máx: {campo.max_nulos_pct}%)"58 )5960 # Unicidad61 if campo.unico and col.dropna().duplicated().any():62 n_dup = col.dropna().duplicated().sum()63 violaciones.append(f"{campo.nombre}: {n_dup} duplicados (debe ser único)")6465 # Rango66 if campo.min_valor is not None:67 fuera = (col.dropna() < campo.min_valor).sum()68 if fuera > 0:69 violaciones.append(f"{campo.nombre}: {fuera} valores < {campo.min_valor}")70 if campo.max_valor is not None:71 fuera = (col.dropna() > campo.max_valor).sum()72 if fuera > 0:73 violaciones.append(f"{campo.nombre}: {fuera} valores > {campo.max_valor}")7475 # Valores válidos76 if campo.valores_validos:77 invalidos = ~col.dropna().isin(campo.valores_validos)78 if invalidos.any():79 violaciones.append(80 f"{campo.nombre}: {invalidos.sum()} valores fuera del conjunto permitido"81 )8283 return {84 'contrato': self.nombre,85 'version': self.version,86 'cumple': len(violaciones) == 0,87 'violaciones': violaciones,88 'total_violaciones': len(violaciones),89 }9091# Definir contrato para pedidos92contrato = ContratoSchema(93 nombre="pedidos_diarios",94 version="1.0.0",95 productor="equipo-backend",96 min_registros=10,97 campos=[98 CampoContrato("pedido_id", "string", obligatorio=True, unico=True),99 CampoContrato("importe", "float", obligatorio=True, min_valor=0.01, max_valor=50000),100 CampoContrato("estado", "string", obligatorio=True,101 valores_validos=["pendiente", "pagado", "enviado", "entregado"]),102 CampoContrato("email", "string", obligatorio=True, max_nulos_pct=5.0),103 ],104)105106# Validar datos contra el contrato107datos = pd.DataFrame({108 'pedido_id': ['P001', 'P002', 'P003', 'P003', 'P005'],109 'importe': [50.0, -10.0, 300.0, 150.0, 75000.0],110 'estado': ['pagado', 'enviado', 'devuelto', 'pendiente', 'pagado'],111 'email': ['a@m.com', None, 'c@m.com', None, 'e@m.com'],112})113114resultado = contrato.validar(datos)115print(f"Contrato: {resultado['contrato']} v{resultado['version']}")116print(f"Cumple: {'✅' if resultado['cumple'] else '🔴 NO'}\n")117for v in resultado['violaciones']:118 print(f" ❌ {v}")
Un sistema de contratos implementado en Python puro — verificable automáticamente
### Versionado semántico para contratos
Los contratos CAMBIAN. El negocio evoluciona, se añaden campos, se modifican reglas. La clave es que los cambios sean CONTROLADOS. Usamos versionado semántico (SemVer) adaptado a datos:
- MAJOR (3.0.0): breaking change — se elimina una columna, se cambia un tipo de dato, se renombra un campo. Los consumidores DEBEN adaptar su código.
- MINOR (2.1.0): cambio compatible — se añade una columna nueva (opcional), se relaja un constraint. Los consumidores NO necesitan cambiar nada.
- PATCH (2.0.1): corrección — se arregla un error en la documentación, se ajusta un umbral. Transparente para los consumidores.
1# Ejemplo de evolución de un contrato2historial_versiones = [3 {4 "version": "1.0.0",5 "fecha": "2024-01-01",6 "cambios": "Versión inicial del contrato de pedidos",7 },8 {9 "version": "1.1.0",10 "fecha": "2024-03-15",11 "tipo": "MINOR",12 "cambios": "Añadido campo opcional 'canal_venta' (web/app/tienda)",13 "impacto": "Ninguno — campo nuevo y opcional",14 },15 {16 "version": "2.0.0",17 "fecha": "2024-06-01",18 "tipo": "MAJOR ⚠️",19 "cambios": "Campo 'total' renombrado a 'importe_total'. Campo 'status' cambia de español a códigos (1=pendiente, 2=pagado...)",20 "impacto": "BREAKING — todos los consumidores deben actualizar",21 "deadline_migracion": "2024-06-15",22 },23]2425for v in historial_versiones:26 tipo_badge = f" [{v.get('tipo', 'INITIAL')}]" if 'tipo' in v else ""27 print(f"v{v['version']}{tipo_badge} — {v['fecha']}")28 print(f" {v['cambios']}")29 if 'impacto' in v:30 print(f" Impacto: {v['impacto']}")31 print()
Un historial de cambios claro permite a los consumidores prepararse para breaking changes
El error más grave que he visto en contratos de datos: NO versionar. Un equipo cambia el schema "porque es un fix pequeño" y rompe 3 pipelines downstream. SIEMPRE versiona. SIEMPRE avisa. SIEMPRE da un periodo de transición para breaking changes. "Lo cambio y ya" no escala.
Consejo de senior: almacena tus contratos como archivos YAML o JSON en un repositorio Git dedicado (ej: "data-contracts" repo). Así tienes historial, reviews (PRs para cambiar un contrato) y una fuente de verdad única. Cualquier equipo puede ver el contrato actual de cualquier dataset. Es documentación viva.
### Cuidado: los nombres de tipo de pandas NO son estables
Un contrato describe el DATO, no la librería con la que hoy lo lees. Dos trampas que muerden en la práctica y que son la causa número uno de falsos breaking changes:
- 01.En pandas 2 una columna de texto tiene dtype "object". En pandas 3, la misma columna es "str". Un contrato que diga "object" empezará a dar falsos breaking changes el día que actualices pandas, sin que nadie haya tocado los datos.
- 02.Un solo nulo en una columna de enteros la convierte en "float64". Si tu contrato dice "int64" y un día llega un nulo en la fila 5001, el validador grita "cambio de tipo" — pero el dato no ha cambiado, es un problema de completitud disfrazado.
La solución: declara tipos abstractos en el contrato ("integer", "string", "float") y traduce a dtypes reales en el momento de comparar. Un mapa de familias resuelve ambos casos: "string" acepta tanto "object" como "str", y "integer" acepta tanto "int64" como "float64" si decides que un nulo no invalida el tipo (o lo reportas como violación de completitud, no de tipo). Es lo que hacen los ejercicios de esta lección.
Y una nota sobre formatos estándar: lo que acabas de montar a mano con diccionarios y dataclasses existe estandarizado como JSON Schema (para validar la estructura de un JSON), Avro y Protobuf (para serializar datos entre sistemas con un schema compartido). La idea es la misma — declarar qué forma tienen los datos una vez y validar automáticamente. La diferencia es que los estándares tienen ecosistemas, herramientas y equipos enteros que los mantienen.
### Contratos en la práctica: el flujo completo
En una organización madura, el flujo de un cambio de contrato es así: 1) El productor propone un cambio abriendo un PR en el repo de contratos. 2) Los consumidores reciben una notificación y revisan si les afecta. 3) Se acuerda un deadline de migración. 4) El productor implementa el cambio y durante el periodo de transición soporta ambas versiones. 5) Los consumidores migran. 6) Se retira la versión antigua. Es como un deprecation en una API — anuncias, das tiempo, retiras.
En la próxima lección veremos cómo ENTERARTE cuando algo va mal: alertas y monitorización. Porque un contrato sin enforcement es como una ley sin policía — bonito en papel pero inútil en la práctica.
## ejercicios
Definir y validar un contrato para la tabla de clientes
El equipo de marketing consume tu tabla de clientes para segmentación. Llevan 3 meses quejándose de que "los datos cambian sin aviso". Define un contrato formal y valida el dataset actual contra él.
💡 Resultado esperado
📋 Contrato: clientes_segmentacion v1.0.0 Resultado: 🔴 VIOLACIONES ENCONTRADAS ❌ nombre: 20.0% nulos/vacíos (máx: 5.0%)
Detectar un breaking change en el schema
Tu pipeline recibe datos de una API. Ayer funcionaba. Hoy falla. Escribe una función que compare el schema esperado (del contrato) con el schema real (del DataFrame recibido) y detecte qué cambió.
💡 Resultado esperado
=== ESCENARIO 1: cambio destructivo === 🏷️ Tipo de cambio: MAJOR 🔴 BREAKING CHANGE
Sistema de contratos con archivo de configuración
En producción, los contratos se definen en archivos de configuración (YAML/JSON), no hardcodeados en Python. Implementa un sistema que lea un contrato desde un diccionario (simulando YAML) y valide datos automáticamente.
💡 Resultado esperado
📋 Contrato: eventos_usuario v1.2.0 Owner: equipo-producto Estado: 🔴 INCUMPLIMIENTO
Regístrate para guardar tu progreso.
## comentarios
Reporta erratas, ayuda a otros o comparte tu opinión. Sé constructivo.
Inicia sesión para comentar y responder.
cargando comentarios...