Capítulo 6 de 15 10 secciones 17 min

GROUP BY, donde SQL empieza a responder

Resumir 900 filas en cuatro números, y la regla que PostgreSQL te exige y SQLite te deja pasar.

GROUP BY junta las filas que comparten un valor y calcula un resumen por grupo. La regla del estándar es que todo lo del SELECT esté dentro de una función de resumen o dentro del GROUP BY: PostgreSQL, MySQL y SQL Server la exigen y SQLite no, así que la misma consulta puede dar un número distinto según dónde corra. WHERE filtra filas antes de agrupar y HAVING filtra grupos después.

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

PostgreSQLERROR: column "pedidos.monto" must appear in the GROUP BY clause
MySQLERROR desde 5.7, que trae ONLY_FULL_GROUP_BY activo de fábrica
SQL ServerERROR: Column 'pedidos.monto' is invalid in the select list
SQLitelo 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:

Orden real de ejecución de una consulta SQL: FROM y JOIN, WHERE, GROUP BY, HAVING, SELECT, ORDER BY y LIMIT. Se escribe empezando por SELECT pero se ejecuta empezando por FROM.
Aquí está la explicación de casi todas las confusiones con SQL: se escribe en un orden y se ejecuta en otro. Por eso no puedes usar en el WHERE un alias que creaste en el SELECT, y por eso WHERE filtra filas y HAVING filtra grupos.
  1. 1️⃣ FROM, que trae las filas
  2. 2️⃣ WHERE, que tira filas
  3. 3️⃣ GROUP BY, que arma los montones
  4. 4️⃣ HAVING, que tira montones
  5. 5️⃣ SELECT, que recién ahí calcula las columnas y los alias
  6. 6️⃣ ORDER BY, que ordena
  7. 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

PostgreSQLno lo acepta: hay que repetir COUNT(*)
MySQLlo acepta
SQL Serverno lo acepta
SQLitelo 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

PostgreSQLCOUNT(*) FILTER (WHERE monto > 800)
MySQLSUM(monto > 800) -- o el CASE WHEN de siempre
SQL ServerCOUNT(CASE WHEN monto > 800 THEN 1 END)
SQLiteCOUNT(*) 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

PostgreSQLSTRING_AGG(segmento, ', ')
MySQLGROUP_CONCAT(segmento SEPARATOR ', ')
SQL ServerSTRING_AGG(segmento, ', ') -- desde 2017
SQLiteGROUP_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

PostgreSQLGROUP BY 1
MySQLGROUP BY 1
SQL Serverno lo soporta: hay que repetir la expresión entera
SQLiteGROUP 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

PostgreSQLGROUP BY ROLLUP(ciudad, segmento)
MySQLGROUP BY ciudad, segmento WITH ROLLUP
SQL ServerGROUP BY ROLLUP(ciudad, segmento)
SQLiteno 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 BY junta 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 SELECT va dentro de una función de resumen o dentro del GROUP 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.
  • 🚦 WHERE filtra filas antes de agrupar; HAVING filtra grupos después. Por eso WHERE COUNT(*) da error.
  • 🔢 COUNT(*), COUNT(columna) y COUNT(DISTINCT columna) son tres preguntas distintas.
  • 🎛️ SUM(CASE WHEN ... THEN ... ELSE 0 END) convierte filas en columnas y funciona en los cuatro. FILTER es más bonito pero solo está en PostgreSQL y SQLite.
  • 🔗 Pegar los valores de un grupo es STRING_AGG en PostgreSQL y SQL Server, GROUP_CONCAT en 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! 🌸

¿Tienes alguna duda o consulta?