ETL vs ELT era només el principi: com organitzar capes raw, staging i marts
Com organitzar les capes raw, staging i marts en un pipeline de dades real. Continuació pràctica de l'article ETL vs ELT.

Fa unes setmanes vaig publicar un article sobre les diferències entre ETL i ELT. La resposta que més es va repetir va ser una cosa així: “D’acord, ja sé quan transformar abans o després de carregar… però, com organitzo les capes dins del warehouse?”. La pregunta té tot el sentit. Saber si fas ETL o ELT és la primera decisió, però la que defineix si el teu pipeline és mantenible o un desastre a càmera lenta és com estructures el que hi ha dins.
Aquest article no repeteix la comparativa ETL vs ELT. Dona per fet que ja tens dades arribant a un destí i que necessites decidir què guardar, on transformar i què exposar. Les tres capes clàssiques ---raw, staging i marts--- són la resposta que millor funciona a la pràctica. No perquè siguin l’única opció, sinó perquè resolen problemes reals que apareixen sempre: dades que es corrompen, transformacions que ningú entén, i mètriques que no quadren perquè algú va modificar la dada crua sense voler.
Per què necessites capes i no una taula gegant
L’impuls natural quan comences amb dades és crear una taula on ho aboques tot. Un CSV que llegeixes, neteges i carregues. Un script que connecta amb una API, transforma el JSON i el fica directament a la taula final. Funciona el primer dia. Al tercer mes tens un problema.
La raó principal per separar en capes no és estètica ni acadèmica. És que sense separació, qualsevol error en la transformació destrueix la dada original i no la pots recuperar.
He vist pipelines on algú va decidir “netejar” les dades directament a l’única taula que existia. Quan vam descobrir que la neteja tenia un bug que eliminava registres vàlids, no hi havia manera de reconstruir-los. La dada crua ja no existia. Això no passa si respectes les capes.
Les capes resolen tres problemes concrets:
- Immutabilitat de la dada crua. Sempre pots tornar a l’origen.
- Separació de responsabilitats. Cada capa té un propòsit clar.
- Depurabilitat. Si una mètrica no quadra, pots rastrejar en quina capa es va trencar.
La capa raw: la dada tal com arriba
La capa raw és el magatzem del que reps. Sense filtres, sense transformacions, sense opinions. Si una API et retorna un JSON amb camps buits, duplicats i timestamps en format estrany, això és exactament el que guardes.
Què guardar a raw
- La dada completa tal com arriba de la font.
- Un timestamp d’ingestió (
_ingested_at) per saber quan es va rebre. - Opcionalment, metadades d’origen: nom de la font, versió de l’schema, ID d’execució del pipeline.
Què NO fer a raw
- No netejar nuls.
- No canviar tipus de dades.
- No deduplicar.
- No filtrar registres “que semblen escombraries”.
La temptació d‘“arreglar una mica” les dades a raw és l’error més comú que he vist. Un company em va dir un cop: “Només els hi he tret els duplicats abans de guardar-los, per estalviar espai”. El problema va venir quan va resultar que aquells “duplicats” eren en realitat actualitzacions del mateix registre amb timestamps diferents, i en deduplicar vam perdre l’historial de canvis.
Exemple: ingestió 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"}'
);Exemple: ingestió 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}")La dada crua és la teva assegurança de vida. Si demà canvies la lògica de transformació, si descobreixes un bug a staging, si el negoci redefineix una mètrica, sempre pots tornar a raw i reprocessar.
La capa staging: netejar, tipar, deduplicar
Staging és on la dada comença a tenir forma. Aquí apliques les transformacions tècniques que no depenen de regles de negoci: tipat correcte, eliminació de duplicats, tractament de nuls, normalització de formats.
Què es fa a staging
| Operació | Exemple |
|---|---|
| Tipat | Convertir "89.99" (string) a 89.99 (decimal) |
| Deduplicació | Quedar-te amb el registre més recent per order_id |
| Nuls | Decidir si un camp nul es descarta, es marca o s’omple |
| Normalització | Unificar formats de data, codis de país, estats |
| Validació bàsica | Rebutjar registres que no compleixen un schema mínim |
Què NO es fa a staging
- No calcules mètriques de negoci.
- No agregues dades (no fas SUM, AVG, COUNT per a informes).
- No apliques regles que només el negoci pot definir.
La frontera entre staging i marts de vegades és difusa. La meva regla és: si la transformació la faria qualsevol enginyer de dades sense conèixer el domini del negoci, va a staging. Si necessites parlar amb producte o finances per saber com calcular-ho, va a marts.
Exemple: transformació 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
);Exemple: transformació 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)A staging l’objectiu és tenir dades netes, tipades i sense duplicats. Res més i res menys. Si arribes aquí amb dades fiables, marts es converteix en un problema molt més senzill.
La capa marts: models de negoci i mètriques
Marts és on les dades es converteixen en respostes. Aquí crees les taules, vistes i models que consumeix el negoci: dashboards, informes, KPIs, models predictius. Les transformacions a marts són de domini, no tècniques.
Què es fa a marts
- Càlculs de negoci: revenue, churn, LTV, conversion rate.
- Agregacions: vendes per mes, comandes per client, tickets per categoria.
- Joins entre entitats de staging per crear vistes de negoci.
- Models dimensionals (fact tables, dimension tables) si el teu warehouse ho requereix.
Exemple: mart de vendes diàries
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;Exemple: mart de clients per a l’equip de producte
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 diferència clau amb staging és que marts té opinió de negoci. “Confirmed revenue” només compta comandes amb status completed. Aquesta regla no és tècnica; és una decisió de finances. Si demà decideixen que shipped també compta com a revenue confirmat, canvies el mart, no staging ni raw.
El flux complet: de la font al dashboard
Per veure-ho de forma global, aquest és el pipeline amb les tres capes:
Font (API, DB, CSV)
│
▼
┌────────┐
│ RAW │ Dada crua + timestamp d'ingestió
│ │ Immutable, sense transformar
└───┬────┘
│
▼
┌──────────┐
│ STAGING │ Tipat, deduplicat, nuls tractats
│ │ Transformacions tècniques
└────┬─────┘
│
▼
┌────────┐
│ MARTS │ Mètriques, KPIs, vistes de negoci
│ │ Transformacions de domini
└───┬────┘
│
▼
Dashboard / API / Model MLOrquestació amb un script Python senzill
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()Errors habituals que trenquen el pipeline
Després de muntar i mantenir diversos pipelines amb aquesta estructura, hi ha patrons d’error que es repeteixen. Els més perillosos no són els bugs tècnics, sinó les decisions de disseny que semblen inofensives al principi.
1. Transformar a raw
El clàssic. “Només normalitzo les dates abans de guardar.” Sembla raonable fins que descobreixes que la font va canviar el format de data sense avisar i la teva normalització porta setmanes descartant registres vàlids. A raw no es toca res. Punt.
2. Barrejar staging i marts
Posar càlculs de negoci a staging és temptador quan vas amb pressa. “Ja que estic netejant, calculo també el revenue.” El problema arriba quan la definició de revenue canvia i has de tocar staging, cosa que invalida totes les taules que en depenen.
3. No versionar els schemas
Si la teva taula de staging té 12 columnes avui i demà la font afegeix 3 camps nous, el teu pipeline es trenca. O pitjor: segueix funcionant però ignora els camps nous silenciosament. Versionar els schemas ---encara que sigui amb un document o un fitxer YAML--- t’avisa quan alguna cosa canvia.
# 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 tenir _ingested_at a raw
Sense timestamp d’ingestió no pots saber quan va arribar una dada. Si necessites reprocessar “les dades que van arribar ahir”, sense aquest camp estàs perdut. És un camp que costa zero afegir i que salva situacions constantment.
5. Taules marts sense documentació de regles de negoci
Si algú mira confirmed_revenue al teu mart i no hi ha cap lloc que expliqui que només inclou comandes amb status completed, assumirà el que li convingui. Documenta les regles de negoci al costat del SQL o al propi schema.
Quan aquesta estructura és massa
No tot necessita tres capes formals. Si el teu pipeline és un script que llegeix un CSV de 200 files i el carrega a un Google Sheet per a un informe mensual, muntar raw/staging/marts seria sobreenginyeria.
El meu criteri per decidir quan val la pena:
| Situació | Raw/Staging/Marts | Un script directe |
|---|---|---|
| Més d’una font de dades | Sí | Probablement no |
| Les dades es reprocessen periòdicament | Sí | No |
| Diverses persones consumeixen les mètriques | Sí | Depèn |
| Les dades crues poden canviar de format | Sí | Risc alt |
| Pipeline d’un sol ús, anàlisi puntual | No | Sí |
| Dashboard en producció amb SLA | Sí | Mai |
Si el teu pipeline té més d’una font, s’executa de forma recurrent i més d’una persona en consumeix els resultats, les tres capes t’estalviaran problemes. És una inversió petita que escala bé.
Relació amb ETL i ELT
Si véns de l’article anterior, això encaixa així:
- En un flux ETL clàssic, les transformacions de staging ocorren abans de carregar al warehouse. Raw pot ser un directori temporal d’arxius o una taula de pas.
- En un flux ELT modern, tot arriba primer al warehouse (raw), i les transformacions de staging i marts es fan amb SQL dins del propi warehouse. Eines com dbt encaixen perfectament en aquest model.
L’estructura raw/staging/marts no competeix amb ETL o ELT. És complementària. Defineix l’organització interna del destí, independentment de com arribin les dades.
El que m’hauria agradat saber abans
Quan vaig muntar el meu primer pipeline amb aquestes capes, vaig cometre gairebé tots els errors que he descrit. El que va canviar la meva perspectiva va ser deixar de pensar en les capes com una burocràcia tècnica i començar a veure-les com un contracte: raw promet immutabilitat, staging promet neteja, marts promet rellevància de negoci.
Cada capa té una única responsabilitat. I com en el codi, quan una peça té una sola raó per canviar, el sistema sencer és més fàcil de mantenir, depurar i fer evolucionar.
Si ja tens un pipeline funcionant i no té capes, no cal reescriure’l de cop. Comença per separar raw. Només això ---guardar la dada crua abans de tocar-la--- ja canvia les regles del joc.


