Cuadernillo de problemas resueltos: nueve consultas sobre una tiendita
SQL no se aprende leyendo: se aprende escribiendo consultas y peleando con resultados equivocados. Este cuadernillo resuelve sobre UNA base de datos fija —una tiendita de tres tablas— las nueve consultas que cubren el 90% del trabajo diario del analista: unir, buscar lo que falta, agrupar, filtrar grupos, comparar contra promedios, rankear, acumular, medir variacion mensual y acelerar con indices. Cada problema muestra la consulta, por que esta escrita asi y que devuelve.

Bloque 1 · Unir y echar de menos
Problema 1. Lista que compro cada cliente: nombre del cliente, producto y fecha.
Solucion paso a paso
Paso 1 · Plan
Inventario: el nombre vive en clientes, el producto en productos, la fecha en ventas. Dos saltos = dos JOIN.Paso 2 · Consulta
Escribe la consulta desde la tabla de hechos hacia las dimensiones.SELECT c.nombre, p.nombre AS producto, v.fecha FROM ventas v JOIN clientes c ON c.id = v.cliente_id JOIN productos p ON p.id = v.producto_id ORDER BY v.fecha;Paso 3 · Semantica
JOIN interno: si una venta tuviera cliente_id roto (huerfano), la fila DESAPARECE del resultado. ¿Sospecha de datos huérfanos? Usa el problema 2 al reves sobre las claves.Paso 4 · Habitos
Alias v, c, p: acortan y obligan a calificar cada columna. Sin calificar, "nombre" seria ambiguo (existe en clientes Y productos) y la consulta moriria.
Paso 1 de 4
Problema 2. Encuentra los clientes que NUNCA compraron (inversion del JOIN).
Solucion paso a paso
Paso 1 · Idea
LEFT JOIN desde clientes: conserva TODOS los clientes y rellena NULL donde no hay venta.Paso 2 · Consulta
Luego filtra los NULL: esos son los que nunca compraron.SELECT c.id, c.nombre, c.ciudad FROM clientes c LEFT JOIN ventas v ON v.cliente_id = c.id WHERE v.id IS NULL;Paso 3 · Trampa
Trampa clasica: WHERE v.id = NULL NO funciona; NULL no es igual a nada, se pregunta con IS NULL. Con = no devuelve ni una fila y parece que "no hay inactivos".Paso 4 · Variante
Alternativa con subconsulta: WHERE c.id NOT IN (SELECT cliente_id FROM ventas) — con NOT IN, cuidado: si la subconsulta devuelve un solo NULL, el resultado queda vacio. NOT EXISTS es la version a prueba de balas.
Paso 1 de 4
Problema 3. ¿Cuantos clientes unicos tiene cada ciudad y cuanto gastan en total?
Solucion paso a paso
Paso 1 · Plan
Agrupar por ciudad y contar: dos tablas implicadas (clientes y ventas) mas productos para el importe: tres JOINs y un GROUP BY.Paso 2 · Consulta
SUM con multiplicacion: cada linea de ticket vale precio por cantidad.SELECT c.ciudad, COUNT(DISTINCT c.id) AS clientes, SUM(v.cantidad * p.precio) AS gastado FROM clientes c JOIN ventas v ON v.cliente_id = c.id JOIN productos p ON p.id = v.producto_id GROUP BY c.ciudad ORDER BY gastado DESC;Paso 3 · Distincion
COUNT(DISTINCT c.id): sin DISTINCT contarias FILAS de ventas (tickets), no clientes: el JOIN duplica al cliente por cada compra. Es el error de doble conteo que mas dashboard mata.
Paso 1 de 3
Bloque 2 · Filtrar grupos y comparar contra agregados
Problema 4. Categorias de producto que VENDAN MAS de 100 unidades acumuladas.
Solucion paso a paso
Paso 1 · Concepto
El filtro actua SOBRE EL GRUPO: eso es HAVING, no WHERE. WHERE filtra filas antes de agrupar; HAVING filtra grupos despues.Paso 2 · Consulta
Escritura correcta.SELECT p.categoria, SUM(v.cantidad) AS unidades FROM ventas v JOIN productos p ON p.id = v.producto_id GROUP BY p.categoria HAVING SUM(v.cantidad) > 100;Paso 3 · Orden logico
Orden mental de ejecucion SQL: FROM y JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT. Por eso en WHERE no puedes usar el alias del SELECT: todavia no existe.
Paso 1 de 3
Problema 5. Productos caros: los que cuestan MAS que el precio promedio de su propia categoria.
Solucion paso a paso
Paso 1 · Plan
Necesitas el promedio POR CATEGORIA y compararlo con cada fila: o ventana, o subconsulta correlacionada. Version ventana, mas limpia.Paso 2 · Consulta
Primero calcula el promedio de categoria en una capa interior; luego filtra afuera, porque WHERE no puede ver a AVG OVER.SELECT * FROM ( SELECT nombre, categoria, precio, AVG(precio) OVER (PARTITION BY categoria) AS media_cat FROM productos ) t WHERE precio > media_cat;Paso 3 · Explicacion
¿Por que el WHERE exterior? Las funciones de ventana se evaluan DESPUES de WHERE y del GROUP BY: si filtraras dentro, aun no existiria media_cat. La subconsulta (o CTE con WITH) es el puente.
Paso 1 de 3
Problema 6. Top 3 productos mas vendidos de cada categoria.
Solucion paso a paso
Paso 1 · Capa 1
Primero agregacion: unidades por producto con su categoria.WITH ventas_prod AS ( SELECT v.producto_id, p.nombre, p.categoria, SUM(v.cantidad) AS unidades FROM ventas v JOIN productos p ON p.id = v.producto_id GROUP BY v.producto_id, p.nombre, p.categoria )Paso 2 · Capa 2
Luego rankeo dentro de cada grupo con RANK() OVER (PARTITION BY categoria ORDER BY unidades DESC).SELECT * FROM ( SELECT *, RANK() OVER (PARTITION BY categoria ORDER BY unidades DESC) AS r FROM ventas_prod ) t WHERE r IN (1, 2, 3) ORDER BY categoria, r;Paso 3 · Matices
RANK vs DENSE_RANK vs ROW_NUMBER: empates al frente → RANK salta numeros (1,1,3), DENSE_RANK no salta (1,1,2), ROW_NUMBER corta el empate arbitrariamente (1,2,3). Para "top 3 con empates justos" decide primero que variante quieres.
Paso 1 de 3
Bloque 3 · Ventanas temporales y rendimiento
Problema 7. Ventas acumuladas mes a mes (curva de crecimiento).
Solucion paso a paso
Paso 1 · Agregado mensual
Capa 1: total por mes con date_trunc.WITH mensual AS ( SELECT date_trunc('month', fecha) AS mes, SUM(v.cantidad * p.precio) AS ventas FROM ventas v JOIN productos p ON p.id = v.producto_id GROUP BY 1 )Paso 2 · Running total
Capa 2: la acumulada es un SUM sobre todas las filas anteriores ordenadas por mes.SELECT mes, ventas, SUM(ventas) OVER (ORDER BY mes) AS acumuladas FROM mensual ORDER BY mes;Paso 3 · Frame
SUM OVER (ORDER BY mes) sin FRAME toma el valor por defecto: desde el inicio hasta la fila actual (peers incluidos). Para comportamiento estricto escribe ROWS UNBOUNDED PRECEDING AND CURRENT ROW.
Paso 1 de 3
Problema 8. Variacion porcentual de cada mes contra el anterior.
Solucion paso a paso
Paso 1 · Idea
LAG(ventas) OVER (ORDER BY mes) trae el valor de la fila anterior a la columna actual: comparar entre filas sin comparar contra si misma.Paso 2 · Consulta
Calcula el porcentaje con cuidado del primer mes (no tiene anterior: NULL).SELECT mes, ventas, LAG(ventas) OVER (ORDER BY mes) AS anterior, ROUND(100.0 * (ventas - LAG(ventas) OVER (ORDER BY mes)) / NULLIF(LAG(ventas) OVER (ORDER BY mes), 0), 1) AS variacion_pct FROM mensual ORDER BY mes;Paso 3 · Proteccion
NULLIF(x, 0): si el mes anterior vendio 0, la division daria error; NULLIF lo convierte en NULL y la fila sobrevive. En reportes de crecimiento, el 0 previo es frecuentisimo.
Paso 1 de 3
Problema 9. La consulta del problema 3 vuela en tu laptop pero en produccion tarda 40 segundos. Diagnostica y acelera.
Solucion paso a paso
Paso 1 · Diagnostico
Primero medir, luego tocar: EXPLAIN ANALYZE muestra el plan real y donde arde.EXPLAIN ANALYZE SELECT c.ciudad, SUM(v.cantidad * p.precio) FROM clientes c JOIN ventas v ON v.cliente_id = c.id JOIN productos p ON p.id = v.producto_id GROUP BY c.ciudad;Paso 2 · Causa
Sospechoso usual: Seq Scan sobre ventas de millones de filas porque no existe indice para la clave ajena. Los JOIN por cliente_id usan el indice del PRIMARY de clientes pero necesitan uno en ventas.cliente_id.Paso 3 · Fixes
Solucion tipica: indice en la columna del JOIN (y compuesta si el filtro incluye fecha).CREATE INDEX idx_ventas_cliente ON ventas (cliente_id); CREATE INDEX idx_ventas_fecha ON ventas (fecha);Paso 4 · Sensatez
Costo: cada indice acelera lecturas y ENLENTECE escrituras ademas de ocupar disco. No se indexa "por si acaso": se indexa lo que EXPLAIN pide. Despues del cambio, vuelve a pasar EXPLAIN y compara tiempos: sin antes y despues no hay ingenieria.
Paso 1 de 4
Arbol de decisiones SQL
Que construccion SQL uso
- Consultar datos
- Faltan columnas de otra tabla
- JOIN: cruce de fronteras
- LEFT JOIN: conserva la izquierda
- Faltan filas
- LEFT JOIN + IS NULL
- NOT EXISTS a prueba de NULL
- Una cifra por grupo
- GROUP BY + agregados
- Filtrar el grupo: HAVING
- Comparar fila contra grupo
- Ventana OVER (PARTITION BY)
- O CTE y filtro exterior
- Tiempo y ranking
- SUM OVER: acumulados
- LAG/LEAD: variaciones
- RANK OVER: top N por grupo
- Faltan columnas de otra tabla
Autoexamen cronometrado (20 minutos)
Sobre el mismo esquema de la tiendita. Responde sin volver a leer los problemas.
¿Cual clausula filtra GRUPOS ya formados?
Para hallar clientes sin ventas sirve WHERE v.id = NULL.
Tienes ventas por cliente duplicadas tras un JOIN y el total sale inflado. ¿Cual es la causa mas probable?
La funcion trae el valor de la fila anterior dentro de la ventana ordenada.
Empareja necesidad y construccion.
VENTAS tiene 5000 filas (cada venta apunta a EXACTAMENTE 1 producto valido) y PRODUCTOS 200 filas. ¿Cuantas filas devuelve ventas JOIN productos?
Para profundizar
PG ExercisesProblemas SQL interactivos con solucion, gratis, sobre postgres.
Use The Index, LukeRendimiento de indices explicado con planes reales.
El problema 3 con COUNT sin DISTINCT es el bug de dashboard mas frecuente del mundo laboral. Inventa un JOIN 1:N tuyo (pedidos-lineas, sesiones-eventos) y escribe la version inflada y la correcta: nota que ambas "funcionan" y solo una miente.
Tu texto se guarda sólo en este dispositivo.
Comentarios
Inicia sesión para comentar.
Todavía no hay comentarios. Sé la primera persona en opinar.