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.

Cover for ETL vs ELT era només el principi: com organitzar capes raw, staging i marts

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:

  1. Immutabilitat de la dada crua. Sempre pots tornar a l’origen.
  2. Separació de responsabilitats. Cada capa té un propòsit clar.
  3. 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
TipatConvertir "89.99" (string) a 89.99 (decimal)
DeduplicacióQuedar-te amb el registre més recent per order_id
NulsDecidir si un camp nul es descarta, es marca o s’omple
NormalitzacióUnificar formats de data, codis de país, estats
Validació bàsicaRebutjar 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 ML

Orquestació 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: false

4. 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/MartsUn script directe
Més d’una font de dadesProbablement no
Les dades es reprocessen periòdicamentNo
Diverses persones consumeixen les mètriquesDepèn
Les dades crues poden canviar de formatRisc alt
Pipeline d’un sol ús, anàlisi puntualNo
Dashboard en producció amb SLAMai

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.

OshyTech

Enginyeria backend i de dades orientada a sistemes escalables, automatització i IA.

Navegació

Copyright 2026 OshyTech. Tots els drets reservats