Capítulo 15 de 23 8 secciones 12 min

Compartir

El almacén, el lago, y por qué se copian los datos a propósito

La base con la que trabajaste todo el libro está hecha para escribir. Un almacén está hecho para leer, y eso cambia cómo se diseña.

La base de un sistema está diseñada para escribir rápido y sin repetir nada; un data warehouse está diseñado para leer, y por eso repite datos a propósito en una tabla ancha de hechos. Un data lake guarda los archivos tal como llegaron, sin forma. Entre el origen y el reporte se suelen poner tres capas: cruda, limpia y lista 🧾

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

NombreQué guardaCuándo se le da forma
Data warehousetablas con columnas y tipos, ya ordenadasantes de guardar
Data lakelos archivos tal como llegaron, incluidos PDF y fotosal leer, si es que alguna vez
Lakehousearchivos en el lago, pero consultables con SQLal 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 SQLiteCREATE TABLE hechos AS SELECT ...
SQL ServerSELECT ... 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.

¿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?