Módulo 4: Slowly Changing Dimensions

Implementando SCD tipo 2 con MERGE INTO

Descripción

La lección 4 historizó P002 a mano, con dos statements que tú mismo escribiste, ordenaste y ejecutaste. Esta lección hace exactamente lo mismo, pero con la herramienta que DuckDB diseñó específicamente para este patrón: MERGE INTO. En vez de escribir un UPDATE y confiar en que el orden sea el correcto, MERGE INTO compara una tabla de stagingstaging_product, la instantánea nueva del catálogo— contra dim_product_scd en un solo statement, decide qué filas cambiaron de verdad, y las cierra de forma segura. Vas a correr el MERGE dos veces: la primera con products_v1 (idéntico al estado actual, cero cambios reales), la segunda con products_v2 (el cambio real de P002) — y vas a confirmar, con la salida literal de ambas corridas, que el statement es seguro de repetir cuando nada cambió, y que historiza correctamente cuando algo sí cambia.

Conexión con el módulo. Esta es la lección central del módulo —la que el diseño de esta guía llama, explícitamente, la pieza "estrella"—. Todo lo anterior —el problema (lección 2), la solución que pierde historia (lección 3), la solución manual que la preserva (lección 4)— existe para que esta lección tenga sentido: MERGE INTO no es un statement mágico, es la automatización, verificada línea por línea contra la documentación oficial de DuckDB, de exactamente la misma mecánica de "cerrar la vieja, abrir la nueva" que ya construiste a mano.

Una analogía: el mismo trámite, pero en una sola ventanilla

En la lección 4, historizar el cambio de P002 fue como hacer un trámite en dos ventanillas distintas de la misma oficina: primero ibas a la ventanilla de "cerrar registros vencidos", después a la ventanilla de "abrir registros nuevos", y tenías que hacer las dos filas en el orden correcto para que el sistema nunca quedara en un estado inconsistente. MERGE INTO es la oficina que rediseñó su proceso: una sola ventanilla, un solo formulario, que internamente decide —comparando lo que ya existe contra lo que acaba de llegar— qué registros cerrar y cuáles crear, sin que la persona que hace el trámite tenga que preocuparse por el orden. El resultado final es idéntico al de las dos ventanillas —lo confirmaste en la lección 4—, pero el riesgo de que alguien haga el trámite en el orden equivocado desaparece, porque ya no hay dos pasos separados que un humano tenga que secuenciar correctamente.

Ejemplo trabajado: MERGE INTO, corrido dos veces

Primero, la tabla dim_product_scd, en el mismo estado inicial de la lección 4 —vigente desde el 1 de agosto de 2026, sin ningún cambio todavía—:

# scd_type2_merge.py
import duckdb

from kiosko import DIM_PRODUCT

con = duckdb.connect()
con.execute("CREATE SEQUENCE product_key_seq START 1")
con.execute("""
    CREATE TABLE dim_product_scd (
        product_key  INTEGER PRIMARY KEY,
        product_id   VARCHAR NOT NULL,
        product_name VARCHAR,
        category     VARCHAR,
        unit_cost    DOUBLE,
        valid_from   DATE NOT NULL,
        valid_to     DATE,
        is_current   BOOLEAN NOT NULL DEFAULT true
    )
""")
con.executemany(
    "INSERT INTO dim_product_scd VALUES (nextval('product_key_seq'), ?, ?, ?, ?, DATE '2026-08-01', NULL, true)",
    [(p["product_id"], p["product_name"], p["category"], p["unit_cost"]) for p in DIM_PRODUCT],
)

Ahora, las dos instantáneas de la lección 2 —products_v1 (sin cambios), products_v2 (el cambio real de P002)— cargadas, una a la vez, como tabla de staging:

PRODUCTS_V1 = [
    ("P001", "Bottled Water 600ml", "beverages", 0.40),
    ("P002", "Energy Bar", "snacks", 0.60),
    ("P003", "Instant Coffee Sachet", "beverages", 0.35),
    ("P004", "Phone Charger Cable", "electronics", 2.10),
]
PRODUCTS_V2 = [
    ("P001", "Bottled Water 600ml", "beverages", 0.40),
    ("P002", "Energy Bar", "health-snacks", 0.68),
    ("P003", "Instant Coffee Sachet", "beverages", 0.35),
    ("P004", "Phone Charger Cable", "electronics", 2.10),
]


def load_staging(products):
    con.execute("DROP TABLE IF EXISTS staging_product")
    con.execute("""
        CREATE TABLE staging_product (
            product_id VARCHAR, product_name VARCHAR, category VARCHAR, unit_cost DOUBLE
        )
    """)
    con.executemany("INSERT INTO staging_product VALUES (?, ?, ?, ?)", products)

Y el MERGE INTO en sí — el statement central de esta lección, verificado contra la guía oficial de DuckDB "Merge Statement for SCD Type 2":

def merge_scd(change_date):
    print(f"--- MERGE INTO dim_product_scd (change_date = {change_date}) ---")
    result = con.sql(f"""
        MERGE INTO dim_product_scd AS target
        USING staging_product AS source
        ON target.product_id = source.product_id AND target.is_current = true
        WHEN MATCHED AND (
            target.unit_cost <> source.unit_cost OR
            target.category  <> source.category
        ) THEN UPDATE SET
            valid_to   = DATE '{change_date}' - INTERVAL 1 DAY,
            is_current = false
        RETURNING merge_action, product_id, category, unit_cost, valid_to, is_current
    """)
    print(result)

    # Segundo statement: abre la fila nueva SOLO para los product_id que el MERGE
    # acaba de cerrar en esta corrida -- el RETURNING de arriba es, literalmente,
    # esa lista, capturada con fetchall() antes de construir el INSERT.
    changed_ids = [row[1] for row in result.fetchall()]
    if changed_ids:
        placeholders = ", ".join("?" for _ in changed_ids)
        con.execute(f"""
            INSERT INTO dim_product_scd (product_key, product_id, product_name, category, unit_cost, valid_from, valid_to, is_current)
            SELECT nextval('product_key_seq'), source.product_id, source.product_name, source.category, source.unit_cost,
                   DATE '{change_date}', NULL, true
            FROM staging_product AS source
            WHERE source.product_id IN ({placeholders})
        """, changed_ids)

Qué esperar (MERGE #1, con products_v1 — sin cambios reales). Al correr load_staging(PRODUCTS_V1) seguido de merge_scd("2026-08-15"), la salida es exactamente esta:

--- MERGE INTO dim_product_scd (change_date = 2026-08-15) ---
┌──────────────┬────────────┬──────────┬───────────┬──────────┬────────────┐
│ merge_action │ product_id │ category │ unit_cost │ valid_to │ is_current │
│   varchar    │  varchar   │ varchar  │  double   │   date   │  boolean   │
└──────────────┴────────────┴──────────┴───────────┴──────────┴────────────┘
                                   0 rows

Cero filas. Ningún producto de products_v1 difiere de la versión vigente en dim_product_scd —tiene sentido, porque products_v1 es idéntico al catálogo con el que la tabla se inicializó—, así que la cláusula WHEN MATCHED AND (...) no se activa para ninguna fila. Detente en esto, porque es la primera confirmación importante de la lección: correr MERGE INTO con datos sin cambios es completamente seguro. No historiza nada de más, no crea filas fantasma, no rompe nada — es idempotente frente a datos sin cambios, exactamente la propiedad que necesitas si vas a correr este mismo proceso todos los días, sin importar si hubo un cambio real ese día o no.

Qué esperar (MERGE #2, con products_v2 — el cambio real de P002). Al correr load_staging(PRODUCTS_V2) seguido de merge_scd("2026-08-15"), la salida es exactamente esta:

--- MERGE INTO dim_product_scd (change_date = 2026-08-15) ---
┌──────────────┬────────────┬──────────┬───────────┬────────────┬────────────┐
│ merge_action │ product_id │ category │ unit_cost │  valid_to  │ is_current │
│   varchar    │  varchar   │ varchar  │  double   │    date    │  boolean   │
├──────────────┼────────────┼──────────┼───────────┼────────────┼────────────┤
│ UPDATE       │ P002       │ snacks   │       0.6 │ 2026-08-14 │ false      │
└──────────────┴────────────┴──────────┴───────────┴────────────┴────────────┘

Ahora sí: una fila, merge_action = 'UPDATE', exactamente P002 —la única fila donde category o unit_cost en staging_product difieren de la versión vigente en dim_product_scd—. Fíjate en las columnas devueltas: category y unit_cost muestran los valores viejos (snacks, 0.6) —el RETURNING de un MERGE refleja el estado de la fila después de aplicar el UPDATE, y como este UPDATE solo modifica valid_to e is_current, category y unit_cost siguen siendo los valores de la versión que se acaba de cerrar—. Después del MERGE, el INSERT de seguimiento crea la fila nueva. El estado final:

print("\n=== dim_product_scd, estado final tras las dos corridas del MERGE ===")
print(con.sql("SELECT * FROM dim_product_scd ORDER BY product_id, product_key"))

print("\n=== Verificacion: P002 tiene exactamente 2 filas, 1 vigente ===")
print(con.sql("""
    SELECT product_id, COUNT(*) AS total_versions,
           SUM(CASE WHEN is_current THEN 1 ELSE 0 END) AS current_versions
    FROM dim_product_scd WHERE product_id = 'P002' GROUP BY product_id
"""))
=== dim_product_scd, estado final tras las dos corridas del MERGE ===
┌─────────────┬────────────┬───────────────────────┬───────────────┬───────────┬────────────┬────────────┬────────────┐
│ product_key │ product_id │     product_name      │   category    │ unit_cost │ valid_from │  valid_to  │ is_current │
│    int32    │  varchar   │        varchar        │    varchar    │  double   │    date    │    date    │  boolean   │
├─────────────┼────────────┼───────────────────────┼───────────────┼───────────┼────────────┼────────────┼────────────┤
│           1 │ P001       │ Bottled Water 600ml   │ beverages     │       0.4 │ 2026-08-01 │ NULL       │ true       │
│           2 │ P002       │ Energy Bar            │ snacks        │       0.6 │ 2026-08-01 │ 2026-08-14 │ false      │
│           5 │ P002       │ Energy Bar            │ health-snacks │      0.68 │ 2026-08-15 │ NULL       │ true       │
│           3 │ P003       │ Instant Coffee Sachet │ beverages     │      0.35 │ 2026-08-01 │ NULL       │ true       │
│           4 │ P004       │ Phone Charger Cable   │ electronics   │       2.1 │ 2026-08-01 │ NULL       │ true       │
└─────────────┴────────────┴───────────────────────┴───────────────┴───────────┴────────────┴────────────┴────────────┘

=== Verificacion: P002 tiene exactamente 2 filas, 1 vigente ===
┌────────────┬────────────────┬──────────────────┐
│ product_id │ total_versions │ current_versions │
│  varchar   │     int64      │      int128      │
├────────────┼────────────────┼──────────────────┤
│ P002       │              2 │                1 │
└────────────┴────────────────┴──────────────────┘

Exactamente el mismo resultado final que la lección 4 —cinco filas en total, P002 con dos versiones, product_key = 2 y product_key = 5—, pero producido por dos corridas de un statement genérico, capaz de manejar cuatro productos o cuatro mil sin cambiar una línea de código.

Diagrama: qué hace cada parte del MERGE

flowchart TD
    A["staging_product\n(la instantanea nueva)"] --> C{"ON target.product_id = source.product_id\nAND target.is_current = true"}
    B["dim_product_scd\n(la version vigente de cada producto)"] --> C
    C -->|"coincide, Y category/unit_cost\nCAMBIARON"| D["WHEN MATCHED AND (...)\nTHEN UPDATE SET valid_to, is_current=false"]
    C -->|"coincide, sin cambios reales"| E["ninguna accion\n(el producto ya esta al dia)"]
    D --> F["INSERT de seguimiento\nabre la fila nueva,\nis_current=true"]
Que compara ON target.product_id = source.product_id AND target.is_current = true
──────────────────────────────────────────────────────────────────────────────────
staging_product (source)         dim_product_scd, SOLO la fila vigente (target)
P001 beverages    0.40      <->  P001 beverages    0.40   (vigente)  -- sin cambio
P002 health-snacks 0.68     <->  P002 snacks       0.60   (vigente)  -- CAMBIO real
P003 beverages    0.35      <->  P003 beverages    0.35   (vigente)  -- sin cambio
P004 electronics  2.10      <->  P004 electronics  2.10   (vigente)  -- sin cambio

Profundización: por qué el MERGE necesita un INSERT de seguimiento, y qué queda fuera a propósito

Es razonable preguntarse por qué, si MERGE INTO es tan capaz, no puede cerrar la fila vieja y abrir la fila nueva en el mismo statement. La razón es la misma que ya viste en la lección 4, ahora aplicada a MERGE: la condición ON target.product_id = source.product_id AND target.is_current = true hace que el MERGE encuentre, para P002, una coincidencia —la fila vigente actual—, y esa coincidencia dispara exactamente una rama (WHEN MATCHED). No existe una forma de que esa misma fila fuente dispare, a la vez, un UPDATE sobre la fila vieja y un INSERT de una fila nueva —cada fila de origen produce, como máximo, una acción por rama—. La guía oficial de DuckDB resuelve esto exactamente como esta lección: el MERGE cierra las versiones que cambiaron, y un INSERT de seguimiento —un statement aparte, ejecutado inmediatamente después— abre la versión nueva para cada producto recién cerrado.

Vale la pena detenerse en cómo ese INSERT de seguimiento decide, con precisión, para cuáles productos abrir una fila nueva. La guía oficial de DuckDB filtra por target.end_date = CURRENT_DATE - INTERVAL '1 day' — en un pipeline de producción real, que corre una sola vez por día con la fecha de hoy, ese filtro identifica sin ambigüedad "lo que se acaba de cerrar", porque nada más pudo haberse cerrado con la fecha de ayer en la misma corrida. Esta lección adapta esa idea con una fecha fija ('2026-08-15'), pero una fecha fija tiene un riesgo que CURRENT_DATE no tiene: si el MERGE se corre más de una vez con la misma change_date —exactamente lo que el ejercicio 1 de esta lección va a hacerte hacer—, un filtro basado solo en la fecha volvería a encontrar la misma fila ya cerrada en corridas anteriores, y insertaría una versión duplicada cada vez. Por eso merge_scd() no filtra por fecha para decidir qué insertar: captura, con result.fetchall(), la lista exacta de product_id que esta corrida específica acaba de cerrar —vacía si nada cambió, con P002 solo la primera vez que cambia de verdad—, y el INSERT de seguimiento se limita a esa lista. Es la misma garantía de idempotencia que ya viste en el MERGE #1, ahora extendida también al INSERT que lo acompaña.

Vale la pena nombrar, sin implementarlo en Kiosko, el patrón completo que la guía oficial de DuckDB documenta, porque un catálogo de producción rara vez es tan estable como el de Kiosko en este ejercicio. El patrón completo incluye dos cláusulas adicionales que esta lección no necesita: WHEN NOT MATCHED BY SOURCE AND target.is_current = true THEN UPDATE SET ... — cierra la versión vigente de cualquier producto que desapareció de la fuente (por ejemplo, si Kiosko descontinuara P004 por completo); y WHEN NOT MATCHED BY TARGET THEN INSERT (...) — inserta directamente cualquier producto que sea completamente nuevo, sin ninguna versión previa en dim_product_scd (por ejemplo, si Kiosko agregara un P005 a su catálogo). Esta lección no las implementa porque el catálogo de Kiosko, en este módulo, siempre tiene exactamente los mismos cuatro productos —ninguno se agrega, ninguno se descontinúa—, así que esas dos ramas nunca se activarían con los datos de este ejercicio. En un catálogo de producción real, donde los productos sí entran y salen, esas dos cláusulas son tan necesarias como la que sí implementaste — la guía oficial de DuckDB, citada en los Recursos de esta lección, muestra el patrón de las tres ramas juntas.

Errores comunes

El error clásico: olvidar cerrar la fila vieja, y terminar con dos is_current = true. Qué pasa: alguien, apurado, corre solo el INSERT de la fila nueva de P002 —sin el MERGE que la precede, o con un MERGE cuya condición WHEN MATCHED AND (...) nunca se cumple por un error de tipeo—, y termina con dos filas de P002, ambas con is_current = true. Verifícalo tú mismo, en un script aparte (scd_error_demo.py), que reconstruye dim_product_scd exactamente como al principio de esta lección, pero esta vez con solo el INSERT, sin el MERGE que debería precederlo:

# scd_error_demo.py -- el error clasico, aislado, para verlo con evidencia
import duckdb

from kiosko import DIM_PRODUCT

con = duckdb.connect()
con.execute("CREATE SEQUENCE product_key_seq START 1")
con.execute("""
    CREATE TABLE dim_product_scd (
        product_key INTEGER PRIMARY KEY, product_id VARCHAR NOT NULL, product_name VARCHAR,
        category VARCHAR, unit_cost DOUBLE, valid_from DATE NOT NULL, valid_to DATE,
        is_current BOOLEAN NOT NULL DEFAULT true
    )
""")
con.executemany(
    "INSERT INTO dim_product_scd VALUES (nextval('product_key_seq'), ?, ?, ?, ?, DATE '2026-08-01', NULL, true)",
    [(p["product_id"], p["product_name"], p["category"], p["unit_cost"]) for p in DIM_PRODUCT],
)

# El error: INSERT de la fila nueva SIN el MERGE que cierra la vieja primero.
con.execute("""
    INSERT INTO dim_product_scd (product_key, product_id, product_name, category, unit_cost, valid_from, valid_to, is_current)
    VALUES (nextval('product_key_seq'), 'P002', 'Energy Bar', 'health-snacks', 0.68, DATE '2026-08-15', NULL, true)
""")
print(con.sql("SELECT product_key, product_id, category, unit_cost, valid_from, valid_to, is_current FROM dim_product_scd WHERE product_id = 'P002' ORDER BY product_key"))
print(con.sql("""
    SELECT product_id, COUNT(*) AS total_versions, SUM(CASE WHEN is_current THEN 1 ELSE 0 END) AS current_versions
    FROM dim_product_scd WHERE product_id = 'P002' GROUP BY product_id
"""))
┌─────────────┬────────────┬───────────────┬───────────┬────────────┬──────────┬────────────┐
│ product_key │ product_id │   category    │ unit_cost │ valid_from │ valid_to │ is_current │
│    int32    │  varchar   │    varchar    │  double   │    date    │   date   │  boolean   │
├─────────────┼────────────┼───────────────┼───────────┼────────────┼──────────┼────────────┤
│           2 │ P002       │ snacks        │       0.6 │ 2026-08-01 │ NULL     │ true       │
│           5 │ P002       │ health-snacks │      0.68 │ 2026-08-15 │ NULL     │ true       │
└─────────────┴────────────┴───────────────┴───────────┴────────────┴──────────┴────────────┘

┌────────────┬────────────────┬──────────────────┐
│ product_id │ total_versions │ current_versions │
│  varchar   │     int64      │      int128      │
├────────────┼────────────────┼──────────────────┤
│ P002       │              2 │                2 │
└────────────┴────────────────┴──────────────────┘

current_versions = 2 — el error clásico, en números. Fíjate en la fila con product_key = 2: sigue con valid_to = NULL, exactamente como si nunca hubiera cambiado — nadie la cerró—. Por qué pasa: en un pipeline con varios pasos, es fácil que un cambio en el orden de ejecución, un reintento parcial después de un fallo, o simplemente un INSERT copiado y pegado sin su MERGE correspondiente, dejen la fila vieja sin cerrar. Cómo detectarlo: exactamente con la consulta de arriba —SUM(CASE WHEN is_current THEN 1 ELSE 0 END) agrupado por product_id nunca debería superar 1—; conviértela en una verificación de calidad de datos que corres después de cada MERGE, no en algo que descubres por accidente. Cómo corregirlo: cualquier product_id con más de una fila is_current = true significa que, en algún punto, un INSERT se ejecutó sin su UPDATE de cierre correspondiente — la solución es siempre la misma disciplina de la lección 4: cerrar antes de abrir, y verificar después de cada corrida que ningún product_id quedó con más de una versión vigente.

Confundir WHEN MATCHED con "el producto existe en staging", en vez de "el producto existe Y su versión vigente coincide". Qué pasa: alguien escribe la condición ON sin AND target.is_current = true, es decir, ON target.product_id = source.product_id a secas. Con una sola versión por producto (antes del primer cambio), esto no muestra ningún problema — pero después de que P002 ya tenga dos versiones, el MERGE encontraría dos filas de destino que coinciden con la misma fila de origen (product_id = 'P002'), una ambigüedad que puede hacer que el UPDATE se aplique sobre la fila equivocada —la ya cerrada, en vez de la vigente—. Por qué pasa: con un catálogo pequeño, sin historia todavía, el filtro is_current = true parece innecesario. Cómo detectarlo: si después de un segundo cambio de P002 el MERGE reporta un error de "múltiples filas coinciden" o, peor, modifica silenciosamente la fila histórica en vez de la vigente, te falta este filtro. Cómo corregirlo: la condición ON de un MERGE sobre una dimensión SCD-2 siempre debe restringir el lado del target a la fila vigente — AND target.is_current = true, sin excepción, exactamente como en el patrón oficial de DuckDB.

Ejecutar el MERGE sin la tabla staging_product recién cargada, reusando datos de una corrida anterior. Qué pasa: alguien corre merge_scd("2026-08-15") una segunda vez, con la intención de simular "un tercer cambio", pero olvida llamar primero a load_staging(...) con una instantánea nueva — staging_product sigue teniendo los mismos datos de la corrida anterior. Por qué pasa: es fácil asumir que el MERGE "recuerda" cuál fue el último cambio aplicado, cuando en realidad compara, cada vez, contra lo que sea que esté en staging_product en ese momento. Cómo detectarlo: si el MERGE reporta 0 rows cuando esperabas un cambio nuevo, verifica primero qué contiene staging_product en ese momento — es la causa más común de un MERGE que "no hace nada" sin ningún error visible. Cómo corregirlo: staging_product siempre debe recargarse con la instantánea correcta antes de cada corrida del MERGE — exactamente el patrón load_staging(PRODUCTS_V1) / load_staging(PRODUCTS_V2) de esta lección, nunca asumido implícito.

Ejercicios

Ejercicio 1 — Corre el MERGE una tercera vez, con products_v2 otra vez, y explica el resultado. Sin cambiar nada del catálogo, vuelve a llamar load_staging(PRODUCTS_V2) seguido de merge_scd("2026-08-15"). Predice, antes de correrlo, si el MERGE va a reportar algún cambio.

Ver solución
load_staging(PRODUCTS_V2)
merge_scd("2026-08-15")
print(con.sql("SELECT COUNT(*) AS total_rows FROM dim_product_scd"))

Salida esperada:

┌──────────────┬────────────┬──────────┬───────────┬──────────┬────────────┐
│ merge_action │ product_id │ category │ unit_cost │ valid_to │ is_current │
│   varchar    │  varchar   │ varchar  │  double   │   date   │  boolean   │
└──────────────┴────────────┴──────────┴───────────┴──────────┴────────────┘
                                   0 rows

┌────────────┐
│ total_rows │
│   int64    │
├────────────┤
│          5 │
└────────────┘

Cero filas cambiadas — porque la versión vigente de P002 en dim_product_scd ya es health-snacks/0.68, idéntica a lo que trae products_v2. total_rows sigue en 5, sin crecer. Esta es la misma propiedad de idempotencia del MERGE #1 de la lección: correr el mismo MERGE con datos que ya coinciden con el estado vigente no historiza nada de más, sin importar cuántas veces se repita.

Ejercicio 2 — Simula un segundo cambio real: P002 sube de costo otra vez, el 2026-08-25, a 0.72, sin cambiar de categoría. Escribe PRODUCTS_V3, cárgalo como staging, y corre el MERGE con change_date = "2026-08-25". Verifica cuántas versiones tiene P002 al final.

Ver solución
PRODUCTS_V3 = [
    ("P001", "Bottled Water 600ml", "beverages", 0.40),
    ("P002", "Energy Bar", "health-snacks", 0.72),
    ("P003", "Instant Coffee Sachet", "beverages", 0.35),
    ("P004", "Phone Charger Cable", "electronics", 2.10),
]
load_staging(PRODUCTS_V3)
merge_scd("2026-08-25")
print(con.sql("SELECT product_key, category, unit_cost, valid_from, valid_to, is_current FROM dim_product_scd WHERE product_id = 'P002' ORDER BY product_key"))

Salida esperada:

┌─────────────┬───────────────┬───────────┬────────────┬────────────┬────────────┐
│ product_key │   category    │ unit_cost │ valid_from │  valid_to  │ is_current │
│    int32    │    varchar    │  double   │    date    │    date    │  boolean   │
├─────────────┼───────────────┼───────────┼────────────┼────────────┼────────────┤
│           2 │ snacks        │       0.6 │ 2026-08-01 │ 2026-08-14 │ false      │
│           5 │ health-snacks │      0.68 │ 2026-08-15 │ 2026-08-24 │ false      │
│           6 │ health-snacks │      0.72 │ 2026-08-25 │ NULL       │ true       │
└─────────────┴───────────────┴───────────┴────────────┴────────────┴────────────┘

P002 ahora tiene tres versiones — el patrón se repite sin límite, cada cambio real agrega una fila más, con su propio rango de vigencia (2026-08-15 a 2026-08-24 para la segunda versión, cerrada un día antes del tercer cambio). Esto confirma que MERGE INTO no está limitado a "un solo cambio" — historiza cualquier número de cambios sucesivos, siempre que cada staging_product refleje el estado correcto en el momento de cada corrida.

Ejercicio 3 — Explica por qué el RETURNING del MERGE #2 muestra category = 'snacks' (el valor viejo) y no category = 'health-snacks' (el valor nuevo). En 2-3 frases, usando lo que aprendiste sobre qué hace exactamente la cláusula WHEN MATCHED, explica por qué el RETURNING de esta lección muestra los valores de la fila que se cerró, no los de la fila que se abrió.

Ver solución

El RETURNING de un MERGE INTO refleja el estado de la fila del target después de aplicar la acción de esa rama — y la acción de la rama WHEN MATCHED en esta lección es UPDATE SET valid_to = ..., is_current = false, que modifica únicamente esas dos columnas. category y unit_cost nunca se tocan en ese UPDATE — siguen siendo, después del MERGE, los mismos valores que tenía la fila antes de correrlo: snacks y 0.6, los valores viejos. La fila con los valores nuevos (health-snacks, 0.68) no existe todavía en ese punto del script — se crea después, con el INSERT de seguimiento, que es un statement completamente separado y no aparece en el RETURNING del MERGE.

Resumen y siguiente paso

Esta lección implementó SCD tipo 2 de producción: MERGE INTO dim_product_scd USING staging_product, con una condición ON que restringe la comparación a la versión vigente de cada producto, una cláusula WHEN MATCHED AND (...) que detecta cambios reales en category o unit_cost, y un INSERT de seguimiento que abre la versión nueva. Corriste el MERGE dos veces —una sin cambios reales, una con el cambio real de P002— y confirmaste, con RETURNING y con una consulta de verificación, que el resultado es idéntico al de la lección 4, pero producido por un statement genérico y seguro de repetir.

Antes de avanzar deberías poder: escribir de memoria la estructura de MERGE INTO ... USING ... ON ... WHEN MATCHED AND (...) THEN UPDATE SET ...; explicar por qué necesita un INSERT de seguimiento en vez de hacerlo todo en un solo statement; y reproducir, sin mirar, el error clásico de esta lección —olvidar cerrar la fila vieja— y cómo detectarlo con una sola consulta.

La lección 6 le da criterio a todo lo que construiste: no todas las columnas de dim_product_scd merecen el mismo tratamiento — vas a decidir, columna por columna, cuáles necesitan SCD tipo 2 y cuáles se corrigen con SCD tipo 1, incluso dentro de la misma tabla historizada.

Recursos