Capítulo 16 de 23 7 secciones 13 min

Compartir

La carga de las tres de la mañana

Traer solo lo que llegó desde la última vez, sin duplicar nada y sin perder lo que llegó tarde. Con los dos errores que salen siempre.

Una carga incremental trae solo las filas que aparecieron desde la anterior, en vez de recargar la tabla entera. La marca de agua que decide el corte tiene que ir sobre la hora en que el dato entró y no sobre la fecha del negocio, porque hay filas que llegan tarde. Y la carga tiene que poder correr dos veces sin cambiar el resultado 🧾

En el capítulo 15 armamos el almacén de una vez. Ahora la parte que se repite: mañana llegan los pedidos de hoy, y pasado los de mañana.

Las dos formas de hacerlo son borrar todo y volver a cargar, o traer solo lo nuevo. La primera es más simple y aguanta bastante más de lo que la gente cree; la segunda es la que hace falta cuando la tabla ya no cabe en la ventana de la madrugada.

Preparo la base con una columna que casi ninguna tabla de sistema trae de fábrica y que es el centro del capítulo:

ALTER TABLE pedidos ADD COLUMN cargado_en TEXT;
UPDATE pedidos SET cargado_en = fecha;

SELECT count(*) AS pedidos, min(fecha) AS desde, max(fecha) AS hasta FROM pedidos;
pedidos  desde       hasta
-------  ----------  ----------
900      2025-01-01  2026-06-24

Novecientos pedidos, año y medio. De momento cargado_en es igual a fecha, o sea que hago como si cada pedido hubiera entrado el mismo día que se hizo. Dentro de un rato dejará de ser verdad, y ahí está el capítulo entero 🙂

La marca de agua

La idea es de una línea: guarda hasta dónde cargaste, y la próxima vez trae lo que venga después. Eso que guardas se llama marca de agua, o watermark si estás leyendo documentación.

Lo bonito es que no hace falta guardarla en ningún sitio: se le pregunta a la tabla de destino.

CREATE TABLE ventas_dw (
    id_pedido  INTEGER PRIMARY KEY,
    fecha      TEXT NOT NULL,
    canal      TEXT,
    monto      REAL,
    cargado_en TEXT
);

INSERT INTO ventas_dw (id_pedido, fecha, canal, monto, cargado_en)
SELECT id, fecha, canal, monto, cargado_en FROM pedidos
WHERE cargado_en > (SELECT max(cargado_en) FROM ventas_dw);

SELECT count(*) AS filas_cargadas FROM ventas_dw;
filas_cargadas
--------------
0

Cero 😐

Y no falló nada. La tabla estaba vacía, max(cargado_en) sobre una tabla vacía devuelve NULL, y cualquier comparación contra NULL da NULL, que no es verdadero. El WHERE no dejó pasar ni una fila.

Es la lección de los nulos del capítulo 4 llegando a la peor hora posible: la primera carga de la historia, la que nadie mira porque "acaba de empezar". Se arregla con un coalesce:

INSERT INTO ventas_dw (id_pedido, fecha, canal, monto, cargado_en)
SELECT id, fecha, canal, monto, cargado_en FROM pedidos
WHERE cargado_en > coalesce((SELECT max(cargado_en) FROM ventas_dw), '0000-00-00')
  AND cargado_en < '2026-06-20';

SELECT count(*) AS filas, max(cargado_en) AS marca FROM ventas_dw;
filas  marca
-----  ----------
890    2026-06-19

890 filas y la marca en el 19 de junio. El AND del final está para simular que hoy es 20 de junio y quedan cuatro días por cargar.

Correrla otra vez, que es lo que va a pasar

Ahora la carga de la noche siguiente. Y voy a escribirla con el error que se escribe siempre, que es usar >= en vez de > "por si acaso":

INSERT INTO ventas_dw (id_pedido, fecha, canal, monto, cargado_en)
SELECT id, fecha, canal, monto, cargado_en FROM pedidos
WHERE cargado_en >= (SELECT max(cargado_en) FROM ventas_dw);
IntegrityError: UNIQUE constraint failed: ventas_dw.id_pedido

El >= vuelve a traer el día que ya estaba cargado, y la clave primaria lo caza. Y acá hay que decir algo importante: que reviente es la buena noticia.

Sin esa clave primaria, esas filas habrían entrado dos veces, la carga habría terminado en verde, y la venta del 19 de junio saldría duplicada en el reporte del lunes sin que nadie lo note. La restricción del capítulo 11 está haciendo exactamente su trabajo 💛

Que se pueda correr dos veces: idempotencia

La palabra suena a examen y la idea es simple: correr la carga dos veces tiene que dejar la tabla igual que correrla una.

Hace falta porque las cargas se repiten todo el tiempo. Se cayó a mitad y la relanzas. Alguien la lanzó a mano sin saber que ya había corrido. El servidor se reinició y el planificador la disparó otra vez. Si repetir duplica, cualquiera de esas tres te ensucia el almacén.

La forma es el upsert del capítulo 12:

INSERT INTO ventas_dw (id_pedido, fecha, canal, monto, cargado_en)
SELECT id, fecha, canal, monto, cargado_en FROM pedidos
WHERE cargado_en >= (SELECT max(cargado_en) FROM ventas_dw)
ON CONFLICT (id_pedido) DO UPDATE
SET fecha = excluded.fecha, canal = excluded.canal, monto = excluded.monto,
    cargado_en = excluded.cargado_en;

SELECT count(*) AS filas, max(cargado_en) AS marca,
       round(sum(monto), 2) AS venta FROM ventas_dw;
filas  marca       venta
-----  ----------  ---------
900    2026-06-24  532653.85

Las 900. Y ahora la prueba, que es correr lo mismo otra vez:

INSERT INTO ventas_dw (id_pedido, fecha, canal, monto, cargado_en)
SELECT id, fecha, canal, monto, cargado_en FROM pedidos
WHERE cargado_en >= (SELECT max(cargado_en) FROM ventas_dw)
ON CONFLICT (id_pedido) DO UPDATE
SET fecha = excluded.fecha, canal = excluded.canal, monto = excluded.monto,
    cargado_en = excluded.cargado_en;

SELECT count(*) AS filas, round(sum(monto), 2) AS venta FROM ventas_dw;
filas  venta
-----  ---------
900    532653.85

Mismo número de filas y misma venta. Eso es idempotente, y es lo que te deja dormir cuando la carga falla a las tres de la mañana: relanzas y ya 🌙

Los dos relojes

Acá viene lo que más datos pierde en el mundo real, y es tan silencioso como el JOIN del capítulo anterior.

Entra un pedido con fecha del 10 de junio, que se cargó hoy 25 porque el marketplace lo mandó tarde:

INSERT INTO pedidos (id, id_cliente, fecha, canal, monto, cargado_en)
VALUES (99001, 7, '2026-06-10', 'WhatsApp', 480.5, '2026-06-25');

SELECT (SELECT count(*) FROM pedidos)   AS en_el_origen,
       (SELECT count(*) FROM ventas_dw) AS en_el_almacen,
       (SELECT max(fecha) FROM ventas_dw) AS marca_por_fecha,
       (SELECT max(cargado_en) FROM ventas_dw) AS marca_por_carga;
en_el_origen  en_el_almacen  marca_por_fecha  marca_por_carga
------------  -------------  ---------------  ---------------
901           900            2026-06-24       2026-06-24

901 en el origen, 900 en el almacén. Corre la carga usando la fecha del pedido, que es lo que hace casi todo el mundo:

INSERT INTO ventas_dw (id_pedido, fecha, canal, monto, cargado_en)
SELECT id, fecha, canal, monto, cargado_en FROM pedidos
WHERE fecha >= (SELECT max(fecha) FROM ventas_dw)
ON CONFLICT (id_pedido) DO UPDATE
SET fecha = excluded.fecha, monto = excluded.monto, cargado_en = excluded.cargado_en;

SELECT count(*) AS en_el_almacen FROM ventas_dw;
en_el_almacen
-------------
900

Sigue en 900. La venta de 480,50 soles existe, entró bien, está en el origen, y tu almacén no la va a ver nunca, porque su fecha es más vieja que la marca de agua y ese pedido ya pasó de largo.

La cura es cambiar de reloj:

INSERT INTO ventas_dw (id_pedido, fecha, canal, monto, cargado_en)
SELECT id, fecha, canal, monto, cargado_en FROM pedidos
WHERE cargado_en >= (SELECT max(cargado_en) FROM ventas_dw)
ON CONFLICT (id_pedido) DO UPDATE
SET fecha = excluded.fecha, monto = excluded.monto, cargado_en = excluded.cargado_en;

SELECT (SELECT count(*) FROM pedidos)   AS en_el_origen,
       (SELECT count(*) FROM ventas_dw) AS en_el_almacen;
en_el_origen  en_el_almacen
------------  -------------
901           901

Los dos relojes tienen nombre y conviene sabérselos porque salen en toda la documentación: la fecha del pedido es hora del negocio, y la de carga es hora de ingesta. Se reportan por la primera y se carga por la segunda 📏

Y si el sistema de origen no guarda la hora de ingesta, esa es una petición de las que se piden el día uno, como en cualquier proyecto de datos.

La bitácora, que contesta "¿corrió?"

Una tabla más, y es la que te salva de la peor pregunta de un lunes, que es "¿esto está actualizado?":

CREATE TABLE carga_log (
    id         INTEGER PRIMARY KEY,
    tabla      TEXT,
    corrio_en  TEXT,
    desde      TEXT,
    filas      INTEGER,
    estado     TEXT
);

INSERT INTO carga_log (tabla, corrio_en, desde, filas, estado) VALUES
    ('ventas_dw', '2026-06-20 03:00', '2026-06-19', 10, 'ok'),
    ('ventas_dw', '2026-06-25 03:00', '2026-06-24',  1, 'ok'),
    ('ventas_dw', '2026-06-26 03:00', '2026-06-25',  0, 'sin datos');

SELECT corrio_en, desde, filas, estado FROM carga_log ORDER BY corrio_en;
corrio_en         desde       filas  estado
----------------  ----------  -----  ---------
2026-06-20 03:00  2026-06-19  10     ok
2026-06-25 03:00  2026-06-24  1      ok
2026-06-26 03:00  2026-06-25  0      sin datos

Mira la última fila. Cero filas no es un error: puede ser un domingo sin ventas o puede ser que el origen dejó de mandar. Las dos se ven igual, y por eso se anota en vez de discutirse de memoria.

Enterarse de qué cambió en el origen, sin preguntar tabla por tabla

PostgreSQLreplicación lógica, o una columna actualizado_en puesta por ti
MySQLel binlog, que se suele leer con Debezium
SQL ServerChange Data Capture, incluido en el motor
SQLiteno tiene nada: se pone a mano con un TRIGGER

Esto se llama captura de cambios, y lo cuento para que sepas que existe antes de que alguien te lo nombre en una reunión. Para casi todo lo que vas a hacer, una columna con la hora de ingesta y una marca de agua alcanzan de sobra, y se entienden leyéndolas.

Comprueba que lo tienes

Tu carga de cada noche trae lo que llegó después de la última vez, y usa la fecha del pedido para saber cuál fue la última vez. ¿Dónde está el problema?

  • En un pedido con fecha vieja que entra hoy: la carga nunca lo va a ver
  • En que la fecha del pedido puede venir nula
  • En que hay que ordenar por fecha antes de cargar
  • En nada, es la forma correcta

Ejercicios

1. La ventana, que es más simple que la marca

En vez de traer lo nuevo, borra los últimos días y vuélvelos a cargar.

DELETE FROM ventas_dw WHERE cargado_en >= '2026-06-18';

INSERT INTO ventas_dw (id_pedido, fecha, canal, monto, cargado_en)
SELECT id, fecha, canal, monto, cargado_en FROM pedidos
WHERE cargado_en >= '2026-06-18';

SELECT count(*) AS filas, round(sum(monto), 2) AS venta FROM ventas_dw;
filas  venta
-----  ---------
901    533134.35

Borrar y reinsertar una ventana de días es idempotente sin necesitar upsert, y se lee de un vistazo. Es lo que yo haría en un proyecto chico.

Lo que hay que cuidar es que el borrado y la inserción vayan en la misma transacción. Si se cae en medio, la ventana queda vacía y el reporte de esa mañana enseña un agujero que no existe.

2. El monto que cambió después

Un pedido se corrige en el origen tres días más tarde. ¿Llega la corrección?

UPDATE pedidos SET monto = monto + 100 WHERE id = 99001;

INSERT INTO ventas_dw (id_pedido, fecha, canal, monto, cargado_en)
SELECT id, fecha, canal, monto, cargado_en FROM pedidos WHERE id = 99001
ON CONFLICT (id_pedido) DO UPDATE SET monto = excluded.monto;

SELECT id_pedido, monto FROM ventas_dw WHERE id_pedido = 99001;
id_pedido  monto
---------  -----
99001      580.5

Llega, porque el upsert actualiza. Pero fíjate en la trampa: la traje diciendo WHERE id = 99001, o sea que yo ya sabía cuál había cambiado.

Una carga de verdad no lo sabe. Si el sistema no toca cargado_en al corregir una fila, la corrección es invisible para la marca de agua. Por eso esa columna se actualiza en cada cambio y no solo al insertar 🧾

3. El borrado que el incremental no ve nunca

El pedido se anula en el origen. Mira qué pasa.

DELETE FROM pedidos WHERE id = 99001;

SELECT (SELECT count(*) FROM pedidos)   AS en_el_origen,
       (SELECT count(*) FROM ventas_dw) AS en_el_almacen,
       (SELECT count(*) FROM ventas_dw v
        WHERE NOT EXISTS (SELECT 1 FROM pedidos p WHERE p.id = v.id_pedido)) AS fantasmas;
en_el_origen  en_el_almacen  fantasmas
------------  -------------  ---------
900           901            1

Un fantasma: una venta que el almacén sigue contando y que en el origen ya no existe. Ninguna carga incremental lo puede detectar sola, porque las filas borradas no aparecen en ningún SELECT.

Las tres salidas: pedirle al sistema que no borre y marque como anulado, que es lo mejor; correr una comparación completa de vez en cuando, que es esta misma consulta; o recargar todo de tanto en tanto. La primera se pide, las otras dos se programan.

4. Que la bitácora te hable

Escribe la consulta que quieres que alguien mire cada mañana.

SELECT corrio_en, filas, estado FROM carga_log
WHERE filas = 0 OR estado <> 'ok';
corrio_en         filas  estado
----------------  -----  ---------
2026-06-26 03:00  0      sin datos

Una consulta de dos líneas. Si devuelve algo, alguien mira; si no devuelve nada, nadie tiene que hacer nada.

Eso último es lo que la hace útil. Un tablero de cargas que hay que abrir y revisar deja de abrirse a las tres semanas; una consulta que solo habla cuando hay algo que decir sobrevive años 📏

5. El dato sucio que congela la carga para siempre

Entra un pedido con fecha del año 2099, de esos que salen de un formulario mal llenado.

INSERT INTO pedidos (id, id_cliente, fecha, canal, monto, cargado_en)
VALUES (99002, 7, '2099-01-01', 'Web', 12.0, '2099-01-01');

INSERT INTO ventas_dw (id_pedido, fecha, canal, monto, cargado_en)
SELECT id, fecha, canal, monto, cargado_en FROM pedidos
WHERE cargado_en > (SELECT max(cargado_en) FROM ventas_dw)
ON CONFLICT (id_pedido) DO UPDATE SET monto = excluded.monto;

SELECT max(cargado_en) AS marca_de_agua,
       (SELECT count(*) FROM pedidos
        WHERE cargado_en > (SELECT max(cargado_en) FROM ventas_dw)) AS lo_que_traeria_manana
FROM ventas_dw;
marca_de_agua  lo_que_traeria_manana
-------------  ---------------------
2099-01-01     0

La marca de agua se fue al 2099 y a partir de ahora ninguna carga va a traer nada. Durante setenta y tres años.

Y lo peor es cómo se ve desde fuera: la carga corre todas las noches, termina en verde, y trae cero filas. Nadie sospecha de un proceso que no falla.

La defensa es un tope: AND cargado_en <= date('now') en el WHERE, y una fila en la bitácora avisando de que había datos del futuro. Un dato imposible tiene que ser ruidoso 🚨

6. Completa o incremental, en tu caso

Cuatro tablas. ¿Cuál cargarías entera cada noche?

El maestro de productos, 40 filas. Entera. Escribir una carga incremental para 40 filas es trabajo que se paga en errores y no ahorra nada.

El maestro de clientes, 120 filas que casi no cambian. Entera también, y de paso te resuelve gratis el problema de los borrados del ejercicio 3.

Las líneas de venta, 2.682 y creciendo cada día. Acá el incremental empieza a tener sentido, y aun así una tabla de este tamaño se recarga en segundos. La pregunta no es cuántas filas tiene hoy, es cuántas va a tener en tres años.

El registro de clics de la web, millones al mes. Incremental sin discusión, y por día. Esa es la tabla donde la carga completa deja de caber en la madrugada, que es el único motivo de verdad para complicarse 💛

Preguntas frecuentes

¿Qué es un watermark en un pipeline de datos?

La marca de hasta dónde cargaste la última vez. La siguiente carga trae solo lo que venga después, y se le pregunta a la propia tabla de destino.

¿Qué es la idempotencia?

Que correr algo dos veces deje el mismo resultado que correrlo una. Es lo que te deja relanzar una carga que se cayó sin pensarlo dos veces.

¿Qué es una carga incremental?

Traer solo lo nuevo en vez de recargar la tabla entera. Hace falta cuando la carga completa ya no cabe en la ventana de la madrugada, y no antes.

Practica este capítulo 📓

Todo el código de arriba en un cuaderno que corre de principio a fin, y los ejercicios con una celda vacía para que los hagas tú. Se abre en Google Colab de un clic y no hay que instalar nada. Donde veas %%revisa, escribe tu respuesta y el cuaderno te dice si te salió.

¿Prefieres trabajar en tu máquina? Bájate el cuaderno de práctica o el de soluciones. Todos están también en github.com/soymissyera/MissYeraEjercicios.

¿Le sirve a alguien que conoces?

Pásale el libro. Es gratis, está entero y no pide registro 🐣

Instagram y TikTok no dejan compartir enlaces desde la web: esos dos copian la URL para que la pegues en tu historia.

¿Tienes alguna duda o consulta?