Todo el libro trabajaste contra tienda.db, que es la base de un
sistema: cinco tablas, cada dato en un solo sitio, y claves que las unen. En el
capítulo 10 vimos por qué está así.
Ahora mira lo que cuesta una pregunta de negocio de las normales:
SELECT c.ciudad, p.canal, round(sum(d.cantidad * d.precio_unit), 2) AS venta FROM detalle d JOIN pedidos p ON p.id = d.id_pedido JOIN clientes c ON c.id = p.id_cliente GROUP BY c.ciudad, p.canal ORDER BY venta DESC LIMIT 5;
ciudad canal venta -------- ----------- --------- Chiclayo WhatsApp 117785.75 Chiclayo Web 107601.31 Chiclayo Marketplace 97222.96 Arequipa Tienda 88834.1 Piura WhatsApp 79504.28
Dos JOIN para saber cuánto se vendió por ciudad y canal. Y esa consulta la escribiste tú, que llevas dieciocho capítulos. La jefa de ventas no la va a escribir nunca 🙃
Escribir y leer piden formas distintas
La base del sistema tiene que escribir bien: que un pedido
entre sin bloquear a nadie, que la ciudad de un cliente esté en un solo lugar
para que cambiarla sea un UPDATE y no cuarenta.
El almacén tiene que leer bien: que la pregunta salga en una consulta, sin JOIN y sin que quien la hace tenga que saberse el modelo.
Son dos objetivos que tiran para lados contrarios, y por eso son dos bases y no una. El almacén se llena copiando desde el sistema, y esa copia repite datos a propósito: la ciudad del cliente va escrita en cada línea de venta. En el capítulo de diseño eso era un pecado; acá es el diseño.
Las tres palabras que vas a oír
| Nombre | Qué guarda | Cuándo se le da forma |
|---|---|---|
| Data warehouse | tablas con columnas y tipos, ya ordenadas | antes de guardar |
| Data lake | los archivos tal como llegaron, incluidos PDF y fotos | al leer, si es que alguna vez |
| Lakehouse | archivos en el lago, pero consultables con SQL | al leer, con el catálogo puesto |
El lago tiene una fama que conviene conocer: se llena mucho más rápido de lo que se ordena, y cuando nadie documentó qué hay dentro se le empieza a llamar pantano. No es una broma del sector, es el resultado normal de guardar todo sin catálogo.
Y el lakehouse es lo que hiciste en el capítulo
19 cuando pusiste FROM 'archivo.parquet':
archivos sueltos que se consultan como tablas. La idea no es rara ni nueva, lo
nuevo es el nombre.
ETL y ELT, que es la misma sopa en otro orden
ETL:
- Extraer
- Transformar
- Cargar
Los datos se limpian por el camino y al almacén entra solo el resultado.
ELT: extraer, cargar, transformar. Entra todo tal cual y la limpieza se hace ya dentro, con SQL.
El ELT ganó terreno por una razón práctica: si guardas lo crudo, puedes volver a transformarlo cuando cambie la regla. Con ETL, el día que descubres que la regla de descuentos estaba mal, lo que se transformó mal ya no existe y toca volver a pedirle los datos al sistema, si es que todavía los tiene 🧾
Las tres capas
Vas a oírlas con muchos nombres, bronce y plata y oro entre ellos. Son tres:
- Cruda: copia fiel de lo que llegó, sin arreglar nada, con la fecha en que se cargó. No se toca nunca.
- Limpia: tipos arreglados, duplicados fuera, nulos decididos.
- Lista: las tablas que se consultan, anchas y sin JOIN.
Empecemos por la cruda, que es una línea:
CREATE TABLE cruda_detalle AS SELECT *, '2026-08-21' AS cargado_en FROM detalle; SELECT count(*) AS filas, min(cargado_en) AS primera_carga FROM cruda_detalle;
filas primera_carga ----- ------------- 2682 2026-08-21
La columna cargado_en parece un adorno y es media capa: es lo
que te deja contestar "esto entró el jueves" cuando alguien pregunte por un
número raro.
La tabla de hechos, y el susto
Ahora la capa lista. Una fila por línea de venta, con todo escrito al lado:
CREATE TABLE hechos_ventas AS SELECT d.id AS id_linea, p.id AS id_pedido, p.fecha AS fecha, p.canal AS canal, c.ciudad AS ciudad, c.segmento AS segmento, pr.categoria AS categoria, d.cantidad AS unidades, round(d.cantidad * d.precio_unit, 2) AS monto FROM cruda_detalle d JOIN pedidos p ON p.id = d.id_pedido JOIN clientes c ON c.id = p.id_cliente JOIN productos pr ON pr.id = d.id_producto; SELECT count(*) AS lineas, count(DISTINCT id_pedido) AS pedidos FROM hechos_ventas;
lineas pedidos ------ ------- 2605 876
Salió. Corrió sin un error, la tabla existe y las consultas contra ella funcionan.
Y le faltan filas 😐
SELECT (SELECT count(*) FROM cruda_detalle) - (SELECT count(*) FROM hechos_ventas) AS lineas_perdidas, round((SELECT sum(cantidad * precio_unit) FROM cruda_detalle) - (SELECT sum(monto) FROM hechos_ventas), 2) AS soles_perdidos;
lineas_perdidas soles_perdidos --------------- -------------- 77 38617.07
Setenta y siete líneas y treinta y ocho mil soles que estaban en el origen y
no están en el almacén. Nadie se enteró, porque un JOIN que no
encuentra pareja no falla: devuelve menos.
Son pedidos cuyo id_cliente no está en la tabla de clientes.
Pasa en todas las bases de verdad: un cliente que se borró, una migración vieja,
un pedido cargado a mano.
Arreglarlo, y el error de por medio
Lo intuitivo es volver a crear la tabla. Prueba:
CREATE TABLE hechos_ventas AS SELECT 1 AS ojo;
OperationalError: table hechos_ventas already exists
Por eso las cargas empiezan siempre con un DROP TABLE IF EXISTS:
la carga de mañana tiene que poder correr aunque la de hoy haya dejado la tabla
puesta.
Y el arreglo de fondo es cambiar el JOIN por un LEFT JOIN con la
etiqueta puesta, que es la técnica del capítulo 7 usada acá para no
perder plata:
DROP TABLE IF EXISTS hechos_ventas; CREATE TABLE hechos_ventas AS SELECT d.id AS id_linea, p.id AS id_pedido, p.fecha AS fecha, p.canal AS canal, coalesce(c.ciudad, 'sin cliente') AS ciudad, coalesce(c.segmento, 'sin cliente') AS segmento, pr.categoria AS categoria, d.cantidad AS unidades, round(d.cantidad * d.precio_unit, 2) AS monto FROM cruda_detalle d JOIN pedidos p ON p.id = d.id_pedido LEFT JOIN clientes c ON c.id = p.id_cliente JOIN productos pr ON pr.id = d.id_producto; SELECT (SELECT count(*) FROM cruda_detalle) - (SELECT count(*) FROM hechos_ventas) AS lineas_perdidas, round((SELECT sum(cantidad * precio_unit) FROM cruda_detalle) - (SELECT sum(monto) FROM hechos_ventas), 2) AS soles_perdidos;
lineas_perdidas soles_perdidos --------------- -------------- 0 0.0
Cero y cero. Y lo que antes desaparecía ahora se puede mirar de frente:
SELECT ciudad, count(*) AS lineas, round(sum(monto), 2) AS venta FROM hechos_ventas GROUP BY ciudad ORDER BY venta LIMIT 3;
ciudad lineas venta ----------- ------ --------- sin cliente 77 38617.07 Lima 308 177604.92 Trujillo 334 181652.49
Ahí está el agujero, con nombre y con monto. Ahora alguien puede decidir qué hacer con él, que es lo único que no se podía cuando el JOIN se lo comía en silencio 💛
Y la consulta del principio, otra vez
SELECT ciudad, canal, round(sum(monto), 2) AS venta FROM hechos_ventas GROUP BY ciudad, canal ORDER BY venta DESC LIMIT 5;
ciudad canal venta -------- ----------- --------- Chiclayo WhatsApp 117785.75 Chiclayo Web 107601.31 Chiclayo Marketplace 97222.96 Arequipa Tienda 88834.1 Piura WhatsApp 79504.28
El mismo resultado, cero JOIN. Eso es todo lo que hace un almacén, y por eso merece la pena copiar los datos.
Crear una tabla a partir de una consulta
| PostgreSQL, MySQL y SQLite | CREATE TABLE hechos AS SELECT ... |
| SQL Server | SELECT ... INTO hechos FROM ... |
PostgreSQL admite ademas CREATE TABLE ... AS con UNLOGGED para tablas intermedias que no hace falta recuperar si se cae el servidor, que es justo el caso de una capa cruda. SQL Server pone el destino en medio de la consulta, con INTO, y confunde la primera vez.
Comprueba que lo tienes
Estás armando la tabla de ventas del almacén y sale de cruzar cuatro tablas con JOIN. ¿Qué es lo primero que hay que comprobar?
- Que no se hayan perdido filas por el camino, comparando el total contra el origen
- Que la consulta sea rápida
- Que los nombres de las columnas sean claros
- Que la tabla tenga un índice
Ejercicios
1. El cuadre, que es la consulta más importante del capítulo
Comprueba mes a mes que la tabla de hechos dice lo mismo que el origen.
SELECT substr(h.fecha, 1, 7) AS mes, round(sum(h.monto), 2) AS en_hechos, (SELECT count(*) FROM pedidos p WHERE substr(p.fecha, 1, 7) = substr(h.fecha, 1, 7)) AS pedidos_del_mes FROM hechos_ventas h GROUP BY mes ORDER BY mes DESC LIMIT 4;
mes en_hechos pedidos_del_mes ------- --------- --------------- 2026-06 83859.26 50 2026-05 97054.87 54 2026-04 84666.93 49 2026-03 87787.71 48
Esta consulta se guarda y se corre después de cada carga, no una vez. Un mes que aparece con la mitad de la venta del anterior casi nunca es que se vendió menos: es que la carga se cortó a medias.
Es el equivalente de datos a cuadrar la caja al cerrar la tienda, y se hace por el mismo motivo 🧾
2. La pregunta que antes no se podía hacer
Venta por segmento y categoría, que en el modelo original pedía cuatro tablas.
SELECT segmento, categoria, round(sum(monto), 2) AS venta, sum(unidades) AS unidades FROM hechos_ventas WHERE segmento <> 'sin cliente' GROUP BY segmento, categoria ORDER BY venta DESC LIMIT 5;
segmento categoria venta unidades -------- --------- --------- -------- Bodega Limpieza 104573.18 2886 Horeca Limpieza 103926.03 2741 Bodega Snacks 97606.69 1909 Horeca Bebidas 95630.86 1536 Bodega Abarrotes 90595.44 2039
Un GROUP BY de los del capítulo 6 y ya está. Sin
tabla de hechos, esa misma respuesta necesita unir detalle, pedidos, clientes y
productos, y quien la pide no sabe hacerlo.
Fíjate también en el WHERE: la fila de "sin cliente" existe y se
saca a propósito de este corte. Eso es distinto a que no exista 🙂
3. El precio de hoy no es el precio al que se vendió
Primero comprueba cómo están las cosas.
SELECT count(*) AS lineas, sum(CASE WHEN d.precio_unit = pr.precio THEN 1 ELSE 0 END) AS al_precio_de_hoy FROM detalle d JOIN productos pr ON pr.id = d.id_producto;
lineas al_precio_de_hoy ------ ---------------- 2682 2682
Todas coinciden, porque en estos datos los precios nunca cambiaron. Ahora sube las bebidas un 15%, como pasa cada año:
UPDATE productos SET precio = round(precio * 1.15, 2) WHERE categoria = 'Bebidas'; SELECT round(sum(d.cantidad * d.precio_unit), 2) AS como_se_cobro, round(sum(d.cantidad * pr.precio), 2) AS con_el_precio_de_hoy, round(sum(d.cantidad * pr.precio) - sum(d.cantidad * d.precio_unit), 2) AS invento FROM detalle d JOIN productos pr ON pr.id = d.id_producto;
como_se_cobro con_el_precio_de_hoy invento ------------- -------------------- -------- 1545619.15 1593168.14 47548.99
Cuarenta y siete mil soles que nadie cobró. Y saldrían igual de convencidos
en un reporte si la tabla de hechos calculara el monto contra el maestro de
productos en vez de contra precio_unit.
Por eso la tabla de hechos guarda el monto tal como se cobró. Un almacén cuenta lo que pasó, no lo que pasaría si hubiera pasado hoy 📏
4. El cliente que se mudó
Las bodegas de Chiclayo pasan a atenderse desde Cusco. Actualiza el maestro y mira qué dice la tabla de hechos.
UPDATE clientes SET ciudad = 'Cusco' WHERE ciudad = 'Chiclayo' AND segmento = 'Bodega'; SELECT h.ciudad AS en_la_tabla_de_hechos, c.ciudad AS en_el_maestro_de_hoy, count(*) AS lineas FROM hechos_ventas h JOIN pedidos p ON p.id = h.id_pedido JOIN clientes c ON c.id = p.id_cliente WHERE h.ciudad <> c.ciudad GROUP BY 1, 2;
en_la_tabla_de_hechos en_el_maestro_de_hoy lineas --------------------- -------------------- ------ Chiclayo Cusco 169
169 líneas que se vendieron en Chiclayo y cuyo cliente hoy figura en Cusco. Las dos cosas son verdad y no hay una respuesta correcta: depende de si la pregunta es "dónde se vendió" o "cuánto vende hoy la cartera de Cusco".
Lo que no vale es no haberlo decidido. Ese problema tiene nombre en el oficio, dimensión que cambia despacio, y la solución más común es guardar las dos: la ciudad del momento de la venta en la tabla de hechos, y la de hoy en el maestro 🧾
5. Por qué la capa cruda no se toca
Simula el desastre: el origen se vacía.
DELETE FROM detalle; SELECT (SELECT count(*) FROM detalle) AS en_el_origen, (SELECT count(*) FROM cruda_detalle) AS en_la_capa_cruda, (SELECT count(*) FROM hechos_ventas) AS en_la_capa_lista;
en_el_origen en_la_capa_cruda en_la_capa_lista ------------ ---------------- ---------------- 0 2682 2682
El origen se quedó en cero y tu almacén sigue completo. Esa es la mitad aburrida del valor de la capa cruda.
La otra mitad es la que se usa más: el día que descubres que la regla de descuentos estaba mal, vuelves a construir la capa lista desde la cruda en una consulta, sin pedirle nada a nadie. Con ETL puro, esa consulta no existe porque lo crudo no se guardó.
6. Dónde va cada cosa
Cuatro pedidos que llegan un lunes. ¿Capa cruda, limpia o lista?
"El CSV que manda el marketplace cada noche, con las comillas raras." Cruda, tal como viene. Las comillas se arreglan después, y si las arreglas al entrar ya no puedes comprobar qué mandaron.
"Las ciudades vienen como LIMA, Lima y lima." Limpia. Es una decisión de normalización y va escrita en una consulta que se puede leer, no en el reporte de cada uno.
"La jefa quiere venta por canal y mes." Lista. Ancha, sin JOIN, y con los nombres que ella usa.
"Los PDF de las facturas del proveedor." Ni una de las tres: eso es lo que va a un lago. Un almacén guarda tablas, y un PDF no lo es 🙂
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.