ETL vs ELT era solo el principio: cómo organizar capas raw, staging y marts
Cómo organizar las capas raw, staging y marts en un pipeline de datos real. Continuación práctica del artículo ETL vs ELT.

Hace unas semanas publiqué un artículo sobre las diferencias entre ETL y ELT. La respuesta que más se repitió fue algo así: “Vale, ya sé cuándo transformar antes o después de cargar… pero, ¿cómo organizo las capas dentro del warehouse?”. La pregunta tiene todo el sentido. Saber si haces ETL o ELT es la primera decisión, pero la que define si tu pipeline es mantenible o un desastre a cámara lenta es cómo estructuras lo que hay dentro.
Este artículo no repite la comparativa ETL vs ELT. Da por hecho que ya tienes datos llegando a un destino y que necesitas decidir qué guardar, dónde transformar y qué exponer. Las tres capas clásicas ---raw, staging y marts--- son la respuesta que mejor funciona en la práctica. No porque sean la única opción, sino porque resuelven problemas reales que aparecen siempre: datos que se corrompen, transformaciones que nadie entiende, y métricas que no cuadran porque alguien modificó el dato crudo sin querer.
Por qué necesitas capas y no una tabla gigante
El impulso natural cuando empiezas con datos es crear una tabla donde vuelcas todo. Un CSV que lees, limpias y cargas. Un script que conecta con una API, transforma el JSON y lo mete directamente en la tabla final. Funciona el primer día. Al tercer mes tienes un problema.
La razón principal para separar en capas no es estética ni académica. Es que sin separación, cualquier error en la transformación destruye el dato original y no puedes recuperarlo.
He visto pipelines donde alguien decidió “limpiar” los datos directamente en la única tabla que existía. Cuando descubrimos que la limpieza tenía un bug que eliminaba registros válidos, no había forma de reconstruirlos. El dato crudo ya no existía. Eso no pasa si respetas las capas.
Las capas resuelven tres problemas concretos:
- Inmutabilidad del dato crudo. Siempre puedes volver al origen.
- Separación de responsabilidades. Cada capa tiene un propósito claro.
- Depurabilidad. Si una métrica no cuadra, puedes rastrear en qué capa se rompió.
La capa raw: el dato tal cual llega
La capa raw es el almacén de lo que recibes. Sin filtros, sin transformaciones, sin opiniones. Si una API te devuelve un JSON con campos vacíos, duplicados y timestamps en formato raro, eso es exactamente lo que guardas.
Qué guardar en raw
- El dato completo tal como llega de la fuente.
- Un timestamp de ingestión (
_ingested_at) para saber cuándo se recibió. - Opcionalmente, metadatos de origen: nombre de la fuente, versión del schema, ID de ejecución del pipeline.
Qué NO hacer en raw
- No limpiar nulos.
- No cambiar tipos de datos.
- No deduplicar.
- No filtrar registros “que parecen basura”.
La tentación de “arreglar un poquito” los datos en raw es el error más común que he visto. Un compañero me dijo una vez: “Solo le quité los duplicados antes de guardarlos, para ahorrar espacio”. El problema vino cuando resultó que esos “duplicados” eran en realidad actualizaciones del mismo registro con timestamps distintos, y al deduplicar perdimos el historial de cambios.
Ejemplo: ingestión raw en SQL
CREATE TABLE raw.orders (
_ingested_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
_source VARCHAR(50) DEFAULT 'api_ecommerce_v2',
_batch_id VARCHAR(36),
payload JSONB -- dato crudo completo, sin tocar
);
-- Inserción: volcamos el JSON tal cual
INSERT INTO raw.orders (_batch_id, payload)
VALUES (
'batch-2026-05-18-001',
'{"order_id": "A1234", "customer": "jdoe", "total": "89.99", "status": "pending", "created_at": "2026-05-17T14:32:00"}'
);Ejemplo: ingestión raw en Python
import json
import psycopg2
from datetime import datetime
from uuid import uuid4
def ingest_raw_orders(orders: list[dict], conn):
"""Guarda los pedidos tal cual llegan, sin transformar nada."""
batch_id = str(uuid4())
cursor = conn.cursor()
for order in orders:
cursor.execute(
"""
INSERT INTO raw.orders (_batch_id, payload)
VALUES (%s, %s)
""",
(batch_id, json.dumps(order))
)
conn.commit()
print(f"[RAW] Ingestados {len(orders)} registros | batch: {batch_id}")El dato crudo es tu seguro de vida. Si mañana cambias la lógica de transformación, si descubres un bug en staging, si el negocio redefine una métrica, siempre puedes volver a raw y reprocesar.
La capa staging: limpiar, tipar, deduplicar
Staging es donde el dato empieza a tener forma. Aquí aplicas las transformaciones técnicas que no dependen de reglas de negocio: tipado correcto, eliminación de duplicados, tratamiento de nulos, normalización de formatos.
Qué se hace en staging
| Operación | Ejemplo |
|---|---|
| Tipado | Convertir "89.99" (string) a 89.99 (decimal) |
| Deduplicación | Quedarte con el registro más reciente por order_id |
| Nulos | Decidir si un campo nulo se descarta, se marca o se rellena |
| Normalización | Unificar formatos de fecha, códigos de país, estados |
| Validación básica | Rechazar registros que no cumplen un schema mínimo |
Qué NO se hace en staging
- No calculas métricas de negocio.
- No agregas datos (no haces SUM, AVG, COUNT para reportes).
- No aplicas reglas que solo el negocio puede definir.
La frontera entre staging y marts a veces es borrosa. Mi regla es: si la transformación la haría cualquier ingeniero de datos sin conocer el dominio del negocio, va en staging. Si necesitas hablar con producto o finanzas para saber cómo calcularlo, va en marts.
Ejemplo: transformación staging en SQL
CREATE TABLE staging.orders AS
SELECT
payload->>'order_id' AS order_id,
payload->>'customer' AS customer_id,
CAST(payload->>'total' AS DECIMAL(10,2)) AS total_amount,
LOWER(payload->>'status') AS status,
CAST(payload->>'created_at' AS TIMESTAMP) AS order_created_at,
_ingested_at,
_batch_id
FROM raw.orders;
-- Deduplicación: quedarnos con la versión más reciente de cada pedido
DELETE FROM staging.orders
WHERE ctid NOT IN (
SELECT DISTINCT ON (order_id) ctid
FROM staging.orders
ORDER BY order_id, _ingested_at DESC
);Ejemplo: transformación staging en Python
import pandas as pd
def transform_staging_orders(raw_df: pd.DataFrame) -> pd.DataFrame:
"""Limpia y tipa los pedidos crudos."""
df = raw_df.copy()
# Extraer campos del payload JSON
df['order_id'] = df['payload'].apply(lambda x: x.get('order_id'))
df['customer_id'] = df['payload'].apply(lambda x: x.get('customer'))
df['total_amount'] = pd.to_numeric(
df['payload'].apply(lambda x: x.get('total')),
errors='coerce'
)
df['status'] = df['payload'].apply(
lambda x: x.get('status', '').lower()
)
df['order_created_at'] = pd.to_datetime(
df['payload'].apply(lambda x: x.get('created_at')),
errors='coerce'
)
# Eliminar registros sin order_id (dato corrupto)
df = df.dropna(subset=['order_id'])
# Deduplicar: quedarnos con el registro más reciente por order_id
df = df.sort_values('_ingested_at', ascending=False)
df = df.drop_duplicates(subset=['order_id'], keep='first')
# Seleccionar columnas finales
staging_cols = [
'order_id', 'customer_id', 'total_amount',
'status', 'order_created_at', '_ingested_at', '_batch_id'
]
return df[staging_cols].reset_index(drop=True)En staging el objetivo es tener datos limpios, tipados y sin duplicados. Nada más y nada menos. Si llegas aquí con datos fiables, marts se convierte en un problema mucho más sencillo.
La capa marts: modelos de negocio y métricas
Marts es donde los datos se convierten en respuestas. Aquí creas las tablas, vistas y modelos que consume el negocio: dashboards, reportes, KPIs, modelos predictivos. Las transformaciones en marts son de dominio, no técnicas.
Qué se hace en marts
- Cálculos de negocio: revenue, churn, LTV, conversion rate.
- Agregaciones: ventas por mes, pedidos por cliente, tickets por categoría.
- Joins entre entidades de staging para crear vistas de negocio.
- Modelos dimensionales (fact tables, dimension tables) si tu warehouse lo requiere.
Ejemplo: mart de ventas diarias
CREATE TABLE marts.daily_sales AS
SELECT
DATE(order_created_at) AS sale_date,
COUNT(*) AS total_orders,
COUNT(DISTINCT customer_id) AS unique_customers,
SUM(total_amount) AS total_revenue,
AVG(total_amount) AS avg_order_value,
SUM(CASE WHEN status = 'completed'
THEN total_amount ELSE 0 END) AS confirmed_revenue
FROM staging.orders
WHERE status IN ('completed', 'pending', 'shipped')
GROUP BY DATE(order_created_at)
ORDER BY sale_date DESC;Ejemplo: mart de clientes para el equipo de producto
CREATE VIEW marts.customer_summary AS
SELECT
customer_id,
COUNT(*) AS total_orders,
SUM(total_amount) AS lifetime_value,
MIN(order_created_at) AS first_order_at,
MAX(order_created_at) AS last_order_at,
AVG(total_amount) AS avg_order_value,
CURRENT_DATE - MAX(order_created_at)::date AS days_since_last_order
FROM staging.orders
WHERE status = 'completed'
GROUP BY customer_id;La diferencia clave con staging es que marts tiene opinión de negocio. “Confirmed revenue” solo cuenta pedidos con status completed. Esa regla no es técnica; es una decisión de finanzas. Si mañana deciden que shipped también cuenta como revenue confirmado, cambias el mart, no staging ni raw.
El flujo completo: de la fuente al dashboard
Para verlo de forma global, este es el pipeline con las tres capas:
Fuente (API, DB, CSV)
│
▼
┌────────┐
│ RAW │ Dato crudo + timestamp de ingestión
│ │ Inmutable, sin transformar
└───┬────┘
│
▼
┌──────────┐
│ STAGING │ Tipado, deduplicado, nulos tratados
│ │ Transformaciones técnicas
└────┬─────┘
│
▼
┌────────┐
│ MARTS │ Métricas, KPIs, vistas de negocio
│ │ Transformaciones de dominio
└───┬────┘
│
▼
Dashboard / API / Modelo MLOrquestación con un script Python sencillo
def run_pipeline():
"""Pipeline completo: raw -> staging -> marts."""
conn = get_connection()
# 1. Raw: ingestar datos crudos
raw_orders = fetch_orders_from_api()
ingest_raw_orders(raw_orders, conn)
print("[PIPELINE] Raw completado")
# 2. Staging: limpiar y tipar
raw_df = pd.read_sql("SELECT * FROM raw.orders", conn)
staging_df = transform_staging_orders(raw_df)
staging_df.to_sql('orders', conn, schema='staging', if_exists='replace', index=False)
print("[PIPELINE] Staging completado")
# 3. Marts: crear vistas de negocio
with conn.cursor() as cur:
cur.execute(open('sql/marts/daily_sales.sql').read())
cur.execute(open('sql/marts/customer_summary.sql').read())
conn.commit()
print("[PIPELINE] Marts completado")
if __name__ == '__main__':
run_pipeline()Errores habituales que rompen el pipeline
Después de montar y mantener varios pipelines con esta estructura, hay patrones de error que se repiten. Los más peligrosos no son los bugs técnicos, sino las decisiones de diseño que parecen inofensivas al principio.
1. Transformar en raw
El clásico. “Solo normalizo las fechas antes de guardar.” Parece razonable hasta que descubres que la fuente cambió el formato de fecha sin avisar y tu normalización lleva semanas descartando registros válidos. En raw no se toca nada. Punto.
2. Mezclar staging y marts
Poner cálculos de negocio en staging es tentador cuando vas con prisa. “Ya que estoy limpiando, calculo también el revenue.” El problema llega cuando la definición de revenue cambia y tienes que tocar staging, lo que invalida todas las tablas que dependen de él.
3. No versionar los schemas
Si tu tabla de staging tiene 12 columnas hoy y mañana la fuente añade 3 campos nuevos, tu pipeline se rompe. O peor: sigue funcionando pero ignora los campos nuevos silenciosamente. Versionar los schemas ---aunque sea con un documento o un fichero YAML--- te avisa cuando algo cambia.
# schema_orders_v2.yaml
version: 2
source: api_ecommerce
fields:
- name: order_id
type: string
nullable: false
- name: customer
type: string
nullable: false
- name: total
type: string # llega como string, se tipa en staging
nullable: false
- name: status
type: string
nullable: false
allowed_values: [pending, completed, shipped, cancelled]
- name: created_at
type: string # ISO 8601
nullable: false4. No tener _ingested_at en raw
Sin timestamp de ingestión no puedes saber cuándo llegó un dato. Si necesitas reprocesar “los datos que llegaron ayer”, sin ese campo estás perdido. Es un campo que cuesta cero añadir y que salva situaciones constantemente.
5. Tablas marts sin documentación de reglas de negocio
Si alguien mira confirmed_revenue en tu mart y no hay ningún sitio que explique que solo incluye pedidos con status completed, va a asumir lo que le convenga. Documenta las reglas de negocio al lado del SQL o en el propio schema.
Cuándo esta estructura es demasiado
No todo necesita tres capas formales. Si tu pipeline es un script que lee un CSV de 200 filas y lo carga en un Google Sheet para un informe mensual, montar raw/staging/marts sería sobreingeniería.
Mi criterio para decidir cuándo vale la pena:
| Situación | Raw/Staging/Marts | Un script directo |
|---|---|---|
| Más de una fuente de datos | Si | Probablemente no |
| Los datos se reprocesan periódicamente | Si | No |
| Varias personas consumen las métricas | Si | Depende |
| Los datos crudos pueden cambiar de formato | Si | Riesgo alto |
| Pipeline de un solo uso, análisis puntual | No | Si |
| Dashboard en producción con SLA | Si | Nunca |
Si tu pipeline tiene más de una fuente, se ejecuta de forma recurrente y más de una persona consume los resultados, las tres capas te van a ahorrar problemas. Es una inversión pequeña que escala bien.
Relación con ETL y ELT
Si vienes del artículo anterior, esto encaja así:
- En un flujo ETL clásico, las transformaciones de staging ocurren antes de cargar al warehouse. Raw puede ser un directorio temporal de archivos o una tabla de paso.
- En un flujo ELT moderno, todo llega primero al warehouse (raw), y las transformaciones de staging y marts se hacen con SQL dentro del propio warehouse. Herramientas como dbt encajan perfectamente en este modelo.
La estructura raw/staging/marts no compite con ETL o ELT. Es complementaria. Define la organización interna del destino, independientemente de cómo lleguen los datos.
Lo que me hubiera gustado saber antes
Cuando monté mi primer pipeline con estas capas, cometí casi todos los errores que he descrito. Lo que cambió mi perspectiva fue dejar de pensar en las capas como una burocracia técnica y empezar a verlas como un contrato: raw promete inmutabilidad, staging promete limpieza, marts promete relevancia de negocio.
Cada capa tiene una única responsabilidad. Y como en el código, cuando una pieza tiene una sola razón para cambiar, el sistema entero es más fácil de mantener, depurar y evolucionar.
Si ya tienes un pipeline funcionando y no tiene capas, no hace falta reescribirlo de golpe. Empieza por separar raw. Solo eso ---guardar el dato crudo antes de tocarlo--- ya cambia las reglas del juego.


