Saltar al contenido

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:

  1. 01.Schema: qué columnas existen, de qué tipo son, cuáles son obligatorias
  2. 02.Constraints: reglas que los datos deben cumplir (rangos, formatos, conjuntos válidos)
  3. 03.SLA de frescura: cada cuánto se actualizan los datos y cuál es el máximo retraso aceptable
  4. 04.Ownership: quién es el productor responsable y cómo contactarle
  5. 05.Semántica: qué SIGNIFICA cada campo (no solo su tipo sino su definición de negocio)
  6. 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.

El contrato es el acuerdo que evita el "alguien cambió algo y mi pipeline se rompió"

### 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, field
2import pandas as pd
3import re
4from typing import Any
5
6@dataclass
7class CampoContrato:
8 """Especificación de un campo en el contrato."""
9 nombre: str
10 tipo: str # 'string', 'integer', 'float', 'datetime'
11 obligatorio: bool = True
12 unico: bool = False
13 min_valor: float | None = None
14 max_valor: float | None = None
15 valores_validos: list[str] | None = None
16 formato_regex: str | None = None
17 max_nulos_pct: float = 0.0 # % máximo de nulos permitido
18
19@dataclass
20class ContratoSchema:
21 """Contrato completo para un dataset."""
22 nombre: str
23 version: str
24 productor: str
25 campos: list[CampoContrato]
26 min_registros: int = 0
27 max_registros: int | None = None
28
29 def validar(self, df: pd.DataFrame) -> dict:
30 """Valida un DataFrame contra este contrato."""
31 violaciones = []
32
33 # Verificar columnas esperadas
34 campos_esperados = {c.nombre for c in self.campos if c.obligatorio}
35 campos_presentes = set(df.columns)
36 faltantes = campos_esperados - campos_presentes
37 if faltantes:
38 violaciones.append(f"Columnas faltantes: {faltantes}")
39
40 # Verificar volumen
41 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}")
45
46 # Verificar cada campo
47 for campo in self.campos:
48 if campo.nombre not in df.columns:
49 continue
50
51 col = df[campo.nombre]
52
53 # Nulos
54 pct_nulos = col.isna().mean() * 100
55 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 )
59
60 # Unicidad
61 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)")
64
65 # Rango
66 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}")
74
75 # Valores válidos
76 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 )
82
83 return {
84 'contrato': self.nombre,
85 'version': self.version,
86 'cumple': len(violaciones) == 0,
87 'violaciones': violaciones,
88 'total_violaciones': len(violaciones),
89 }
90
91# Definir contrato para pedidos
92contrato = 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)
105
106# Validar datos contra el contrato
107datos = 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})
113
114resultado = 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 contrato
2historial_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]
24
25for 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:

  1. 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.
  2. 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

[01]

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%)
Cargando editor...
[02]

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
Cargando editor...
[03]

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
Cargando editor...

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...