Hasta aquí SQL te ha listado filas. A partir de este capítulo empieza a responder preguntas, que es otra cosa 🌟
"¿Cuánto vendimos por canal?" no se contesta con una lista de 900 pedidos.
Se contesta con cuatro números. Y el que convierte 900 filas en 4 se llama
GROUP BY.
Es también el capítulo donde los motores dejan de ser parecidos. PostgreSQL y SQL Server te van a exigir una cosa que SQLite te deja pasar sin decir nada, y esa diferencia es la que hace que una consulta que funcionaba en tu laptop devuelva otro número en la nube.
Primero, resumir todo junto
Las cinco funciones de resumen, que en los manuales vas a ver como funciones de agregación, son las mismas en los cuatro motores, y con esas cinco se hace el 90% de los reportes. Las agregaciones y las agrupaciones van siempre juntas: una calcula y la otra decide sobre qué.
SELECT COUNT(*) AS pedidos,
ROUND(SUM(monto), 2) AS total,
ROUND(AVG(monto), 2) AS ticket,
MIN(monto) AS minimo,
MAX(monto) AS maximo
FROM pedidos;
pedidos total ticket minimo maximo ------- --------- ------ ------ ------- 900 532653.85 610.14 5.05 1399.98
Una sola fila para las 900. COUNT cuenta, SUM suma,
AVG promedia, MIN y MAX son el más chico y
el más grande.
Y un detalle del capítulo 4 que aquí se vuelve importante: hay 900 pedidos
pero solo 873 tienen monto, porque 27 vienen nulos. COUNT(*) cuenta
filas y AVG ignora los nulos, así que ese ticket de 610,14 es el
promedio de 873, no de 900.
Ahora, resumir por grupos
SELECT canal,
COUNT(*) AS pedidos,
ROUND(SUM(monto), 2) AS total
FROM pedidos
GROUP BY canal
ORDER BY total DESC;
canal pedidos total ----------- ------- --------- Web 234 140446.17 WhatsApp 231 137968.56 Marketplace 229 135754.05 Tienda 206 118485.07
Cuatro filas, una por canal. Eso es todo lo que hace
GROUP BY: junta las filas que tienen el mismo valor en esa
columna y calcula el resumen dentro de cada montón.
Web va primero pero por poquito: 140 mil contra 137 mil de WhatsApp. Si alguien te pide "el canal ganador", ese margen de menos del 2% merece la conversación de si de verdad hay un ganador 🤔
Puedes agrupar por más de una columna, y ahí los montones se parten.
SELECT ciudad, segmento, COUNT(*) AS clientes FROM clientes GROUP BY ciudad, segmento ORDER BY ciudad, clientes DESC LIMIT 8;
ciudad segmento clientes -------- ---------- -------- Arequipa Bodega 11 Arequipa Horeca 5 Arequipa Minimarket 3 Arequipa Mayorista 3 Chiclayo Horeca 9 Chiclayo Bodega 7 Chiclayo Minimarket 6 Chiclayo Mayorista 3
Y puedes agrupar por algo calculado, que es donde se pone bueno. El mes no es una columna de la tabla: lo fabricamos con lo del capítulo 5.
SELECT STRFTIME('%Y-%m', fecha) AS mes,
COUNT(*) AS pedidos,
ROUND(SUM(monto), 2) AS soles
FROM pedidos
WHERE fecha >= '2026-01-01'
GROUP BY mes
ORDER BY mes;
mes pedidos soles ------- ------- -------- 2026-01 51 29373.17 2026-02 40 24033.91 2026-03 48 28103.64 2026-04 49 27651.42 2026-05 54 33711.97 2026-06 50 25293.12
Ese es el reporte mensual entero, en seis líneas de SQL. Mayo manda en pedidos y en soles, febrero es el más flojo en los dos, y ahí ya tienes algo que contarle a alguien.
La regla que PostgreSQL exige y SQLite no
Esta es la parte importante del capítulo. Mira esta consulta y dime qué
esperas que salga en la columna monto.
SELECT canal, monto, COUNT(*) AS pedidos FROM pedidos GROUP BY canal;
canal monto pedidos ----------- ------ ------- Marketplace 731.09 229 Tienda 407.39 206 Web 892.06 234 WhatsApp 264.43 231
¿El monto de qué pedido? Web tiene 234 pedidos y aquí sale un monto solo, sin que nadie te diga de cuál de los 234 salió.
Es que la pregunta no tiene sentido. Le pediste a la base que junte 234 filas en una y que además te dé "el monto", en singular. SQLite, en vez de decírtelo, elige uno cualquiera y sigue como si nada.
Y para que no quede duda de que es cualquiera: la misma columna, la misma tabla, filtrando solo Web.
SELECT canal, monto, MIN(monto) AS el_mas_bajo, MAX(monto) AS el_mas_alto, COUNT(*) AS pedidos FROM pedidos WHERE canal = 'Web' GROUP BY canal;
canal monto el_mas_bajo el_mas_alto pedidos ----- ------- ----------- ----------- ------- Web 1372.34 48.72 1372.34 234
Otro número. Misma data, mismo canal, y el monto cambió solo
porque cambió cómo escribí la consulta. Ese valor no significa nada y ningún
motor te va a garantizar cuál te toca.
La regla de verdad, la del estándar, es esta: todo lo que pongas en
el SELECT tiene que estar o dentro de una función de resumen o
dentro del GROUP BY. Ninguna otra cosa.
Poner en el SELECT una columna que no está en el GROUP BY
| PostgreSQL | ERROR: column "pedidos.monto" must appear in the GROUP BY clause |
| MySQL | ERROR desde 5.7, que trae ONLY_FULL_GROUP_BY activo de fábrica |
| SQL Server | ERROR: Column 'pedidos.monto' is invalid in the select list |
| SQLite | lo permite y devuelve un valor cualquiera del grupo |
Este es EL choque del capítulo. Aprendes con SQLite, te funciona, lo llevas a la base de la empresa y no corre. Y la versión mala es al revés: traes una consulta vieja de MySQL 5.6 que sí corría, y ahora te da un número distinto sin explicación. Escribe siempre como si el motor fuera estricto, aunque el tuyo no lo sea.
El grupo que nadie invitó: los nulos
Vamos a sacar el ranking de clientes por número de pedidos, que es de las consultas más pedidas del mundo.
SELECT id_cliente, COUNT(*) AS pedidos FROM pedidos GROUP BY id_cliente ORDER BY pedidos DESC LIMIT 5;
id_cliente pedidos
---------- -------
24
58 17
78 14
48 14
8 14
El mejor cliente de la tienda es… una celda vacía, con 24 pedidos 😅
Esos 24 son los pedidos que tienen id_cliente en
NULL, y GROUP BY les arma su propio
grupo: todos los nulos caen juntos, como si "no se sabe" fuera un
cliente más. Eso es igual en los cuatro motores.
Y fíjate el daño que hace: si mandas ese ranking sin mirarlo, el número uno de tu tabla no existe. La solución es decidir qué quieres, no dejar que lo decida la base.
SELECT id_cliente, COUNT(*) AS pedidos, ROUND(SUM(monto), 2) AS total FROM pedidos WHERE id_cliente IS NOT NULL GROUP BY id_cliente HAVING COUNT(*) >= 14 ORDER BY total DESC;
id_cliente pedidos total ---------- ------- -------- 58 17 10131.72 78 14 7977.9 8 14 7494.38 48 14 7328.96
Ahí sí: cuatro clientes con 14 pedidos o más, y el 58 destacado con 17 pedidos y S/10.131.
Y una más, que engaña a mucha gente.
SELECT COUNT(*) AS pedidos_con_cliente,
COUNT(DISTINCT id_cliente) AS clientes_que_compraron
FROM pedidos;
pedidos_con_cliente clientes_que_compraron ------------------- ---------------------- 900 119
119 clientes distintos, no 120. Uno de los 120 de la tabla
clientes no compró nunca, y COUNT(DISTINCT) tampoco
cuenta el nulo. Dos huecos que se compensan y por eso el número parece
razonable 🕵️♀️
WHERE filtra filas, HAVING filtra grupos
Los dos filtran, y la diferencia es cuándo.
SELECT canal, COUNT(*) AS pedidos, ROUND(SUM(monto), 2) AS total FROM pedidos WHERE monto > 800 GROUP BY canal ORDER BY total DESC;
canal pedidos total ----------- ------- -------- WhatsApp 59 57360.92 Web 57 55626.54 Marketplace 50 47619.29 Tienda 40 37892.16
Eso es "de los pedidos que pasan de S/800, agrúpame por canal". El
WHERE tira filas antes de armar los montones, así
que WhatsApp gana con 59 pedidos grandes aunque en total venda menos que Web.
SELECT ciudad, COUNT(*) AS clientes FROM clientes GROUP BY ciudad HAVING COUNT(*) >= 20 ORDER BY clientes DESC;
ciudad clientes -------- -------- Chiclayo 25 Piura 24 Arequipa 22
Eso es "agrúpame por ciudad y quédate solo con las ciudades que tengan 20
clientes o más". El HAVING tira montones después
de armarlos.
Por eso HAVING puede usar COUNT(*) y
WHERE no. Pruébalo.
SELECT ciudad, COUNT(*) AS clientes FROM clientes WHERE COUNT(*) > 20 GROUP BY ciudad;
OperationalError: misuse of aggregate: COUNT()
misuse of aggregate. Cuando el WHERE hace su trabajo,
los grupos todavía no existen, así que no hay nada que contar. No es un capricho
de la sintaxis, es el orden en que pasan las cosas.
El orden real, que explica casi todo
Tú escribes la consulta en un orden y la base la ejecuta en otro. Este es el de verdad, y es igual en los cuatro motores:
- 1️⃣
FROM, que trae las filas - 2️⃣
WHERE, que tira filas - 3️⃣
GROUP BY, que arma los montones - 4️⃣
HAVING, que tira montones - 5️⃣
SELECT, que recién ahí calcula las columnas y los alias - 6️⃣
ORDER BY, que ordena - 7️⃣
LIMIT, que corta
Con esa lista al lado se explican solas tres cosas que confunden: por qué el
WHERE no ve los alias del SELECT (paso 2 contra paso
5), por qué el ORDER BY sí los ve (paso 6), y por qué
HAVING puede contar y WHERE no.
Usar el alias del SELECT en el HAVING
| PostgreSQL | no lo acepta: hay que repetir COUNT(*) |
| MySQL | lo acepta |
| SQL Server | no lo acepta |
| SQLite | lo acepta |
Dos sí y dos no, y esto es hermano del alias en el WHERE del capítulo 3. Si repites la función en el HAVING, funciona en los cuatro y no tienes que acordarte de nada.
Contar bien: las tres formas de COUNT
SELECT canal,
COUNT(*) AS filas,
COUNT(monto) AS con_monto,
COUNT(DISTINCT id_cliente) AS clientes_distintos
FROM pedidos
GROUP BY canal
ORDER BY canal;
canal filas con_monto clientes_distintos ----------- ----- --------- ------------------ Marketplace 229 222 101 Tienda 206 198 95 Web 234 228 105 WhatsApp 231 225 97
COUNT(*) cuenta filas, COUNT(columna) cuenta valores
que no son nulos, y COUNT(DISTINCT columna) cuenta valores
distintos. Tres números distintos en la misma fila, y cada uno contesta una
pregunta distinta:
- 📦 ¿Cuántos pedidos entraron por Web? 234.
- 💰 ¿De cuántos sé el monto? 228.
- 👥 ¿Cuántos clientes distintos compraron por Web? 105.
La cantidad de veces que he visto un reporte que dice "clientes" y está
contando pedidos… muchas 🙈 Cuando el número te salga sospechosamente redondo o
sospechosamente alto, revisa cuál de los tres COUNT pusiste.
Contar solo algunos, dentro del grupo
Esta es la técnica que convierte un GROUP BY en una tabla de
Excel con columnas, y una vez que la ves ya no la sueltas.
SELECT STRFTIME('%Y', fecha) AS anio,
ROUND(SUM(CASE WHEN canal = 'Web' THEN monto ELSE 0 END), 2) AS web,
ROUND(SUM(CASE WHEN canal = 'WhatsApp' THEN monto ELSE 0 END), 2) AS whatsapp,
ROUND(SUM(CASE WHEN canal = 'Tienda' THEN monto ELSE 0 END), 2) AS tienda,
ROUND(SUM(CASE WHEN canal = 'Marketplace' THEN monto ELSE 0 END), 2) AS marketplace
FROM pedidos
GROUP BY anio
ORDER BY anio;
anio web whatsapp tienda marketplace ---- -------- -------- -------- ----------- 2025 96347.61 91865.53 82409.71 93863.77 2026 44098.56 46103.03 36075.36 41890.28
Los canales pasaron de ser filas a ser columnas. Eso en Excel es una tabla
dinámica y aquí es un CASE WHEN dentro del SUM: suma
el monto cuando el canal es el que quiero y suma cero cuando no.
CASE WHEN ... THEN ... ELSE ... END son los
condicionales de SQL, el equivalente del SI de Excel o del
if de Python, y se escriben igual en los cuatro motores. Funcionan
en cualquier sitio donde vaya un valor: en el SELECT, en el
WHERE y en el ORDER BY.
Ojo con leer el 2026: son solo seis meses, así que la mitad de 2025 es lo que hay que comparar, no el año entero.
Contar solo las filas que cumplen algo, dentro del grupo
| PostgreSQL | COUNT(*) FILTER (WHERE monto > 800) |
| MySQL | SUM(monto > 800) -- o el CASE WHEN de siempre |
| SQL Server | COUNT(CASE WHEN monto > 800 THEN 1 END) |
| SQLite | COUNT(*) FILTER (WHERE monto > 800) -- desde la versión 3.30 |
FILTER es del estándar, se lee precioso y está en PostgreSQL y en SQLite. SQL Server y MySQL no lo tienen. El COUNT(CASE WHEN ... THEN 1 END) es más feo y funciona en los cuatro, así que es el que escribo cuando no sé dónde va a terminar corriendo la consulta.
Juntar los valores de un grupo en un texto
SELECT ciudad, GROUP_CONCAT(DISTINCT segmento) AS segmentos
FROM clientes
WHERE ciudad IN ('Lima', 'Cusco')
GROUP BY ciudad
ORDER BY ciudad;
ciudad segmentos ------ ---------------------------------- Cusco Bodega,Mayorista,Minimarket,Horeca Lima Mayorista,Minimarket,Horeca,Bodega
En vez de resumir a un número, pega todos los valores del grupo en una sola celda. Sirve muchísimo para revisar datos: de un vistazo ves qué hay dentro de cada montón.
Pegar los valores de un grupo separados por coma
| PostgreSQL | STRING_AGG(segmento, ', ') |
| MySQL | GROUP_CONCAT(segmento SEPARATOR ', ') |
| SQL Server | STRING_AGG(segmento, ', ') -- desde 2017 |
| SQLite | GROUP_CONCAT(segmento, ', ') |
Dos nombres para lo mismo, y encima MySQL pide la palabra SEPARATOR donde SQLite pide una coma. De todas las diferencias del libro, esta es la que más veces he tenido que buscar.
Agrupar por el número de la columna en vez de repetirla
| PostgreSQL | GROUP BY 1 |
| MySQL | GROUP BY 1 |
| SQL Server | no lo soporta: hay que repetir la expresión entera |
| SQLite | GROUP BY 1 |
Tres de cuatro lo aceptan y es cómodo cuando la expresión es larga, tipo GROUP BY STRFTIME('%Y-%m', fecha). Pero en una consulta que otra persona va a leer, el número no dice nada y la expresión sí.
Subtotales y total general en la misma consulta
| PostgreSQL | GROUP BY ROLLUP(ciudad, segmento) |
| MySQL | GROUP BY ciudad, segmento WITH ROLLUP |
| SQL Server | GROUP BY ROLLUP(ciudad, segmento) |
| SQLite | no lo tiene: se arma con dos consultas y un UNION ALL |
ROLLUP te agrega las filas de subtotal por ciudad y la de total general, que es justo lo que pide un gerente. SQLite es el único que se queda fuera, así que si lo necesitas ahí, toca UNION ALL a mano.
Ejercicios
Siete sobre tienda.db. Intenta antes de abrir 💛
1. Clientes por ciudad
Cuántos clientes hay en cada ciudad, de mayor a menor.
SELECT ciudad, COUNT(*) AS clientes FROM clientes GROUP BY ciudad ORDER BY clientes DESC;
ciudad clientes -------- -------- Chiclayo 25 Piura 24 Arequipa 22 Trujillo 17 Cusco 17 Lima 15
Chiclayo y Piura arriba, Lima última con 15. Contraintuitivo, y por eso vale la pena mirarlo antes de asumir que el negocio está en Lima 🇵🇪
2. Ticket promedio por canal
El ticket promedio de cada canal, y de paso cuántos montos nulos esconde cada uno.
SELECT canal, ROUND(AVG(monto), 2) AS ticket, COUNT(*) AS filas, COUNT(monto) AS con_monto FROM pedidos GROUP BY canal ORDER BY ticket DESC;
canal ticket filas con_monto ----------- ------ ----- --------- Web 615.99 234 228 WhatsApp 613.19 231 225 Marketplace 611.5 229 222 Tienda 598.41 206 198
Los cuatro tickets están entre 598 y 616, o sea que el canal casi no cambia cuánto gasta la gente: lo que cambia es cuántos pedidos entran por cada uno. Un hallazgo de los buenos, porque cambia dónde invertir 💡
3. El catálogo por categoría
Cuántos productos y qué precio promedio tiene cada categoría.
SELECT categoria, COUNT(*) AS productos, ROUND(AVG(precio), 2) AS precio_medio FROM productos GROUP BY categoria ORDER BY precio_medio DESC;
categoria productos precio_medio ---------------- --------- ------------ Cuidado personal 4 64.27 Bebidas 7 59.78 Snacks 8 52.43 Abarrotes 9 40.88 Limpieza 12 35.64
Limpieza es la categoría con más productos y la más barata; Cuidado personal es la de menos productos y la más cara. Con 40 productos en total, ojo con sacar conclusiones de las categorías de 4.
4. Los productos que más facturan
Los 5 productos con más soles vendidos, usando la tabla
detalle.
SELECT id_producto, SUM(cantidad) AS unidades, ROUND(SUM(cantidad * precio_unit), 2) AS soles FROM detalle GROUP BY id_producto ORDER BY soles DESC LIMIT 5;
id_producto unidades soles ----------- -------- -------- 16 857 71490.94 17 832 67500.16 27 741 66208.35 36 796 66044.12 40 928 64792.96
Mira que de estos cinco, el producto 40 es el que más unidades mueve (928) y aun así queda último en soles, porque es el más barato. Unidades y soles son dos rankings distintos y casi siempre te piden el equivocado 📊
5. Cuántas líneas por pedido
Cuántas líneas de detalle hay en total y a cuántos pedidos distintos pertenecen.
SELECT COUNT(*) AS lineas, COUNT(DISTINCT id_pedido) AS pedidos_con_detalle FROM detalle;
lineas pedidos_con_detalle ------ ------------------- 2682 900
2.682 líneas repartidas en los 900 pedidos, o sea unas 3 líneas por pedido.
Y ese COUNT(DISTINCT id_pedido) de 900 es una comprobación de
integridad gratis: si hubiera salido 899, habría un pedido con detalle
huérfano.
6. Alta de clientes por segmento
Por segmento: cuántos clientes, cuándo entró el primero y cuándo el último.
SELECT segmento, COUNT(*) AS clientes, MIN(fecha_alta) AS primero, MAX(fecha_alta) AS ultimo FROM clientes GROUP BY segmento ORDER BY clientes DESC;
segmento clientes primero ultimo ---------- -------- ---------- ---------- Horeca 36 2025-01-15 2026-02-04 Bodega 35 2025-01-05 2026-01-31 Minimarket 25 2025-01-04 2025-12-16 Mayorista 24 2025-01-12 2026-01-30
MIN y MAX sobre una fecha en texto funcionan porque
el formato es AAAA-MM-DD, que ordena igual como fecha y como texto. Con
'24/06/2026' te habrían dado cualquier cosa.
Y ahí ves algo: Minimarket no suma un cliente nuevo desde diciembre. Eso es una pregunta para el área comercial, no una respuesta.
7. Escríbelo para los cuatro
Sin ejecutar: por ciudad, cuántos clientes y la lista de sus segmentos separados por coma, solo para las ciudades con 20 o más clientes.
-- PostgreSQL SELECT ciudad, COUNT(*) AS clientes, STRING_AGG(DISTINCT segmento, ', ') AS segmentos FROM clientes GROUP BY ciudad HAVING COUNT(*) >= 20 ORDER BY clientes DESC; -- MySQL SELECT ciudad, COUNT(*) AS clientes, GROUP_CONCAT(DISTINCT segmento SEPARATOR ', ') AS segmentos FROM clientes GROUP BY ciudad HAVING COUNT(*) >= 20 ORDER BY clientes DESC; -- SQL Server SELECT ciudad, COUNT(*) AS clientes, STRING_AGG(segmento, ', ') AS segmentos FROM clientes GROUP BY ciudad HAVING COUNT(*) >= 20 ORDER BY COUNT(*) DESC; -- SQLite SELECT ciudad, COUNT(*) AS clientes, GROUP_CONCAT(DISTINCT segmento) AS segmentos FROM clientes GROUP BY ciudad HAVING COUNT(*) >= 20 ORDER BY clientes DESC;
Tres cosas cambian entre las cuatro: el nombre de la función, cómo se pide el
separador, y que en SQL Server el ORDER BY tampoco acepta el alias
cuando hay GROUP BY, así que hay que repetir el
COUNT(*). Y el STRING_AGG de SQL Server no admite
DISTINCT, que es la cuarta 🫠
Lo que te llevas
- 📦
GROUP BYjunta las filas con el mismo valor y resume cada montón. Cinco funciones te dan casi todo:COUNT,SUM,AVG,MIN,MAX. - ⚖️ Todo lo del
SELECTva dentro de una función de resumen o dentro delGROUP BY. PostgreSQL, MySQL y SQL Server te lo exigen; SQLite te deja pasar y te devuelve un valor cualquiera. - 🕳️ Los nulos forman su propio grupo y pueden encabezar tu ranking. Aquí eran 24 pedidos sin cliente.
- 🚦
WHEREfiltra filas antes de agrupar;HAVINGfiltra grupos después. Por esoWHERE COUNT(*)da error. - 🔢
COUNT(*),COUNT(columna)yCOUNT(DISTINCT columna)son tres preguntas distintas. - 🎛️
SUM(CASE WHEN ... THEN ... ELSE 0 END)convierte filas en columnas y funciona en los cuatro.FILTERes más bonito pero solo está en PostgreSQL y SQLite. - 🔗 Pegar los valores de un grupo es
STRING_AGGen PostgreSQL y SQL Server,GROUP_CONCATen MySQL y SQLite.
En el capítulo 7 llegan los JOIN, que es lo que nos falta para
poder decir "el ticket promedio por ciudad": el ticket está en
pedidos y la ciudad está en clientes, y hasta ahora no
sabemos juntarlas.
Que tengas lindo día! 🌸