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
- Qué es una función de agregación
- Las cinco funciones y sus tipos de retorno
- Las tres formas de
COUNT SUMyAVGsobre el detalle de ventasAVGignora losNULL: dos respuestas correctas para preguntas distintasMINyMAXsobre números, fechas y texto- El conjunto vacío:
SUMdaNULL,COUNTda0 - Un agregado colapsa toda la tabla, y no se puede mezclar con una columna suelta
FILTER (WHERE ...): agregar solo una parteSTRING_AGGyARRAY_AGG: agregados no numéricos- La resolución del aviso del módulo 3
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- 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:
| 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 BYcolapsa 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. ElWHEREdecide 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 existeHAVING, y es la lección 04-06.
- 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 | Sí | 0 |
SUM(expr) |
Suma | Numéricos, INTERVAL |
Sí | NULL |
AVG(expr) |
Media aritmética | Numéricos, INTERVAL |
Sí | NULL |
MIN(expr) |
Valor mínimo | Cualquier tipo ordenable | Sí | NULL |
MAX(expr) |
Valor máximo | Cualquier tipo ordenable | Sí | 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:
SUMde enteros devuelveBIGINT, noINTEGER. Es una protección contra el desbordamiento: sumar un millón de enteros grandes desbordaINTEGERcon facilidad.AVGde un entero devuelveNUMERIC, no un entero. PostgreSQL no trunca. Lo verás en la sección 6, y es una diferencia importante con otros motores.
- Las tres formas de
COUNT
COUNTCOUNT 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
NULLdel 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:
| 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 queCOUNT(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 escribirCOUNT(DISTINCT (a, b))usando la sintaxis de fila compuesta. YCOUNT(*)frente aCOUNT(1): en PostgreSQL son idénticos en rendimiento y ambos se optimizan igual, así que la elección es puramente de estilo. El curso usaCOUNT(*).
SUM y AVG sobre el detalle de ventas
SUM y AVG sobre el detalle de ventasAhora 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:
SUMde ventas siempre sobrelp.precio_unitario.productos.preciosirve 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.
AVG ignora los NULL: dos respuestas correctas para preguntas distintas
AVG ignora los NULL: dos respuestas correctas para preguntas distintasQue 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.
MIN y MAX sobre números, fechas y texto
MIN y MAX sobre números, fechas y textoMIN 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.
| 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 AVGsobre una columna enteraResultado con 49/12 PostgreSQL NUMERICcon precisión completa4.0833333333333333MySQL DECIMAL4.0833SQLite Siempre REAL(coma flotante)4.083333333333333SQL Server INT: trunca4Oracle NUMBER4.08333333333333…SQL Server es el caso peligroso:
AVGde una columnaINThace división entera y devuelve4. 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.
- El conjunto vacío:
SUM da NULL, COUNT da 0
SUM da NULL, COUNT da 0Esta 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í:
- Una consulta con agregados y sin
GROUP BYsiempre devuelve una fila, aunque elWHEREno deje pasar ninguna. No devuelve "0 filas": devuelve una fila con el resultado de agregar la nada. COUNTde la nada es0;SUM,AVG,MINyMAXde la nada sonNULL. 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.
- 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.
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
SELECTdebe estar dentro de una función de agregación o formar parte delGROUP 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_id → lecció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_BYdesactivado acepta esa consulta sin protestar y devuelve un valor arbitrario denombre. 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.
FILTER (WHERE ...): agregar solo una parte
FILTER (WHERE ...): agregar solo una parteA 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:
FILTERes 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 unCASEdentro 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_entregadosFunciona porque
COUNTignora losNULLque devuelve elCASEsinELSE. Es exactamente el mismo mecanismo de la sección 3, aprovechado a propósito.CASEes la lección 06-05.
STRING_AGG y ARRAY_AGG: agregados no numéricos
STRING_AGG y ARRAY_AGG: agregados no numéricosNo 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. ElORDER BYinterno 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_AGGexiste en PostgreSQL y en SQL Server (2017+). MySQL y SQLite tienenGROUP_CONCATcon sintaxis distinta, y Oracle usaLISTAGG.ARRAY_AGGes específico de PostgreSQL, porque depende de que el motor tenga tipos array nativos.
- 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)yCOUNT(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
AVGignora los nulos. 27 420 € frente a 13 710 €. Pregúntate siempre si la media es sobre las filas con dato o sobre todas. - Esperar
0de unSUMsin filas. DevuelveNULL.COALESCE(SUM(...), 0)en 06-04. - Poner un agregado en el
WHERE.ERROR: aggregate functions are not allowed in WHERE. EsHAVING(04-06). - Mezclar una columna suelta con un agregado.
column ... must appear in the GROUP BY clause. EsGROUP BY(04-05). - Calcular el importe con
p.precioen lugar delp.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
AVGde 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
FILTERpara reunir varias métricas en una sola pasada. Es más rápido que lanzar cinco consultas y más legible que cincoCASEanidados.
Ejercicios
Ejercicio 1
Dirección pide un cuadro de mando de una sola fila con estas seis métricas sobre pedidos:
- Número total de pedidos.
- Número de clientes distintos que han comprado.
- Número de pedidos entregados.
- Ingresos totales por gastos de envío.
- Fecha del primer pedido y del último.
- 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:
- ¿Cuántos productos del catálogo no tienen ninguna reseña? ¿Puedes obtenerlo con esta misma consulta?
- Si calcularas
AVG(puntuacion)sobre unLEFT JOINdeproductosconresenas, ¿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';- Ejecuta la consulta y di qué número da.
- Explica exactamente qué parte está mal y por qué.
- Calcula el número correcto con las dos consultas separadas que corresponden.
- 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:
| 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 BYcolapsa toda la tabla en una sola fila, incluso si elWHEREno deja pasar ninguna. - Las cinco funciones:
COUNT,SUM,AVG,MINyMAX.MINyMAXfuncionan sobre cualquier tipo ordenable, incluidos fechas y texto. - Las tres formas de
COUNTdan 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 losNULLy de queCOUNT(*)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.
AVGignora 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,
COUNTdevuelve0peroSUM,AVG,MINyMAXdevuelvenNULL. La categoría 6 de TiendaVerde lo demuestra. AVGdevuelveNUMERICen 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 unCASEdentro del agregado (06-05).STRING_AGGyARRAY_AGGagregan texto y arrays.- El aviso del módulo 3 queda resuelto:
SUM(pe.gastos_envio)tras unir conlineas_pedidoda 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
- ¿Qué es SQL?
- Configurando tu entorno SQL
- Sintaxis básica de SQL
- Entendiendo bases de datos y tablas
- El modelo relacional: claves primarias y foráneas
- La base de datos del curso: TiendaVerde
Módulo 2: Consultas básicas de SQL
- Instrucción SELECT
- Alias, expresiones y columnas calculadas
- Filtrando datos con WHERE
- DISTINCT y eliminación de duplicados
- Ordenando datos con ORDER BY
- Limitando resultados con LIMIT
Módulo 3: Trabajando con múltiples tablas
- Operaciones JOIN
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN y CROSS JOIN
- Uniones de conjuntos: UNION, INTERSECT y EXCEPT
Módulo 4: Filtrado avanzado de datos
- Usando LIKE para coincidencia de patrones
- Operadores IN y BETWEEN
- Valores NULL y IS NULL
- Funciones de agregación: COUNT, SUM, AVG, MIN y MAX
- Agregando datos con GROUP BY
- Cláusula HAVING
Módulo 5: Manipulación de datos
- Creando tablas y restricciones con CREATE TABLE
- Instrucción INSERT
- Instrucción UPDATE
- Instrucción DELETE
- Instrucción UPSERT (MERGE)
- Modificando el esquema: ALTER TABLE y migraciones seguras
Módulo 6: Funciones avanzadas de SQL
- Funciones de cadena
- Funciones numéricas
- Funciones de fecha y hora
- Conversión de tipos y manejo de NULL: CAST y COALESCE
- Expresiones condicionales
Módulo 7: Subconsultas y consultas anidadas
- Introducción a subconsultas
- Subconsultas correlacionadas
- EXISTS y NOT EXISTS
- Usando subconsultas en cláusulas SELECT, FROM y WHERE
- Subconsultas o JOIN: cuál elegir
Módulo 8: Índices y optimización de rendimiento
- Entendiendo los índices
- Creación y gestión de índices
- Tipos de índice y cuándo no indexar
- Técnicas de optimización de consultas
- Análisis del rendimiento de consultas
Módulo 9: Transacciones y concurrencia
- Introducción a las transacciones
- Propiedades ACID
- Instrucciones de control de transacciones
- Niveles de aislamiento y anomalías de concurrencia
- Manejo de concurrencia: bloqueos e interbloqueos
Módulo 10: Temas avanzados
- Vistas
- Expresiones de tabla comunes (CTE)
- Funciones de ventana
- Procedimientos almacenados
- Triggers
- JSON y datos semiestructurados
Módulo 11: SQL en la práctica
- Casos de uso en el mundo real
- Mejores prácticas
- Seguridad: inyección SQL, permisos y roles
- SQL para análisis de datos
- SQL en desarrollo web
