Sabes hacer un SELECT, un WHERE, un JOIN. Perfecto, eso es el 40% del SQL que usa un analista. El otro 60% es saber agrupar, filtrar grupos y — sobre todo — comparar filas sin perder el detalle. Ahí es donde entra el SQL analítico, y donde la mayoría de tutoriales se ponen innecesariamente abstractos.
Vamos a ir de menos a más, con una sola tabla de ventas como ejemplo en todo el artículo. Nada de cambiar de dataset cada sección. Así ves la progresión real.
### La tabla: ventas de una tienda
1-- Nuestra tabla para todo el artículo2CREATE TABLE ventas (3 id SERIAL PRIMARY KEY,4 fecha DATE NOT NULL,5 producto VARCHAR(50),6 tienda VARCHAR(50),7 cantidad INT,8 importe DECIMAL(10, 2)9);1011-- Datos de ejemplo (imagina 10.000 filas)12-- fecha | producto | tienda | cantidad | importe13-- 2024-01-15 | Laptop | Madrid | 2 | 1799.9814-- 2024-01-15 | Monitor | Madrid | 3 | 1048.5015-- 2024-01-15 | Laptop | Barcelona | 1 | 899.9916-- 2024-01-16 | Teclado | Madrid | 5 | 149.9517-- ...
Una tabla, muchas preguntas. Toda la magia de SQL analítico es saber qué pregunta contestar con qué herramienta.
### Nivel 1: GROUP BY y HAVING
GROUP BY responde a "¿cuánto en total?" agrupado por algo. HAVING es el WHERE de los grupos — filtra después de agrupar. La diferencia clave: WHERE filtra filas ANTES de agrupar; HAVING filtra grupos DESPUÉS.
1-- Total vendido por tienda2SELECT3 tienda,4 SUM(importe) AS total_vendido,5 COUNT(*) AS num_transacciones6FROM ventas7GROUP BY tienda;89-- Solo tiendas con más de 100k€ en ventas10SELECT11 tienda,12 SUM(importe) AS total_vendido13FROM ventas14GROUP BY tienda15HAVING SUM(importe) > 100000;1617-- Ventas por producto y mes (agregación multinivel)18SELECT19 DATE_TRUNC('month', fecha) AS mes,20 producto,21 SUM(importe) AS total,22 AVG(importe) AS ticket_medio23FROM ventas24GROUP BY DATE_TRUNC('month', fecha), producto25ORDER BY mes, total DESC;
WHERE filtra filas. HAVING filtra grupos. No los mezcles — son dos momentos distintos de la query.
Truco para no equivocarte: si el filtro usa una función de agregación (SUM, COUNT, AVG), va en HAVING. Si no la usa, va en WHERE. Siempre.
### El problema de GROUP BY: colapsa filas
GROUP BY te da un resumen, pero pierdes el detalle. Si quieres saber "el total por tienda Y además ver cada venta individual con su porcentaje del total", GROUP BY no puede. Necesitas ver el bosque y los árboles al mismo tiempo. Aquí entran las funciones de ventana.
### Nivel 2: ROW_NUMBER, RANK y DENSE_RANK
La primera función de ventana que todo el mundo necesita: numerar filas dentro de un grupo. "Dame el producto más vendido de cada tienda", "la última venta de cada cliente", "el top 3 por mes". Todo eso es ROW_NUMBER.
1-- Top 1 producto por tienda (el más vendido)2WITH ranking AS (3 SELECT4 tienda,5 producto,6 SUM(importe) AS total,7 ROW_NUMBER() OVER (8 PARTITION BY tienda9 ORDER BY SUM(importe) DESC10 ) AS rn11 FROM ventas12 GROUP BY tienda, producto13)14SELECT tienda, producto, total15FROM ranking16WHERE rn = 1;1718-- Diferencia entre ROW_NUMBER, RANK y DENSE_RANK:19-- Si dos productos empatados en ventas:20-- ROW_NUMBER: 1, 2, 3 (siempre consecutivo, desempata arbitrariamente)21-- RANK: 1, 1, 3 (empate real, se salta el 2)22-- DENSE_RANK: 1, 1, 2 (empate real, NO se salta)
PARTITION BY es el GROUP BY de las window functions: define los grupos. ORDER BY define el criterio dentro de cada grupo.
### Nivel 3: LAG, LEAD y acumulados con SUM OVER
Aquí es donde el SQL analítico brilla de verdad. LAG te da la fila anterior, LEAD la siguiente. SUM OVER con un frame te da acumulados. Con estas tres puedes responder "¿cuánto crecimos respecto al mes pasado?" y "¿cuál es el acumulado del año?" sin salir de SQL.
1-- Ventas mensuales con variación respecto al mes anterior2WITH mensual AS (3 SELECT4 DATE_TRUNC('month', fecha) AS mes,5 SUM(importe) AS total6 FROM ventas7 GROUP BY DATE_TRUNC('month', fecha)8)9SELECT10 mes,11 total,12 LAG(total) OVER (ORDER BY mes) AS mes_anterior,13 total - LAG(total) OVER (ORDER BY mes) AS diferencia,14 ROUND(15 (total - LAG(total) OVER (ORDER BY mes))16 / LAG(total) OVER (ORDER BY mes) * 100, 117 ) AS pct_cambio18FROM mensual19ORDER BY mes;
LAG(columna) OVER (ORDER BY ...) = "dame el valor de la fila anterior". Así de simple.
1-- Acumulado de ventas por tienda a lo largo del año2SELECT3 fecha,4 tienda,5 importe,6 SUM(importe) OVER (7 PARTITION BY tienda8 ORDER BY fecha9 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW10 ) AS acumulado11FROM ventas12ORDER BY tienda, fecha;1314-- Media móvil de 7 días (suavizar picos)15SELECT16 fecha,17 importe,18 AVG(importe) OVER (19 ORDER BY fecha20 ROWS BETWEEN 6 PRECEDING AND CURRENT ROW21 ) AS media_7d22FROM ventas;
El frame (ROWS BETWEEN) define la "ventana" de filas que usa el cálculo. Sin él, usa todo el partition.
Cuidado con ROWS vs RANGE. ROWS cuenta filas literales; RANGE agrupa valores iguales. Si tienes varias filas con la misma fecha y usas RANGE, las trata como una sola posición. Para acumulados financieros, normalmente quieres ROWS.
### Cuándo usar cada herramienta
- Quieres un resumen (total, media, conteo) → GROUP BY
- Quieres filtrar esos resúmenes → GROUP BY + HAVING
- Quieres el top N por categoría → ROW_NUMBER() + PARTITION BY
- Quieres comparar con el periodo anterior → LAG()
- Quieres un acumulado progresivo → SUM() OVER(ORDER BY ... ROWS ...)
- Quieres un porcentaje del total del grupo → valor / SUM() OVER(PARTITION BY ...)
La clave para dominar SQL analítico es practicar con datos reales y preguntas de negocio reales. No te estudies la sintaxis de memoria — plantéate preguntas ("¿cuál fue el mejor mes de cada tienda?") e intenta responderlas. Si quieres practicar esto con ejercicios progresivos y un editor SQL integrado, lo trabajamos en profundidad en la plataforma.
Consejo de entrevista: si te preguntan por window functions, explica primero QUÉ PROBLEMA resuelven (ver detalle + cálculo al mismo tiempo) antes de escribir la sintaxis. Demuestra que entiendes el porqué, no solo el cómo.