Hasta ahora, cada fila de tus resultados venía de una fila de la base de datos. SELECT proyectaba columnas, WHERE descartaba filas, JOIN combinaba tablas: la granularidad podía multiplicarse, pero seguías trabajando fila a fila. Las funciones de agregación rompen esa correspondencia. Reciben muchas filas y devuelven un solo valor: un total, un recuento, una media, un máximo.

Es el paso que convierte una consulta en un informe. "¿Cuánto hemos facturado?", "¿cuántos pedidos hay pendientes?", "¿cuál es el ticket medio?" son preguntas que ninguna consulta anterior podía responder. En esta lección aprenderás las cinco funciones fundamentales, verás por qué todas ignoran los NULL salvo COUNT(*) —y por qué eso a veces te conviene y a veces te engaña—, descubrirás que SUM de un conjunto vacío devuelve NULL mientras que COUNT devuelve 0, y resolverás por fin el aviso que el módulo 3 te repitió tres veces: sumar los gastos de envío después de unir con el detalle infla el total. Verás el número mal, el número bien y las dos formas correctas de plantearlo.

Contenido

  1. Qué es una función de agregación
  2. Las cinco funciones y sus tipos de retorno
  3. Las tres formas de COUNT
  4. SUM y AVG sobre el detalle de ventas
  5. AVG ignora los NULL: dos respuestas correctas para preguntas distintas
  6. MIN y MAX sobre números, fechas y texto
  7. El conjunto vacío: SUM da NULL, COUNT da 0
  8. Un agregado colapsa toda la tabla, y no se puede mezclar con una columna suelta
  9. FILTER (WHERE ...): agregar solo una parte
  10. STRING_AGG y ARRAY_AGG: agregados no numéricos
  11. La resolución del aviso del módulo 3
  12. Errores Comunes y Consejos
  13. Ejercicios
  14. Conclusión

  1. Qué es una función de agregación

Una función de agregación recorre un conjunto de filas y produce un único valor.

flowchart LR
    subgraph E["Entrada: 47 filas de lineas_pedido"]
      A["23.90"]
      B["11.70"]
      C["6.50"]
      D["…"]
    end
    E --> F["SUM( )"]
    F --> G["727.95<br/>1 fila, 1 valor"]

La consulta más simple posible con un agregado:

SELECT COUNT(*) AS total_lineas
FROM lineas_pedido;
total_lineas
47

Una sola fila, aunque la tabla tenga 47. Ese colapso es la característica que define la operación, y de él salen todas las reglas de la lección.

Dos observaciones que conviene fijar desde el principio:

  • Un agregado sin GROUP BY colapsa la tabla entera en una fila. Siempre devuelve exactamente una, incluso si la tabla está vacía.
  • Las funciones de agregación no pueden usarse en el WHERE. El WHERE decide qué filas entran en el conjunto, así que no puede depender de un cálculo hecho sobre ese mismo conjunto. Para filtrar por un agregado existe HAVING, y es la lección 04-06.

  1. Las cinco funciones y sus tipos de retorno

Función Qué devuelve Tipos que acepta ¿Ignora NULL? Conjunto vacío
COUNT(*) Número de filas No aplica 0
COUNT(expr) Número de valores no nulos Cualquiera 0
SUM(expr) Suma Numéricos, INTERVAL NULL
AVG(expr) Media aritmética Numéricos, INTERVAL NULL
MIN(expr) Valor mínimo Cualquier tipo ordenable NULL
MAX(expr) Valor máximo Cualquier tipo ordenable NULL

Y los tipos de retorno en PostgreSQL, que importan más de lo que parece:

Expresión de entrada COUNT SUM AVG MIN / MAX
SMALLINT / INTEGER BIGINT BIGINT NUMERIC mismo tipo
BIGINT BIGINT NUMERIC NUMERIC BIGINT
NUMERIC BIGINT NUMERIC NUMERIC NUMERIC
REAL / DOUBLE BIGINT DOUBLE DOUBLE mismo tipo
DATE, TEXT, BOOLEAN BIGINT mismo tipo

Dos detalles con consecuencias prácticas:

  1. SUM de enteros devuelve BIGINT, no INTEGER. Es una protección contra el desbordamiento: sumar un millón de enteros grandes desborda INTEGER con facilidad.
  2. AVG de un entero devuelve NUMERIC, no un entero. PostgreSQL no trunca. Lo verás en la sección 6, y es una diferencia importante con otros motores.

  1. Las tres formas de COUNT

COUNT tiene tres escrituras que casi nunca dan el mismo número, y confundirlas es una de las fuentes de error más habituales en informes.

Escritura Cuenta
COUNT(*) Filas. Todas, tengan los valores que tengan
COUNT(columna) Valores no nulos de esa columna
COUNT(DISTINCT columna) Valores distintos y no nulos de esa columna

La mejor demostración posible está en pedidos.empleado_id, que tiene 20 filas, 10 valores y 3 comerciales distintos:

SELECT COUNT(*)                     AS filas,
       COUNT(empleado_id)           AS con_comercial,
       COUNT(DISTINCT empleado_id)  AS comerciales_distintos
FROM pedidos;
filas con_comercial comerciales_distintos
20 10 3

20, 10 y 3. Tres números sobre la misma columna de la misma tabla, y los tres son correctos porque responden a preguntas distintas:

  • 20: ¿cuántos pedidos hay? Todos los pedidos existen, tengan comercial o no.
  • 10: ¿cuántos pedidos gestionó un comercial? Los diez del canal telefónico. Los NULL del canal web no se cuentan.
  • 3: ¿cuántos comerciales han gestionado algún pedido? Óscar (4), Laia (5) y Marc (6). Los otros cinco empleados nunca aparecen.

Esta es la prueba definitiva de que los agregados ignoran los NULL: COUNT(*) es la única forma que cuenta las diez filas del canal web, porque es la única que no mira ningún valor.

flowchart TD
    A["20 filas de pedidos"] --> B["COUNT(*)<br/>cuenta filas<br/>→ 20"]
    A --> C["COUNT(empleado_id)<br/>descarta los 10 NULL<br/>→ 10"]
    A --> D["COUNT(DISTINCT empleado_id)<br/>descarta NULL y duplicados<br/>→ 3"]

Cuándo usar cada una

Pregunta de negocio Escritura correcta
"¿Cuántos pedidos hemos recibido?" COUNT(*)
"¿Cuántos pedidos llevan comercial asignado?" COUNT(empleado_id)
"¿Cuántos comerciales están activos en ventas?" COUNT(DISTINCT empleado_id)
"¿Cuántos clientes distintos han comprado?" COUNT(DISTINCT cliente_id)

Ese último merece verse, porque conecta con el DISTINCT de 02-04 y con los clientes desaparecidos de 03-02:

SELECT COUNT(*)                    AS pedidos,
       COUNT(DISTINCT cliente_id)  AS clientes_compradores
FROM pedidos;
pedidos clientes_compradores
20 12

12 de los 15 clientes han comprado alguna vez. Los tres que faltan son Núria, Hugo e Inés, exactamente los que recuperaste con el anti-join de 03-03. Y fíjate en algo importante: COUNT(DISTINCT cliente_id) sobre pedidos no puede decirte que son 15 en total, porque en pedidos no existen. Para eso hay que partir de clientes.

Aviso de rendimiento: COUNT(DISTINCT columna) es notablemente más caro que COUNT(columna), porque obliga al motor a ordenar o a construir una tabla hash con todos los valores. Con 20 filas es irrelevante; con cien millones, es la diferencia entre un segundo y varios minutos. Úsalo cuando lo necesites, no por costumbre.

Nota de dialecto: COUNT(DISTINCT a, b) con varias columnas funciona en MySQL pero no en PostgreSQL, donde hay que escribir COUNT(DISTINCT (a, b)) usando la sintaxis de fila compuesta. Y COUNT(*) frente a COUNT(1): en PostgreSQL son idénticos en rendimiento y ambos se optimizan igual, así que la elección es puramente de estilo. El curso usa COUNT(*).

  1. SUM y AVG sobre el detalle de ventas

Ahora la pregunta que el negocio hace de verdad: ¿cuánto hemos facturado? El importe de una línea es el de siempre, y se calcula con lp.precio_unitario (la regla de 03-02), nunca con p.precio.

SELECT COUNT(*)                                                  AS lineas,
       SUM(lp.cantidad)                                          AS unidades,
       ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS facturacion,
       ROUND(AVG(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS importe_medio_linea
FROM lineas_pedido AS lp;
lineas unidades facturacion importe_medio_linea
47 113 727.95 15.49

Estas son las cifras maestras de TiendaVerde: 47 líneas, 113 unidades vendidas y 727,95 € de facturación de producto (sin contar gastos de envío, que veremos en la sección 11).

Por qué se agrega sobre precio_unitario y no sobre productos.precio

Es el momento de comprobar con números lo que 03-02 anunciaba. Las líneas 1 y 4 llevan precio histórico, anterior a la subida de tarifas de abril de 2025:

SELECT lp.id,
       p.nombre AS producto,
       lp.cantidad,
       lp.precio_unitario                     AS precio_cobrado,
       p.precio                               AS precio_actual,
       ROUND(lp.cantidad * lp.precio_unitario, 2) AS importe_real,
       ROUND(lp.cantidad * p.precio, 2)           AS importe_si_usaras_el_actual
FROM lineas_pedido AS lp
JOIN productos AS p ON lp.producto_id = p.id
WHERE lp.precio_unitario <> p.precio
ORDER BY lp.id;
id producto cantidad precio_cobrado precio_actual importe_real importe_si_usaras_el_actual
1 Aceite de oliva virgen extra 500 ml 2 11.95 12.50 23.90 25.00
4 Crema facial de aloe vera 50 ml 1 17.50 18.90 17.50 18.90

2 líneas de 47. La diferencia acumulada es de 2,50 €, calderilla en este conjunto de datos. Pero fíjate en lo que significa: si agregaras sobre p.precio, estarías afirmando que en marzo de 2025 se facturaron 2,50 € que nunca entraron en caja. Con un catálogo real y varias subidas de tarifa al año, esa cifra se cuenta en miles.

Regla del curso, ahora con agregados: SUM de ventas siempre sobre lp.precio_unitario. productos.precio sirve para responder "¿cuánto cuesta hoy?", nunca "¿cuánto facturamos entonces?".

Sumar y contar sobre un subconjunto

Los agregados se combinan con WHERE con toda naturalidad, y el WHERE actúa antes:

SELECT COUNT(*)                                                  AS lineas_2026,
       ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS facturacion_2026
FROM lineas_pedido AS lp
JOIN pedidos AS pe ON lp.pedido_id = pe.id
WHERE pe.fecha_pedido >= DATE '2026-01-01'
  AND pe.fecha_pedido <  DATE '2027-01-01';
lineas_2026 facturacion_2026
7 124.43

124,43 € en los cuatro pedidos de 2026, frente a 603,52 € en los dieciséis de 2025. Suman los 727,95 € del total, como debe ser.

  1. AVG ignora los NULL: dos respuestas correctas para preguntas distintas

Que AVG ignore los nulos parece un detalle técnico. Es una decisión de negocio disfrazada, y conviene verla con un caso donde la diferencia duele.

La pregunta: "¿cuál es el salario medio del comercial que gestiona nuestros pedidos?"

SELECT COUNT(*)         AS pedidos,
       COUNT(e.salario) AS pedidos_con_comercial,
       SUM(e.salario)   AS suma_salarios,
       AVG(e.salario)   AS media_avg
FROM pedidos AS pe
LEFT JOIN empleados AS e ON pe.empleado_id = e.id;
pedidos pedidos_con_comercial suma_salarios media_avg
20 10 274200.00 27420.000000000000

AVG ha devuelto 27 420 €: ha sumado 274 200 € y ha dividido entre 10, no entre 20, porque los diez pedidos web tienen e.salario a NULL y AVG los ignora.

Ahora la otra lectura de la misma pregunta:

SELECT ROUND(SUM(e.salario) / COUNT(*), 2) AS media_sobre_todas_las_filas
FROM pedidos AS pe
LEFT JOIN empleados AS e ON pe.empleado_id = e.id;
media_sobre_todas_las_filas
13710.00

13 710 €, exactamente la mitad. Y las dos cifras son correctas:

Cifra Divide entre Responde a
27 420 € 10 (los valores no nulos) "De los pedidos que llevan comercial, ¿cuál es su salario medio?"
13 710 € 20 (todas las filas) "Por cada pedido que entra, ¿cuánto salario de comercial hay detrás de media?"

La segunda tiene sentido si estás repartiendo el coste comercial entre todo el volumen de pedidos, incluidos los que no consumen tiempo de nadie. La primera, si estás comparando perfiles de comercial. Elegir mal no da error: da una cifra que dobla o divide por dos la realidad.

flowchart TD
    A["AVG(columna)"] --> B["SUM de los valores<br/>NO nulos"]
    A --> C["dividido entre<br/>COUNT(columna)"]
    D["¿Quieres los NULL<br/>como cero?"] -->|"Sí"| E["SUM(col) / COUNT(*)<br/>o AVG(COALESCE(col, 0))"]
    D -->|"No"| F["AVG(col) tal cual"]

La forma explícita de tratar los nulos como ceros es AVG(COALESCE(e.salario, 0)), que devolvería los mismos 13 710 €. COALESCE se estudia en 06-04; menciónalo mentalmente cada vez que escribas un AVG sobre una columna nullable.

La pregunta que hay que hacerse siempre antes de escribir AVG: "¿la media es sobre las filas que tienen dato, o sobre todas las filas?". Si no puedes responderla, la consulta todavía no está definida.

  1. MIN y MAX sobre números, fechas y texto

MIN y MAX funcionan sobre cualquier tipo que se pueda ordenar, no solo sobre números. Es su característica más infrautilizada.

SELECT MIN(precio)      AS precio_minimo,
       MAX(precio)      AS precio_maximo,
       MIN(fecha_alta)  AS primera_alta,
       MAX(fecha_alta)  AS ultima_alta,
       MIN(nombre)      AS primero_alfabeticamente,
       MAX(nombre)      AS ultimo_alfabeticamente
FROM productos;
precio_minimo precio_maximo primera_alta ultima_alta primero_alfabeticamente ultimo_alfabeticamente
1.95 22.00 2025-01-15 2025-06-01 Aceite corporal de almendras 200 ml Zumo de naranja prensado en frío 1 L

Cuatro tipos de dato en una consulta: NUMERIC, DATE y TEXT. Sobre texto, MIN/MAX usan la colación de la base (02-05), así que el resultado puede variar entre servidores configurados de forma distinta.

El uso más frecuente en la práctica es sobre fechas, para acotar el histórico:

SELECT COUNT(*)          AS pedidos,
       MIN(fecha_pedido) AS primer_pedido,
       MAX(fecha_pedido) AS ultimo_pedido,
       MIN(gastos_envio) AS envio_minimo,
       MAX(gastos_envio) AS envio_maximo
FROM pedidos;
pedidos primer_pedido ultimo_pedido envio_minimo envio_maximo
20 2025-03-04 2026-02-21 0.00 12.50

Once meses y medio de histórico, del 4 de marzo de 2025 al 21 de febrero de 2026, con portes entre 0,00 € (envío gratuito) y 12,50 €.

El tipo de retorno de AVG y por qué salen tantos decimales

SELECT COUNT(*)        AS resenas,
       MIN(puntuacion) AS peor,
       MAX(puntuacion) AS mejor,
       AVG(puntuacion) AS media_bruta
FROM resenas;
resenas peor mejor media_bruta
12 2 5 4.0833333333333333

La peor puntuación de todo TiendaVerde es un 2, el del kombucha de jengibre ("demasiado jengibre para mi gusto"). Y fíjate en la media: dieciséis decimales. Ocurre porque AVG sobre una columna SMALLINT devuelve NUMERIC, y la división de dos NUMERIC en PostgreSQL se calcula con al menos 16 dígitos significativos para no perder precisión. No es un fallo: es la garantía de que el motor no ha redondeado por su cuenta.

La forma de presentarlo es la de siempre: calcular con precisión completa y redondear solo al mostrar.

SELECT COUNT(*)                 AS resenas,
       ROUND(AVG(puntuacion), 2) AS puntuacion_media
FROM resenas;
resenas puntuacion_media
12 4.08

Nota de dialecto: este es uno de los puntos donde más se divergen los motores, y donde más errores silenciosos se producen al portar código.

Motor AVG sobre una columna entera Resultado con 49/12
PostgreSQL NUMERIC con precisión completa 4.0833333333333333
MySQL DECIMAL 4.0833
SQLite Siempre REAL (coma flotante) 4.083333333333333
SQL Server INT: trunca 4
Oracle NUMBER 4.08333333333333…

SQL Server es el caso peligroso: AVG de una columna INT hace división entera y devuelve 4. Para obtener el decimal hay que convertir explícitamente: AVG(CAST(puntuacion AS DECIMAL(10,2))). Un informe portado de PostgreSQL a SQL Server puede empezar a redondear a la baja sin que nadie lo advierta.

  1. El conjunto vacío: SUM da NULL, COUNT da 0

Esta es una trampa clásica de informes, y TiendaVerde tiene el caso perfecto: la categoría 6 (Complementos) tiene un solo producto, el 20 (Cápsulas de espirulina), que está descatalogado y nunca se ha vendido.

SELECT COUNT(*)                                                  AS lineas,
       SUM(lp.cantidad)                                          AS unidades,
       SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)) AS facturacion,
       AVG(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)) AS importe_medio,
       MAX(lp.cantidad)                                          AS maxima_cantidad
FROM lineas_pedido AS lp
JOIN productos AS p ON lp.producto_id = p.id
WHERE p.categoria_id = 6;
lineas unidades facturacion importe_medio maxima_cantidad
0 (null) (null) (null) (null)

Una sola fila, con un 0 y cuatro nulos. Dos cosas que aprender de aquí:

  1. Una consulta con agregados y sin GROUP BY siempre devuelve una fila, aunque el WHERE no deje pasar ninguna. No devuelve "0 filas": devuelve una fila con el resultado de agregar la nada.
  2. COUNT de la nada es 0; SUM, AVG, MIN y MAX de la nada son NULL. Es coherente: contar cero elementos da cero, pero la suma de un conjunto vacío no tiene un valor natural que devolver, y su media aún menos.

Por qué importa: si tu informe calcula facturacion * 1.21 para añadir el IVA, esa celda mostrará NULL, no 0.00. Y si la aplicación que consume el resultado espera un número, puede fallar. La solución es COALESCE(SUM(...), 0), que verás en 06-04.

Agregado Conjunto vacío Todos los valores NULL
COUNT(*) 0 n (cuenta filas)
COUNT(expr) 0 0
SUM(expr) NULL NULL
AVG(expr) NULL NULL
MIN / MAX NULL NULL

Fíjate en la columna de la derecha: es el mismo comportamiento. Para SUM y AVG da igual que no haya filas o que las haya todas con el valor a nulo. Es coherente con la regla de la sección 3: los agregados descartan los nulos antes de operar, así que "todo nulo" y "vacío" acaban siendo el mismo conjunto.

  1. Un agregado colapsa toda la tabla, y no se puede mezclar con una columna suelta

Ya lo has visto: sin GROUP BY, el agregado se aplica a todas las filas que salen del WHERE y produce una fila. Y de ahí sale la restricción más importante de esta lección.

-- ⚠️ INCORRECTA
SELECT p.nombre,
       COUNT(*) AS productos
FROM productos AS p;
ERROR:  column "p.nombre" must appear in the GROUP BY clause or be used in an aggregate function
LINE 1: SELECT p.nombre,
               ^

El error tiene toda la razón. Piénsalo: COUNT(*) va a devolver una sola fila con el valor 20. ¿Qué debería aparecer en la columna nombre de esa fila? ¿"Aceite de oliva"? ¿"Zumo de naranja"? ¿Los veinte concatenados? La pregunta no tiene respuesta, y PostgreSQL se niega a inventarse una.

La regla general, que gobernará toda la lección 04-05:

Toda columna del SELECT debe estar dentro de una función de agregación o formar parte del GROUP BY. No hay tercera opción.

Aquí hay tres salidas, y cada una responde a una pregunta distinta:

Qué quieres Cómo se escribe
Un solo número para toda la tabla SELECT COUNT(*) FROM productos;
Un número por cada categoría Añadir GROUP BY categoria_idlección 04-05
Un nombre concreto junto al total Agregar también el nombre: MIN(p.nombre), o usar funciones de ventana (módulo 10)

Esta es exactamente la puerta de entrada a GROUP BY. La mayoría de las preguntas de negocio no son "¿cuánto hemos facturado?" sino "¿cuánto hemos facturado por categoría?", y eso requiere partir la tabla en grupos antes de agregar.

Nota de dialecto: MySQL con el modo ONLY_FULL_GROUP_BY desactivado acepta esa consulta sin protestar y devuelve un valor arbitrario de nombre. SQLite hace lo mismo siempre. Es una comodidad que produce informes silenciosamente incorrectos, y la lección 04-05 la trata en detalle con una tabla comparativa. Desde MySQL 5.7.5 el modo está activo por defecto, precisamente por esto.

  1. FILTER (WHERE ...): agregar solo una parte

A menudo quieres varios agregados sobre subconjuntos distintos en una misma fila de resultado: total, entregados, cancelados. Escribir tres consultas y unirlas es tedioso. PostgreSQL ofrece la cláusula FILTER:

SELECT COUNT(*)                                        AS pedidos,
       COUNT(*) FILTER (WHERE estado = 'entregado')     AS entregados,
       COUNT(*) FILTER (WHERE estado = 'cancelado')     AS cancelados,
       COUNT(*) FILTER (WHERE empleado_id IS NULL)      AS canal_web,
       SUM(gastos_envio)                                AS envio_total,
       SUM(gastos_envio) FILTER (WHERE estado = 'entregado') AS envio_entregados
FROM pedidos;
pedidos entregados cancelados canal_web envio_total envio_entregados
20 14 1 10 118.25 76.05

Seis métricas en una sola pasada sobre la tabla. La sintaxis es AGREGADO(expr) FILTER (WHERE condición): la condición decide qué filas entran en ese agregado concreto, sin afectar a los demás ni al WHERE general de la consulta.

Un ejemplo con dinero, comparando los dos ejercicios de TiendaVerde:

SELECT ROUND(SUM(imp.importe), 2)                                          AS total,
       ROUND(SUM(imp.importe) FILTER (WHERE imp.anio = 2025), 2)           AS ventas_2025,
       ROUND(SUM(imp.importe) FILTER (WHERE imp.anio = 2026), 2)           AS ventas_2026,
       COUNT(*) FILTER (WHERE imp.descuento > 0)                           AS lineas_con_descuento
FROM (
    SELECT lp.cantidad * lp.precio_unitario * (1 - lp.descuento) AS importe,
           lp.descuento,
           EXTRACT(YEAR FROM pe.fecha_pedido)                    AS anio
    FROM lineas_pedido AS lp
    JOIN pedidos AS pe ON lp.pedido_id = pe.id
) AS imp;
total ventas_2025 ventas_2026 lineas_con_descuento
727.95 603.52 124.43 6

Solo 6 de las 47 líneas llevan descuento. (EXTRACT se ve a fondo en 06-03; aquí solo extrae el año. La subconsulta en el FROM es del módulo 7: aquí sirve únicamente para no repetir tres veces la expresión del importe.)

Portabilidad: FILTER es SQL estándar pero solo lo implementan PostgreSQL (9.4+) y SQLite (3.30+). En MySQL, SQL Server y Oracle hay que escribir la forma clásica y portable, que consiste en meter un CASE dentro del agregado:

COUNT(CASE WHEN estado = 'entregado' THEN 1 END)  AS entregados
SUM(CASE WHEN estado = 'entregado' THEN gastos_envio ELSE 0 END) AS envio_entregados

Funciona porque COUNT ignora los NULL que devuelve el CASE sin ELSE. Es exactamente el mismo mecanismo de la sección 3, aprovechado a propósito. CASE es la lección 06-05.

  1. STRING_AGG y ARRAY_AGG: agregados no numéricos

No todos los agregados producen números. Dos de PostgreSQL son especialmente útiles y conviene conocerlos ya, aunque su terreno natural sea el GROUP BY de mañana:

SELECT COUNT(*) AS comerciales,
       STRING_AGG(nombre || ' ' || apellidos, ', ' ORDER BY id) AS equipo,
       ARRAY_AGG(id ORDER BY id)                                AS ids
FROM empleados
WHERE puesto = 'Comercial';
comerciales equipo ids
2 Óscar Peris Blasco, Laia Puig Sanchis {4,5}
  • STRING_AGG(expresión, separador) concatena los valores de todas las filas en una sola cadena. El ORDER BY interno es fundamental: sin él, el orden de concatenación es arbitrario y el resultado no es reproducible.
  • ARRAY_AGG(expresión) hace lo mismo pero devolviendo un array de PostgreSQL, útil cuando la aplicación va a procesar los valores por separado.

Ambos ignoran los NULL, como el resto.

Nota de dialecto: STRING_AGG existe en PostgreSQL y en SQL Server (2017+). MySQL y SQLite tienen GROUP_CONCAT con sintaxis distinta, y Oracle usa LISTAGG. ARRAY_AGG es específico de PostgreSQL, porque depende de que el motor tenga tipos array nativos.

  1. La resolución del aviso del módulo 3

Llegamos al plato fuerte. El módulo 3 te avisó tres veces —en 03-02, en 03-03 y en su conclusión— de que sumar un valor de cabecera después de unir con el detalle infla el resultado. Ahora ya tienes las herramientas para verlo, medirlo y arreglarlo.

La pregunta: ¿cuánto hemos ingresado en total por gastos de envío?

El número mal

-- ⚠️ INCORRECTA: infla los gastos de envío
SELECT COUNT(*)              AS filas,
       SUM(pe.gastos_envio)  AS gastos_envio_total
FROM lineas_pedido AS lp
JOIN pedidos AS pe ON lp.pedido_id = pe.id;
filas gastos_envio_total
47 278.70

El número bien

-- ✅ CORRECTA: agrega solo sobre la tabla de cabecera
SELECT COUNT(*)             AS filas,
       SUM(gastos_envio)    AS gastos_envio_total
FROM pedidos;
filas gastos_envio_total
20 118.25

278,70 € frente a 118,25 €. El error es de 160,45 €: la cifra inflada es 2,36 veces la real, un factor muy cercano al número medio de líneas por pedido (47 / 20 = 2,35). No coincide exactamente porque los pedidos con más líneas no son los que más portes pagan, pero el orden de magnitud del error siempre lo marca esa proporción.

Por qué ocurre, con el pedido 1 a la vista

SELECT pe.id AS pedido_id,
       pe.gastos_envio,
       lp.id AS linea_id,
       lp.producto_id
FROM pedidos AS pe
JOIN lineas_pedido AS lp ON lp.pedido_id = pe.id
WHERE pe.id = 1
ORDER BY lp.id;
pedido_id gastos_envio linea_id producto_id
1 4.95 1 1
1 4.95 2 2
1 4.95 3 14

Los 4,95 € que TiendaVerde cobró una sola vez aparecen en tres filas, porque el pedido tiene tres líneas. SUM no sabe que son el mismo cobro repetido: suma lo que ve, 14,85 € para este pedido. Y no hay ningún error, ningún aviso: solo un número equivocado en un informe de dirección.

flowchart TD
    A["pedidos<br/>20 filas · 118.25 € de portes"] --> B["JOIN lineas_pedido"]
    B --> C["47 filas<br/>cada gasto_envio repetido<br/>tantas veces como líneas"]
    C --> D["SUM(pe.gastos_envio)<br/>= 278.70 €<br/>❌ inflado"]
    A --> E["SUM(gastos_envio)<br/>sin unir con el detalle<br/>= 118.25 €<br/>✅ correcto"]

Las dos formas correctas de plantearlo

Forma 1 (la de hoy): agregar cada cosa a su nivel de granularidad.

Si la pregunta es sobre cabeceras, agrega la tabla de cabeceras. Si es sobre líneas, agrega las líneas. Dos consultas separadas, cada una con su granularidad natural:

-- Facturación de producto: nivel LÍNEA (47 filas)
SELECT ROUND(SUM(cantidad * precio_unitario * (1 - descuento)), 2) AS facturacion_producto
FROM lineas_pedido;
facturacion_producto
727.95
-- Ingresos por portes: nivel PEDIDO (20 filas)
SELECT SUM(gastos_envio) AS ingresos_portes
FROM pedidos;
ingresos_portes
118.25

Total facturado por TiendaVerde: 727,95 + 118,25 = 846,20 €. Esta es la cifra correcta, y se obtiene sumando dos agregados calculados por separado, cada uno sobre su tabla.

Forma 2 (la del módulo 7): agregar el detalle primero y unirlo después.

Cuando necesites las dos cifras en una misma consulta —por ejemplo, el total de cada pedido con sus portes— la técnica correcta consiste en colapsar el detalle a nivel de pedido antes de unirlo con la cabecera. Eso exige una subconsulta o una CTE, que son los módulos 7 y 10. El esqueleto, para que lo reconozcas cuando llegue:

-- Avance del módulo 7: no lo escribas aún, solo léelo
SELECT pe.id,
       pe.gastos_envio,
       tot.importe_lineas,
       pe.gastos_envio + tot.importe_lineas AS total_pedido
FROM pedidos AS pe
JOIN (SELECT pedido_id,
             SUM(cantidad * precio_unitario * (1 - descuento)) AS importe_lineas
      FROM lineas_pedido
      GROUP BY pedido_id) AS tot ON tot.pedido_id = pe.id;

La idea clave: la subconsulta reduce las 47 líneas a 20 filas, una por pedido. Al unirla con pedidos ya no hay multiplicación, y pe.gastos_envio aparece una sola vez por pedido. Es la solución general al problema, y por eso 07-01 empieza justo aquí.

Cómo detectarlo tú mismo, siempre

Comprobación Cómo
Cuenta las filas antes de agregar Si tu FROM con JOIN devuelve 47 filas y estás sumando una columna de pedidos, la estás sumando 47 veces
Compara con el agregado directo SUM(gastos_envio) FROM pedidos es la verdad de referencia. Si tu consulta compleja no coincide, tienes multiplicación
Pregúntate a qué nivel vive cada columna gastos_envio vive en el pedido; cantidad vive en la línea. Sumarlas juntas exige llevarlas antes al mismo nivel
Usa COUNT(DISTINCT pe.id) Si es menor que COUNT(*), las cabeceras están repetidas

Esa última comprobación, aplicada aquí:

SELECT COUNT(*)                AS filas,
       COUNT(DISTINCT pe.id)   AS pedidos_reales
FROM lineas_pedido AS lp
JOIN pedidos AS pe ON lp.pedido_id = pe.id;
filas pedidos_reales
47 20

47 ≠ 20: hay multiplicación. En cuanto veas esa desigualdad, sabes que no puedes sumar ninguna columna de pedidos sin corregirla.

Errores Comunes y Consejos

  • Sumar una columna de cabecera después de unir con el detalle. El error de esta lección: 278,70 € en lugar de 118,25 €. Agrega cada tabla a su nivel.
  • Confundir COUNT(*), COUNT(columna) y COUNT(DISTINCT columna). 20, 10 y 3 sobre la misma columna. Elige según la pregunta, no por costumbre.
  • Usar COUNT(columna) creyendo que cuenta filas. Cuenta valores no nulos. Si la columna admite nulos, te faltan filas.
  • Olvidar que AVG ignora los nulos. 27 420 € frente a 13 710 €. Pregúntate siempre si la media es sobre las filas con dato o sobre todas.
  • Esperar 0 de un SUM sin filas. Devuelve NULL. COALESCE(SUM(...), 0) en 06-04.
  • Poner un agregado en el WHERE. ERROR: aggregate functions are not allowed in WHERE. Es HAVING (04-06).
  • Mezclar una columna suelta con un agregado. column ... must appear in the GROUP BY clause. Es GROUP BY (04-05).
  • Calcular el importe con p.precio en lugar de lp.precio_unitario. Reescribe la historia comercial: 2,50 € de más en TiendaVerde, miles en un catálogo real.
  • Redondear antes de agregar. SUM(ROUND(x, 2)) acumula el error de cada redondeo. Calcula con precisión completa y redondea el resultado final.
  • Suponer que AVG de un entero devuelve decimales en todos los motores. SQL Server trunca. Convierte explícitamente si el código debe viajar.
  • Consejo: escribe primero la consulta sin agregar y cuenta las filas. Si el recuento no es el que esperas, el agregado que le pongas encima estará mal aunque la sintaxis sea perfecta.
  • Consejo: valida cada cifra nueva contra una que ya conozcas. 727,95 € de facturación debe cuadrar con 603,52 € de 2025 más 124,43 € de 2026. Si no cuadra, hay filas de más o de menos.
  • Consejo: usa FILTER para reunir varias métricas en una sola pasada. Es más rápido que lanzar cinco consultas y más legible que cinco CASE anidados.

Ejercicios

Ejercicio 1

Dirección pide un cuadro de mando de una sola fila con estas seis métricas sobre pedidos:

  1. Número total de pedidos.
  2. Número de clientes distintos que han comprado.
  3. Número de pedidos entregados.
  4. Ingresos totales por gastos de envío.
  5. Fecha del primer pedido y del último.
  6. Número de pedidos del canal web (sin comercial).

Escribe una única consulta. Después responde: ¿por qué el punto 2 no puede darte los 15 clientes de la tabla clientes?

Ejercicio 2

Sobre resenas, calcula el número de reseñas, la puntuación media redondeada a dos decimales, la peor y la mejor puntuación, y cuántos productos distintos han sido reseñados.

Después responde a estas dos preguntas y justifícalas con números:

  1. ¿Cuántos productos del catálogo no tienen ninguna reseña? ¿Puedes obtenerlo con esta misma consulta?
  2. Si calcularas AVG(puntuacion) sobre un LEFT JOIN de productos con resenas, ¿saldría la misma media? ¿Por qué?

Ejercicio 3

El director financiero quiere el total facturado por TiendaVerde en 2025, gastos de envío incluidos. Un becario le entrega esto:

-- ⚠️ INCORRECTA
SELECT ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento))
             + SUM(pe.gastos_envio), 2) AS total_2025
FROM lineas_pedido AS lp
JOIN pedidos AS pe ON lp.pedido_id = pe.id
WHERE pe.fecha_pedido >= DATE '2025-01-01'
  AND pe.fecha_pedido <  DATE '2026-01-01';
  1. Ejecuta la consulta y di qué número da.
  2. Explica exactamente qué parte está mal y por qué.
  3. Calcula el número correcto con las dos consultas separadas que corresponden.
  4. Indica de cuánto es el error, en euros y en porcentaje.

Soluciones

Solución 1

SELECT COUNT(*)                                    AS pedidos,
       COUNT(DISTINCT cliente_id)                  AS clientes_compradores,
       COUNT(*) FILTER (WHERE estado = 'entregado') AS entregados,
       SUM(gastos_envio)                           AS ingresos_portes,
       MIN(fecha_pedido)                           AS primer_pedido,
       MAX(fecha_pedido)                           AS ultimo_pedido,
       COUNT(*) FILTER (WHERE empleado_id IS NULL) AS canal_web
FROM pedidos;
pedidos clientes_compradores entregados ingresos_portes primer_pedido ultimo_pedido canal_web
20 12 14 118.25 2025-03-04 2026-02-21 10

Por qué el punto 2 da 12 y no 15: porque la consulta parte de pedidos, y en pedidos no existe ninguna fila cuyo cliente_id sea 13, 14 o 15. Núria, Hugo e Inés no han comprado nunca, así que no hay nada que contar. COUNT(DISTINCT cliente_id) responde a "¿cuántos clientes han comprado?", no a "¿cuántos clientes tenemos?". Para lo segundo hay que preguntarle a clientes:

SELECT COUNT(*) AS clientes_registrados FROM clientes;
clientes_registrados
15

Es el mismo aprendizaje de 03-02: la tabla desde la que partes determina qué preguntas puedes responder.

Solución 2

SELECT COUNT(*)                     AS resenas,
       ROUND(AVG(puntuacion), 2)    AS puntuacion_media,
       MIN(puntuacion)              AS peor,
       MAX(puntuacion)              AS mejor,
       COUNT(DISTINCT producto_id)  AS productos_resenados
FROM resenas;
resenas puntuacion_media peor mejor productos_resenados
12 4.08 2 5 9

12 reseñas sobre 9 productos distintos: tres productos (el aceite, el arroz y la crema facial) tienen dos reseñas cada uno.

1. Los productos sin reseña son 11: los 20 del catálogo menos los 9 reseñados. Pero no puedes obtenerlo con esta consulta, porque parte de resenas y los productos sin reseña no aparecen ahí — es literalmente el problema del INNER JOIN de 03-02. Hay que preguntárselo a productos con un anti-join, como en la solución 1 de 03-03:

SELECT COUNT(*) AS productos_sin_resena
FROM productos AS p
LEFT JOIN resenas AS r ON r.producto_id = p.id
WHERE r.id IS NULL;
productos_sin_resena
11

9 + 11 = 20. Cierra.

2. Sí, saldría exactamente la misma media: 4.08. Y este es un resultado que sorprende. Con productos LEFT JOIN resenas obtendrías 23 filas (las 12 reseñas más los 11 productos sin ninguna), pero en esas 11 filas extra r.puntuacion es NULL, y AVG ignora los nulos. Sigue sumando 49 y dividiendo entre 12.

SELECT COUNT(*)                  AS filas,
       COUNT(r.puntuacion)       AS puntuaciones,
       ROUND(AVG(r.puntuacion), 2) AS media
FROM productos AS p
LEFT JOIN resenas AS r ON r.producto_id = p.id;
filas puntuaciones media
23 12 4.08

23 filas, 12 valores, la misma media. Es la sección 5 en estado puro: el LEFT JOIN cambió el número de filas pero no el conjunto de valores agregados. Si lo que quisieras fuera "la puntuación media del catálogo tratando los productos sin reseña como un 0", tendrías que decirlo explícitamente con COALESCE (06-04) — y sería una métrica bastante discutible.

Solución 3

1. Qué número da:

-- ⚠️ INCORRECTA
SELECT ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento))
             + SUM(pe.gastos_envio), 2) AS total_2025
FROM lineas_pedido AS lp
JOIN pedidos AS pe ON lp.pedido_id = pe.id
WHERE pe.fecha_pedido >= DATE '2025-01-01'
  AND pe.fecha_pedido <  DATE '2026-01-01';
total_2025
822.57

2. Qué está mal. El primer SUM es correcto: cantidad, precio_unitario y descuento viven en lineas_pedido, que es justo la granularidad del FROM. Suma las 40 líneas de 2025 y da 603,52 €.

El segundo SUM es incorrecto: gastos_envio vive en pedidos, y tras el JOIN cada pedido aparece tantas veces como líneas tenga. Los 16 pedidos de 2025 se han convertido en 40 filas, así que sus portes se han contado 40 veces en lugar de 16. En vez de 85,95 € da 219,05 €.

3. El cálculo correcto, con dos consultas a sus respectivas granularidades:

-- Producto: nivel LÍNEA
SELECT COUNT(*) AS lineas,
       ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS facturacion_producto_2025
FROM lineas_pedido AS lp
JOIN pedidos AS pe ON lp.pedido_id = pe.id
WHERE pe.fecha_pedido >= DATE '2025-01-01'
  AND pe.fecha_pedido <  DATE '2026-01-01';
lineas facturacion_producto_2025
40 603.52
-- Portes: nivel PEDIDO
SELECT COUNT(*)          AS pedidos,
       SUM(gastos_envio) AS portes_2025
FROM pedidos
WHERE fecha_pedido >= DATE '2025-01-01'
  AND fecha_pedido <  DATE '2026-01-01';
pedidos portes_2025
16 85.95

Total correcto de 2025: 603,52 + 85,95 = 689,47 €.

4. El error.

Concepto Cifra del becario Cifra correcta Diferencia
Facturación de producto 603.52 603.52 0.00
Gastos de envío 219.05 85.95 +133.10
Total 2025 822.57 689.47 +133.10

133,10 € de más, un 19,3 % sobre el total correcto. Y observa lo peligroso del caso: la cifra inflada no es absurda —no son diez millones, es un número plausible que nadie cuestionaría en una reunión—. Ese es el motivo por el que el módulo 3 insistió tres veces: este error no se detecta leyendo el resultado, solo se detecta entendiendo la granularidad.

Conclusión

Ya sabes convertir filas en cifras:

  • Una función de agregación recibe muchas filas y devuelve un valor. Sin GROUP BY colapsa toda la tabla en una sola fila, incluso si el WHERE no deja pasar ninguna.
  • Las cinco funciones: COUNT, SUM, AVG, MIN y MAX. MIN y MAX funcionan sobre cualquier tipo ordenable, incluidos fechas y texto.
  • Las tres formas de COUNT dan tres números distintos sobre la misma columna: COUNT(*) = 20 pedidos, COUNT(empleado_id) = 10 con comercial, COUNT(DISTINCT empleado_id) = 3 comerciales. Es la mejor prueba de que los agregados ignoran los NULL y de que COUNT(*) es la única excepción.
  • Las cifras maestras de TiendaVerde: 47 líneas, 113 unidades, 727,95 € de facturación de producto, 118,25 € de portes, 846,20 € de total. Por años: 603,52 € en 2025 y 124,43 € en 2026.
  • AVG ignora los nulos, y eso puede ser lo que quieres o exactamente lo contrario: 27 420 € dividiendo entre 10 valores, 13 710 € dividiendo entre 20 filas. Las dos cifras son correctas para preguntas distintas.
  • Sobre el conjunto vacío, COUNT devuelve 0 pero SUM, AVG, MIN y MAX devuelven NULL. La categoría 6 de TiendaVerde lo demuestra.
  • AVG devuelve NUMERIC en PostgreSQL y por eso muestra dieciséis decimales; SQL Server, en cambio, trunca la media de una columna entera.
  • No puedes mezclar una columna suelta con un agregado: column ... must appear in the GROUP BY clause. Ese error es la puerta de entrada a la lección siguiente.
  • FILTER (WHERE ...) reúne varias métricas en una sola pasada; su equivalente portable es un CASE dentro del agregado (06-05). STRING_AGG y ARRAY_AGG agregan texto y arrays.
  • El aviso del módulo 3 queda resuelto: SUM(pe.gastos_envio) tras unir con lineas_pedido da 278,70 € en vez de 118,25 €, porque cada porte se repite tantas veces como líneas tenga el pedido. Las dos soluciones son agregar cada tabla a su nivel o colapsar el detalle antes de unir (módulo 7).

En la lección siguiente, agregando datos con GROUP BY, darás el salto que convierte todo esto en análisis de verdad. En vez de una cifra para toda la empresa, obtendrás una cifra por cada grupo: ventas por categoría, pedidos por estado, clientes por país, unidades por producto. Ampliaremos por fin el diagrama del orden lógico de ejecución con GROUP BY y HAVING entre WHERE y SELECT —lo prometimos en 02-01—, verás por qué los NULL forman su propio grupo, y descubrirás por qué un INNER JOIN esconde la categoría 6 mientras que un LEFT JOIN con COUNT(columna) la muestra con un honesto 0.

Curso de SQL

Módulo 1: Introducción a SQL

Módulo 2: Consultas básicas de SQL

Módulo 3: Trabajando con múltiples tablas

Módulo 4: Filtrado avanzado de datos

Módulo 5: Manipulación de datos

Módulo 6: Funciones avanzadas de SQL

Módulo 7: Subconsultas y consultas anidadas

Módulo 8: Índices y optimización de rendimiento

Módulo 9: Transacciones y concurrencia

Módulo 10: Temas avanzados

Módulo 11: SQL en la práctica

Módulo 12: Proyecto final

© Copyright 2026. Todos los derechos reservados