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
| PostgreSQL | replicación lógica, o una columna actualizado_en puesta por ti |
| MySQL | el binlog, que se suele leer con Debezium |
| SQL Server | Change Data Capture, incluido en el motor |
| SQLite | no 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.