Saltar al contenido

lección 6

ETL vs ELT: cómo llegan los datos al warehouse

Patrones de carga de datos: staging areas, transformación, carga incremental vs full refresh, e idempotencia.

⏱ 40 min

Ya sabes CÓMO organizar los datos en el warehouse (estrella, hechos, dimensiones). Pero hay una pregunta crucial que no hemos respondido: ¿CÓMO llegan los datos desde la base operacional hasta el warehouse? No es magia — alguien tiene que escribir código que extraiga datos del sistema fuente, los transforme al modelo dimensional y los cargue en las tablas destino. Ese proceso es el ETL (Extract-Transform-Load) o ELT (Extract-Load-Transform), y es probablemente el 70% del trabajo real de un ingeniero de datos.

La analogía: imagina una fábrica de muebles. La madera llega en bruto del bosque (Extract). En el taller la cortan, lijan y pintan (Transform). El mueble terminado se lleva a la tienda (Load). ETL es eso: extraer materia prima, transformarla, y cargar el producto final. ELT cambia el orden: primero llevas la madera bruta a la tienda (Load) y después la transformas allí mismo. ¿Cuándo tiene sentido cada enfoque? Vamos a verlo.

## ETL clásico: transformar ANTES de cargar

ETL fue el paradigma dominante durante 30 años (1990-2020). Herramientas como Informatica, DataStage y Talend extraían datos de las fuentes, los transformaban en un servidor intermedio (el ETL server), y cargaban el resultado limpio en el warehouse. El warehouse solo recibía datos LIMPIOS y MODELADOS.

  1. 01.Extract: conectar a la fuente (PostgreSQL, API, archivo) y extraer datos crudos
  2. 02.Transform: en un servidor intermedio, limpiar, desnormalizar, aplicar reglas de negocio, generar surrogate keys
  3. 03.Load: insertar los datos transformados en las tablas del warehouse (fact + dims)

¿Por qué se hacía así? Porque los warehouses on-premise (Teradata, Oracle DW) tenían capacidad de cómputo LIMITADA y CARA. No querías desperdiciar ciclos de tu carísimo Teradata haciendo limpieza — mejor hacerla fuera y cargar solo lo necesario.

## ELT moderno: cargar CRUDO y transformar en destino

Con la nube (BigQuery, Redshift, Snowflake, Databricks), el compute se volvió elástico y barato. Ya no tiene sentido mantener un servidor ETL intermedio cuando tu warehouse tiene capacidad de sobra para hacer las transformaciones. ELT dice: extrae los datos, cárgalos TAL CUAL en una zona de staging del warehouse, y después usa SQL (o dbt, Spark) para transformarlos DENTRO del warehouse.

  1. 01.Extract: conectar a la fuente y extraer datos crudos (igual que ETL)
  2. 02.Load: cargar los datos crudos en tablas de staging del warehouse SIN transformar
  3. 03.Transform: usar SQL/Spark DENTRO del warehouse para limpiar y modelar hacia las tablas finales
ETL transforma fuera. ELT carga primero y transforma dentro con SQL. En 2024, ELT domina en la nube.

## La staging area: zona de aterrizaje

Tanto en ETL como en ELT, los datos pasan por una staging area (zona de aterrizaje). Es un espacio temporal donde los datos aterrizan desde la fuente SIN transformaciones. La staging es una copia cruda del sistema fuente. ¿Por qué no transformar directamente? Porque si algo sale mal en la transformación, necesitas volver a los datos originales sin volver a extraer de la fuente (que puede ser lento o costoso).

1-- Staging area: réplica cruda de la fuente
2-- Naming convention: stg_<sistema_fuente>_<tabla>
3
4CREATE SCHEMA staging;
5
6-- Réplica de la tabla orders del sistema operacional
7CREATE TABLE staging.stg_shopify_orders (
8 -- Columnas EXACTAS del sistema fuente (sin transformar)
9 id VARCHAR(50),
10 customer_id VARCHAR(50),
11 status VARCHAR(20),
12 total_amount VARCHAR(20), -- ¡Viene como string de la API!
13 currency VARCHAR(3),
14 created_at VARCHAR(30), -- timestamp como string ISO
15 updated_at VARCHAR(30),
16 -- Metadata del ETL
17 _extracted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
18 _source_file VARCHAR(200),
19 _batch_id VARCHAR(50)
20);
21
22-- Una tabla de staging por tabla de origen. Los pedidos no traen la ciudad ni
23-- el segmento del cliente: eso vive en customers. Si intentas cargar
24-- dim_customer desde stg_shopify_orders, las columnas no están.
25CREATE TABLE staging.stg_shopify_customers (
26 id VARCHAR(50),
27 name VARCHAR(200),
28 email VARCHAR(200),
29 city VARCHAR(100),
30 segment VARCHAR(30),
31 updated_at VARCHAR(30),
32 _extracted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
33 _source_file VARCHAR(200),
34 _batch_id VARCHAR(50)
35);
36
37-- La tabla de control del ETL. No es staging: staging se trunca en cada
38-- ejecución, y la marca de agua tiene que sobrevivir a eso.
39CREATE TABLE etl_control (
40 table_name VARCHAR(100),
41 loaded_until TIMESTAMP,
42 loaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
43);
44
45-- Notas:
46-- 1. Tipos de dato GENEROSOS (varchar para todo) — no rechazar datos por tipos
47-- 2. Metadata del ETL (_extracted_at, _batch_id) para auditoría
48-- 3. Se TRUNCA y recarga cada ejecución (no es histórica)
49-- 4. etl_control es la excepción: esa NO se trunca nunca

La staging area es tu red de seguridad. Si la transformación falla, puedes re-ejecutarla sin re-extraer.

## Carga incremental vs Full Refresh

Cuando tu tabla fuente tiene 100 millones de filas, no quieres copiarlas TODAS cada día. La carga incremental solo extrae los datos NUEVOS o MODIFICADOS desde la última ejecución. Pero implementarla correctamente es uno de los retos más difíciles del data engineering.

  • Full refresh: DROP + recrear toda la tabla destino cada ejecución. Simple pero lento con tablas grandes.
  • Incremental por timestamp: extraer filas con updated_at > última_ejecución. Rápido pero requiere que la fuente tenga un campo updated_at fiable.
  • Incremental por ID: extraer filas con id > máximo_id_en_destino. Solo funciona para INSERTs, no detecta UPDATEs.
  • CDC (Change Data Capture): capturar el log de cambios de la base fuente (binlog en MySQL, WAL en PostgreSQL). El más completo pero el más complejo.
1-- Patrón: Carga incremental por timestamp
2
3-- 1. Obtener la marca de agua (watermark): hasta dónde llegó la última carga.
4-- Vive en su propia tabla de control, NO en la de staging: staging se trunca en
5-- cada ejecución, así que ahí el máximo sería siempre NULL y cada noche
6-- recargarías el histórico entero creyendo que haces carga incremental.
7SELECT COALESCE(MAX(loaded_until), TIMESTAMP '1900-01-01') AS last_watermark
8FROM etl_control
9WHERE table_name = 'stg_shopify_orders';
10
11-- 2. Extraer solo lo nuevo/modificado de la fuente
12-- (esto se haría desde Python/Spark, aquí es pseudocódigo SQL)
13TRUNCATE staging.stg_shopify_orders; -- staging arranca vacía en cada carga
14INSERT INTO staging.stg_shopify_orders
15SELECT *, CURRENT_TIMESTAMP AS _extracted_at
16FROM source_shopify.orders
17WHERE updated_at > :last_watermark; -- solo cambios desde la última vez
18
19-- 3a. Cerrar la versión vigente de los clientes cuyos atributos han cambiado.
20-- Ojo a la tabla: los atributos del cliente están en la staging de CLIENTES,
21-- no en la de pedidos.
22UPDATE dim_customer d
23SET is_current = FALSE,
24 valid_to = CURRENT_DATE - INTERVAL '1 day'
25WHERE d.is_current = TRUE
26 AND EXISTS (
27 SELECT 1 FROM staging.stg_shopify_customers s
28 WHERE s.id = d.customer_id
29 AND (s.city != d.city OR s.segment != d.segment)
30 );
31
32-- 3b. Insertar la versión nueva. Cubre los dos casos de golpe: los que acabamos
33-- de cerrar arriba y los clientes que no existían todavía. Los que no han
34-- cambiado siguen teniendo su fila vigente, así que este INSERT no los toca.
35INSERT INTO dim_customer (customer_id, name, city, segment, is_current, valid_from, valid_to)
36SELECT DISTINCT s.id, s.name, s.city, s.segment, TRUE, CURRENT_DATE, DATE '9999-12-31'
37FROM staging.stg_shopify_customers s
38WHERE NOT EXISTS (
39 SELECT 1 FROM dim_customer d
40 WHERE d.customer_id = s.id AND d.is_current = TRUE
41);
42
43-- 4. Avanzar la marca de agua, y solo ahora: si algo de arriba ha fallado, la
44-- carga de mañana tiene que volver a intentar este mismo tramo.
45-- El CAST no es decorativo: en staging updated_at es VARCHAR (ahí entra todo
46-- sin validar), y loaded_until es TIMESTAMP. Sin castear, o falla o guardas
47-- el máximo ALFABÉTICO de unas cadenas, que no es el máximo cronológico.
48INSERT INTO etl_control (table_name, loaded_until)
49SELECT 'stg_shopify_orders', MAX(CAST(updated_at AS TIMESTAMP))
50FROM staging.stg_shopify_orders;

Carga incremental + SCD Tipo 2 combinados. Este es el patrón real que usarás en producción.

## Idempotencia: la propiedad más importante de tu ETL

Un proceso es idempotente si ejecutarlo 1 vez produce el MISMO resultado que ejecutarlo 5 veces. ¿Por qué importa? Porque en producción, las cosas fallan. La red se cae a mitad de una carga. Un job se ejecuta dos veces por un bug en el scheduler. Si tu ETL no es idempotente, tendrás datos duplicados, conteos inflados o registros faltantes.

1-- [ERROR] NO IDEMPOTENTE: si se ejecuta 2 veces, duplica datos
2INSERT INTO fact_sales (date_key, customer_key, product_key, amount)
3SELECT date_key, customer_key, product_key, amount
4FROM staging.stg_sales
5WHERE date_key = 20240315;
6
7-- [OK] IDEMPOTENTE: borra y recarga la partición del día
8-- Si se ejecuta 2 veces, el resultado es idéntico
9DELETE FROM fact_sales WHERE date_key = 20240315;
10INSERT INTO fact_sales (date_key, customer_key, product_key, amount)
11SELECT date_key, customer_key, product_key, amount
12FROM staging.stg_sales
13WHERE date_key = 20240315;
14
15-- [OK] ALTERNATIVA IDEMPOTENTE: MERGE/UPSERT con clave natural
16MERGE INTO fact_sales AS target
17USING staging.stg_sales AS source
18ON target.order_number = source.order_number
19 AND target.product_key = source.product_key
20WHEN MATCHED THEN UPDATE SET amount = source.amount
21WHEN NOT MATCHED THEN INSERT (date_key, customer_key, product_key, amount)
22VALUES (source.date_key, source.customer_key, source.product_key, source.amount);

DELETE + INSERT por partición es el patrón idempotente más simple y robusto.

Patrón que uso SIEMPRE: particionar la fact table por fecha (date_key) y hacer DELETE + INSERT de la partición del día. Es idempotente por definición: no importa cuántas veces ejecutes el job del 15 de marzo, siempre borra y recarga SOLO el 15 de marzo. Simple, predecible, auditable.

## El flujo completo: de staging a estrella

  1. 01.EXTRACT: Python/Spark extrae datos de las fuentes (PostgreSQL, APIs, archivos) y los deposita en staging
  2. 02.STAGE: Los datos crudos aterrizan en tablas staging.stg_* (tipos generosos, sin validación)
  3. 03.VALIDATE: Queries de validación comprueban que los datos staging están completos y razonables
  4. 04.TRANSFORM DIMS: SQL actualiza las dimensiones (SCD Tipo 1/2/3 según corresponda)
  5. 05.TRANSFORM FACTS: SQL genera las filas de la fact table haciendo JOIN con las dims para resolver surrogate keys
  6. 06.LOAD: INSERT (o DELETE+INSERT idempotente) de los hechos transformados en la fact table final
  7. 07.AUDIT: Registrar en una tabla de log qué se procesó, cuántas filas, si hubo errores

El fallo más común en ETLs: no manejar las dependencias de orden. Las dimensiones DEBEN cargarse ANTES que los hechos. ¿Por qué? Porque la fact table necesita los surrogate keys de las dimensiones. Si cargas la fact table antes de actualizar dim_customer con un cliente nuevo, esa FK será NULL o errónea. El orden es siempre: staging → dimensiones → hechos.

Un apunte de dialecto sobre strftime. Dar formato a una fecha es de las cosas que más cambian de un motor a otro. strftime viene de SQLite y la tiene también DuckDB, que es el motor de estos ejercicios. En PostgreSQL la función se llama TO_CHAR — TO_CHAR(fecha, 'YYYY-MM') para agrupar por mes— y en BigQuery, FORMAT_DATE. La idea es la misma en las tres; lo que cambia es el nombre y, ojo, también las letras del formato. Cuando cambies de motor, comprueba el formato con un ejemplo antes de fiarte del resultado.

Regístrate para guardar tu progreso.