Hasta ahora, todas nuestras consultas han devuelto detalle: una fila del resultado por cada fila de la base de datos. Eso responde a preguntas del tipo "¿qué?" —qué préstamos hay abiertos, qué ejemplares no ha tocado nadie—, pero la dirección de BiblioRed hace otras preguntas: ¿cuántos préstamos hicimos el trimestre pasado?, ¿qué sucursal presta más?, ¿cuál es el título estrella?, ¿cuánto hemos recaudado en recargos?, ¿qué socios superan los dos préstamos?.
Esas preguntas exigen pasar del detalle al resumen: condensar muchas filas en una sola cifra. Es lo que hacen las funciones de agregado y la cláusula GROUP BY, y es el paso que convierte una base de datos en una herramienta de gestión.
Esta lección también aclara algo que llevas arrastrando desde la primera consulta: en qué orden se ejecuta realmente una instrucción SQL. No es el orden en que se escribe, y entenderlo resuelve de golpe media docena de errores aparentemente inexplicables.
Damos por sabidos los JOIN y las subconsultas de la lección 02-04: los usaremos sin volver a explicarlos.
Contenido
- Las funciones de agregado
COUNTy sus tres formasSUM,AVG,MINyMAX- Cómo tratan los agregados los valores
NULL GROUP BY: agregar por grupos- La regla de qué puede aparecer en el
SELECT GROUP BYpor varias columnasHAVINGfrente aWHERE- El orden lógico de ejecución de una consulta
- Agregación combinada con
JOIN - El problema de las filas infladas por un
JOIN COALESCEyCASE WHENdentro de agregados- Introducción a las funciones de ventana
- Errores comunes y consejos
- Ejercicios
- Conclusión
- Las funciones de agregado
Una función de agregado recibe muchos valores y devuelve uno solo. Las cinco del estándar SQL, presentes en todos los gestores:
| Función | Qué devuelve | Tipos aceptados |
|---|---|---|
COUNT |
Número de filas o de valores | Cualquiera |
SUM |
Suma | Numéricos |
AVG |
Media aritmética | Numéricos |
MIN |
Valor mínimo | Numéricos, texto, fechas |
MAX |
Valor máximo | Numéricos, texto, fechas |
Sin GROUP BY, una función de agregado trata toda la tabla como un único grupo y devuelve una sola fila:
Puedes combinar varias en la misma consulta, siempre que todas resuman el mismo conjunto de filas:
SELECT COUNT(*) AS total_prestamos,
MIN(fecha_prestamo) AS primer_prestamo,
MAX(fecha_prestamo) AS ultimo_prestamo,
SUM(recargo) AS recargos_cobrados
FROM prestamos;| total_prestamos | primer_prestamo | ultimo_prestamo | recargos_cobrados |
|---|---|---|---|
| 12 | 2026-03-02 | 2026-07-25 | 7.00 |
Doce préstamos entre el 2 de marzo y el 25 de julio de 2026, con 7,00 € recaudados en recargos.
Fíjate en un detalle esencial: el resultado tiene una fila, no doce. La consulta ya no habla de préstamos individuales, habla del conjunto. Por eso no puedes mezclar libremente agregados con columnas de detalle; lo veremos en el apartado 6.
COUNT y sus tres formas
COUNT y sus tres formasCOUNT parece la función más simple y es la que más confusión genera, porque tiene tres variantes que cuentan cosas distintas.
SELECT COUNT(*) AS filas,
COUNT(email) AS con_email,
COUNT(DISTINCT sucursal_id) AS sucursales_distintas
FROM socios;| filas | con_email | sucursales_distintas |
|---|---|---|
| 10 | 9 | 4 |
| Forma | Cuenta | Resultado aquí |
|---|---|---|
COUNT(*) |
Filas, sin mirar el contenido | 10 socios |
COUNT(columna) |
Valores no nulos de esa columna | 9 (Pau Miralles no tiene correo) |
COUNT(DISTINCT columna) |
Valores no nulos distintos | 4 sucursales diferentes |
La diferencia entre las dos primeras es la que más veces se pasa por alto, y en prestamos es especialmente elocuente:
SELECT COUNT(*) AS prestamos,
COUNT(fecha_devolucion) AS cerrados,
COUNT(*) - COUNT(fecha_devolucion) AS abiertos,
COUNT(DISTINCT socio_id) AS socios_distintos
FROM prestamos;| prestamos | cerrados | abiertos | socios_distintos |
|---|---|---|---|
| 12 | 8 | 4 | 8 |
COUNT(fecha_devolucion) cuenta 8 porque los cuatro préstamos abiertos tienen esa columna a NULL. Es un truco muy útil: contar los no nulos de una columna equivale a contar las filas que cumplen cierta condición, si esa condición se refleja en la nulidad.
Y COUNT(DISTINCT socio_id) devuelve 8, no 12: hay ocho socios distintos con préstamos, porque algunos tienen más de uno.
Sobre el rendimiento: existe la leyenda de que
COUNT(1)es más rápido queCOUNT(*). Es falso en PostgreSQL y en cualquier gestor moderno: son idénticos.COUNT(DISTINCT columna)sí es notablemente más caro, porque obliga a deduplicar.
SUM, AVG, MIN y MAX
SUM, AVG, MIN y MAXSELECT COUNT(*) AS libros,
MIN(anio_publicacion) AS mas_antiguo,
MAX(anio_publicacion) AS mas_reciente,
ROUND(AVG(anio_publicacion), 2) AS anio_medio
FROM libros;| libros | mas_antiguo | mas_reciente | anio_medio |
|---|---|---|---|
| 9 | 1904 | 2023 | 2000.89 |
ROUND(expresión, decimales) no es una función de agregado: redondea el resultado. Sin ella, AVG sobre NUMERIC en PostgreSQL devuelve un número con muchísimos decimales (2000.8888888888888889), poco práctico para un informe.
MIN y MAX funcionan también sobre texto (orden alfabético) y sobre fechas (orden cronológico):
SELECT MIN(apellidos) AS primero_alfabeticamente,
MAX(apellidos) AS ultimo_alfabeticamente,
MIN(fecha_alta) AS socio_mas_antiguo,
MAX(fecha_alta) AS socio_mas_reciente
FROM socios;| primero_alfabeticamente | ultimo_alfabeticamente | socio_mas_antiguo | socio_mas_reciente |
|---|---|---|---|
| Alsina | Vendrell | 2018-01-22 | 2024-10-01 |
Atención a una trampa clásica: esta consulta te dice cuál es el apellido mínimo y cuál es la fecha mínima, pero no te dice que sean de la misma persona. Cada agregado se calcula por separado. Para obtener la fila completa del socio más antiguo hace falta otra cosa:
SELECT socio_id, nombre, apellidos, fecha_alta
FROM socios
WHERE fecha_alta = (SELECT MIN(fecha_alta) FROM socios);| socio_id | nombre | apellidos | fecha_alta |
|---|---|---|---|
| 11 | Álvaro | Ferrán | 2018-01-22 |
Es la subconsulta escalar de la lección anterior, ahora con un agregado dentro. En el apartado 13 veremos una alternativa con funciones de ventana.
- Cómo tratan los agregados los valores
NULL
NULLAquí hay una regla y una excepción, y conviene grabarlas:
Todas las funciones de agregado ignoran los
NULL. La única excepción esCOUNT(*), que cuenta filas y no mira el contenido.
Veámoslo con la columna recargo, que tiene ocho valores y cuatro NULL:
SELECT COUNT(*) AS filas,
COUNT(recargo) AS con_recargo,
SUM(recargo) AS suma,
ROUND(AVG(recargo), 4) AS media_ignorando_nulos,
ROUND(SUM(recargo) / COUNT(*), 4) AS media_contando_nulos_como_cero,
MIN(recargo) AS minimo,
MAX(recargo) AS maximo
FROM prestamos;| filas | con_recargo | suma | media_ignorando_nulos | media_contando_nulos_como_cero | minimo | maximo |
|---|---|---|---|---|---|---|
| 12 | 8 | 7.00 | 0.8750 | 0.5833 | 0.00 | 4.20 |
Dos medias distintas para los mismos datos, y ninguna de las dos está mal: significan cosas diferentes.
AVG(recargo)= 7,00 / 8 = 0,875 €. Es "el recargo medio de los préstamos ya cerrados", porque los abiertos aún no tienen recargo calculado.SUM(recargo) / COUNT(*)= 7,00 / 12 = 0,583 €. Es "el recargo medio por préstamo realizado", tratando los pendientes como cero.
La pregunta que debes hacerte siempre es: ¿qué significa el NULL en esta columna? Si significa "todavía no se sabe", ignorarlo es correcto. Si significa "cero", ignorarlo distorsiona la media. En el apartado 12 veremos COALESCE, la herramienta para convertir explícitamente NULL en cero cuando esa es la semántica correcta.
Un caso extremo que sorprende:
SELECT SUM(recargo), COUNT(recargo), AVG(recargo)
FROM prestamos
WHERE fecha_devolucion IS NULL; -- los cuatro préstamos abiertos| sum | count | avg |
|---|---|---|
| (NULL) | 0 | (NULL) |
SUM de un conjunto donde todos los valores son NULL devuelve NULL, no cero. Y lo mismo si el conjunto está vacío. COUNT, en cambio, devuelve 0: es la única función de agregado que nunca devuelve NULL.
GROUP BY: agregar por grupos
GROUP BY: agregar por gruposHasta ahora resumíamos la tabla entera. GROUP BY la parte en grupos y calcula los agregados dentro de cada grupo, devolviendo una fila por grupo.
SELECT sucursal_id, COUNT(*) AS ejemplares
FROM ejemplares
GROUP BY sucursal_id
ORDER BY sucursal_id;| sucursal_id | ejemplares |
|---|---|
| 1 | 6 |
| 2 | 4 |
| 3 | 3 |
| 4 | 2 |
Cuatro filas, una por sucursal, y la suma de los recuentos es 15: el total de ejemplares. Conceptualmente ocurre esto:
flowchart LR
T["ejemplares<br/>15 filas"] --> G1["sucursal_id = 1<br/>6 filas"]
T --> G2["sucursal_id = 2<br/>4 filas"]
T --> G3["sucursal_id = 3<br/>3 filas"]
T --> G4["sucursal_id = 4<br/>2 filas"]
G1 --> R["Resultado<br/>4 filas,<br/>una por grupo"]
G2 --> R
G3 --> R
G4 --> R
Otro ejemplo, agrupando por una columna de texto:
| estado | cuantos |
|---|---|
| disponible | 9 |
| prestado | 4 |
| baja | 1 |
| reparacion | 1 |
Observa que se puede ordenar por el agregado usando su alias. Y observa también que los grupos salen de los datos que hay: si ningún ejemplar estuviera en reparación, esa fila simplemente no aparecería. GROUP BY nunca inventa grupos vacíos.
Un tercer ejemplo, con más de un agregado por grupo:
SELECT editorial,
COUNT(*) AS titulos,
MIN(anio_publicacion) AS mas_antiguo,
MAX(anio_publicacion) AS mas_reciente
FROM libros
GROUP BY editorial
ORDER BY titulos DESC, editorial;| editorial | titulos | mas_antiguo | mas_reciente |
|---|---|---|---|
| Ediciones Marlia | 3 | 2012 | 2017 |
| Editorial Andana | 3 | 1989 | 2021 |
| Prensa Técnica Norte | 2 | 2019 | 2023 |
| Ayuntamiento de Vallmar | 1 | 1904 | 1904 |
- La regla de qué puede aparecer en el
SELECT
SELECTEsta es la regla que más errores produce al empezar:
Toda columna que aparezca en el
SELECTy no esté dentro de una función de agregado debe figurar en elGROUP BY.
El motivo es puro sentido común. Si agrupas los ejemplares por sucursal, cada fila del resultado representa seis ejemplares distintos (los de la sucursal 1). ¿Qué codigo debería mostrar? ¿El de cuál de los seis? La pregunta no tiene respuesta, así que el gestor la rechaza:
ERROR: la columna «ejemplares.codigo» debe aparecer en la cláusula GROUP BY
o ser usada en una función de agregaciónLas tres formas legítimas de arreglarlo, según lo que quieras de verdad:
-- a) Añadir la columna al GROUP BY: cambian los grupos (y aquí ya no agrupa nada,
-- porque codigo es único)
SELECT sucursal_id, codigo, COUNT(*) FROM ejemplares GROUP BY sucursal_id, codigo;
-- b) Envolverla en un agregado: "el código más pequeño de cada sucursal"
SELECT sucursal_id, MIN(codigo) AS primer_codigo, COUNT(*) AS ejemplares
FROM ejemplares GROUP BY sucursal_id ORDER BY sucursal_id;
-- c) Quitarla del SELECT
SELECT sucursal_id, COUNT(*) FROM ejemplares GROUP BY sucursal_id;Resultado de la opción b):
| sucursal_id | primer_codigo | ejemplares |
|---|---|---|
| 1 | EJ-3082 | 6 |
| 2 | EJ-3081 | 4 |
| 3 | EJ-3083 | 3 |
| 4 | EJ-3088 | 2 |
La excepción de la dependencia funcional
PostgreSQL, desde la versión 9.1, admite una relajación muy práctica: si agrupas por la clave primaria de una tabla, puedes seleccionar cualquier otra columna de esa misma tabla sin listarla, porque la clave primaria la determina de forma unívoca.
-- Legal en PostgreSQL: socios.apellidos depende funcionalmente de socios.socio_id
SELECT s.socio_id, s.nombre, s.apellidos, COUNT(p.prestamo_id) AS prestamos
FROM socios s
LEFT JOIN prestamos p ON p.socio_id = s.socio_id
GROUP BY s.socio_id
ORDER BY s.socio_id;Es una comodidad real, pero no es portable: en otros gestores tendrías que escribir GROUP BY s.socio_id, s.nombre, s.apellidos. Si tu SQL debe funcionar en varios motores, lista todas las columnas.
Y el peligro de SQLite
SQLite no aplica esta regla en absoluto. La consulta que en PostgreSQL da error, en SQLite se ejecuta y devuelve un codigo cualquiera de los del grupo, elegido de forma no documentada. No hay error, no hay aviso, y el informe está mal.
sqlite> SELECT sucursal_id, codigo, COUNT(*) FROM ejemplares GROUP BY sucursal_id;
1|EJ-3095|6
2|EJ-3090|4
...Ese EJ-3095 no representa nada. Es una de las diferencias más peligrosas entre ambos gestores: la permisividad de SQLite convierte un error en un dato falso. Escribe el SQL como si PostgreSQL te vigilara, aunque estés en SQLite.
GROUP BY por varias columnas
GROUP BY por varias columnasAl agrupar por dos columnas, los grupos son las combinaciones distintas de ambas:
SELECT sucursal_id, estado, COUNT(*) AS cuantos
FROM ejemplares
GROUP BY sucursal_id, estado
ORDER BY sucursal_id, estado;| sucursal_id | estado | cuantos |
|---|---|---|
| 1 | disponible | 4 |
| 1 | prestado | 2 |
| 2 | disponible | 2 |
| 2 | prestado | 2 |
| 3 | baja | 1 |
| 3 | disponible | 2 |
| 4 | disponible | 1 |
| 4 | reparacion | 1 |
Ocho grupos, cuyos recuentos suman 15. Igual que antes: solo aparecen las combinaciones que existen. La sucursal 1 no tiene ningún ejemplar en reparación, así que esa fila no está —no sale con un 0—. Si necesitas la rejilla completa con ceros incluidos, hay dos caminos: un CROSS JOIN que genere todas las combinaciones (lección 02-04) y un LEFT JOIN contra los datos, o la tabulación con CASE WHEN del apartado 12.
El orden de las columnas en el GROUP BY no altera los grupos (GROUP BY a, b y GROUP BY b, a producen los mismos), pero sí conviene que coincida con el ORDER BY para que el informe se lea bien.
HAVING frente a WHERE
HAVING frente a WHEREWHERE filtra filas, antes de agrupar. HAVING filta grupos, después de agrupar. La diferencia es de momento, no de sintaxis.
Pregunta: ¿qué socios tienen dos o más préstamos?
SELECT s.socio_id,
s.nombre || ' ' || s.apellidos AS socio,
COUNT(*) AS prestamos
FROM prestamos p
INNER JOIN socios s ON s.socio_id = p.socio_id
GROUP BY s.socio_id, s.nombre, s.apellidos
HAVING COUNT(*) >= 2
ORDER BY prestamos DESC, socio;| socio_id | socio | prestamos |
|---|---|---|
| 14 | Marta Alsina | 3 |
| 15 | Iván Pereda | 2 |
| 16 | Nuria Bastos | 2 |
Intentar lo mismo con WHERE es imposible:
SELECT s.socio_id, COUNT(*) FROM prestamos p
INNER JOIN socios s ON s.socio_id = p.socio_id
WHERE COUNT(*) >= 2
GROUP BY s.socio_id;Y es lógico: cuando se evalúa el WHERE, los grupos todavía no existen, así que no hay nada que contar.
Usar los dos a la vez
Es lo habitual, y cada uno hace su trabajo:
-- Filas: solo los préstamos con recargo. Grupos: solo los socios que superan 1 €.
SELECT s.socio_id,
s.apellidos,
COUNT(*) AS prestamos_con_recargo,
SUM(p.recargo) AS total_recargo
FROM prestamos p
INNER JOIN socios s ON s.socio_id = p.socio_id
WHERE p.recargo > 0
GROUP BY s.socio_id, s.apellidos
HAVING SUM(p.recargo) > 1.00
ORDER BY total_recargo DESC;| socio_id | apellidos | prestamos_con_recargo | total_recargo |
|---|---|---|---|
| 12 | Quiroga | 1 | 4.20 |
| 15 | Pereda | 1 | 1.40 |
| 18 | Vendrell | 1 | 1.40 |
| Cláusula | Qué filtra | Cuándo actúa | ¿Admite agregados? |
|---|---|---|---|
WHERE |
Filas individuales | Antes de GROUP BY |
No |
HAVING |
Grupos ya formados | Después de GROUP BY |
Sí |
Regla práctica de eficiencia: si una condición se puede expresar en WHERE, ponla en WHERE. Filtrar antes de agrupar significa agrupar menos filas. Meter en HAVING una condición que no usa agregados (HAVING s.sucursal_id = 2) funciona en muchos gestores, pero es más lento y más confuso.
- El orden lógico de ejecución de una consulta
Ahora se entiende todo. Una consulta SQL no se evalúa en el orden en que se escribe. El orden lógico es este:
flowchart TD
A["1. FROM<br/>toma las tablas de partida"] --> B["2. JOIN ... ON<br/>empareja filas"]
B --> C["3. WHERE<br/>descarta filas individuales"]
C --> D["4. GROUP BY<br/>parte el resultado en grupos"]
D --> E["5. HAVING<br/>descarta grupos enteros"]
E --> F["6. SELECT<br/>calcula columnas y agregados,<br/>aplica los alias"]
F --> G["7. DISTINCT<br/>elimina filas repetidas"]
G --> H["8. ORDER BY<br/>ordena el resultado"]
H --> I["9. LIMIT / OFFSET<br/>recorta"]
Este diagrama explica de un vistazo cuatro comportamientos que hasta ahora parecían caprichos:
WHEREno puede usar agregados. Se ejecuta en el paso 3; los grupos se forman en el 4.WHEREno puede usar los alias delSELECT. ElSELECTes el paso 6, posterior.WHERE prestamos >= 2da error de columna inexistente.ORDER BYsí puede usar los alias delSELECT. Es el paso 8, posterior al 6. Por esoORDER BY prestamos DESCfuncionaba.LIMITes lo último. Recorta el resultado final, no las filas leídas:LIMIT 3en una consulta conGROUP BYdevuelve tres grupos, no tres filas de la tabla.
-- ERROR: 'prestamos' es un alias definido en el paso 6, y WHERE es el paso 3
SELECT s.socio_id, COUNT(*) AS prestamos
FROM prestamos p JOIN socios s ON s.socio_id = p.socio_id
WHERE prestamos >= 2
GROUP BY s.socio_id;Insistimos en lo de lógico: es el orden en que hay que razonar la consulta. El optimizador (lección 01-04) es libre de ejecutar las cosas en otro orden físico mientras el resultado sea el mismo.
- Agregación combinada con
JOIN
JOINAquí es donde las dos últimas lecciones se juntan y BiblioRed empieza a producir informes de verdad.
Préstamos por sucursal
Ojo al matiz: la sucursal de un préstamo es la del ejemplar prestado, que no tiene por qué ser la de alta del socio.
SELECT su.nombre AS sucursal,
COUNT(*) AS prestamos
FROM prestamos p
INNER JOIN ejemplares e ON e.ejemplar_id = p.ejemplar_id
INNER JOIN sucursales su ON su.sucursal_id = e.sucursal_id
GROUP BY su.sucursal_id, su.nombre
ORDER BY prestamos DESC;| sucursal | prestamos |
|---|---|
| Norte | 6 |
| Centro | 4 |
| Sur | 1 |
| Este | 1 |
La sucursal Norte concentra la mitad de la actividad. Suman 12 ✓.
Los libros más prestados
SELECT l.titulo,
COUNT(*) AS veces_prestado
FROM prestamos p
INNER JOIN ejemplares e ON e.ejemplar_id = p.ejemplar_id
INNER JOIN libros l ON l.libro_id = e.libro_id
GROUP BY l.libro_id, l.titulo
ORDER BY veces_prestado DESC, l.titulo
LIMIT 5;| titulo | veces_prestado |
|---|---|
| El mapa del tiempo | 4 |
| Los pilares de la Tierra | 3 |
| Álgebra para impacientes | 1 |
| Cuadernos de Ravena | 1 |
| El invierno de los pájaros | 1 |
Importante: en este listado faltan dos libros. "Manual de jardinería urbana" y "Memoria del Ensanche (1904)" no se han prestado nunca, y el INNER JOIN los elimina antes de agrupar. Si el informe debe incluirlos con un 0, hay que combinar LEFT JOIN con COUNT(columna):
SELECT l.titulo,
COUNT(p.prestamo_id) AS veces_prestado
FROM libros l
LEFT JOIN ejemplares e ON e.libro_id = l.libro_id
LEFT JOIN prestamos p ON p.ejemplar_id = e.ejemplar_id
GROUP BY l.libro_id, l.titulo
ORDER BY veces_prestado DESC, l.titulo;| titulo | veces_prestado |
|---|---|
| El mapa del tiempo | 4 |
| Los pilares de la Tierra | 3 |
| Álgebra para impacientes | 1 |
| Cuadernos de Ravena | 1 |
| El invierno de los pájaros | 1 |
| La casa de las mareas | 1 |
| Rutas del delta | 1 |
| Manual de jardinería urbana | 0 |
| Memoria del Ensanche (1904) | 0 |
Aquí hay una lección que vale por sí sola: COUNT(*) habría devuelto 1 en lugar de 0 para los dos últimos, porque el LEFT JOIN genera una fila rellena de NULL y COUNT(*) cuenta filas. COUNT(p.prestamo_id) cuenta valores no nulos, y ahí no hay ninguno.
Con
LEFT JOIN, nuncaCOUNT(*): siempreCOUNT(columna_de_la_tabla_derecha).
Préstamos por socio, incluidos los que no tienen ninguno
SELECT s.socio_id,
s.nombre || ' ' || s.apellidos AS socio,
COUNT(p.prestamo_id) AS prestamos,
MAX(p.fecha_prestamo) AS ultimo_prestamo
FROM socios s
LEFT JOIN prestamos p ON p.socio_id = s.socio_id
GROUP BY s.socio_id, s.nombre, s.apellidos
ORDER BY prestamos DESC, socio;| socio_id | socio | prestamos | ultimo_prestamo |
|---|---|---|---|
| 14 | Marta Alsina | 3 | 2026-07-14 |
| 15 | Iván Pereda | 2 | 2026-07-18 |
| 16 | Nuria Bastos | 2 | 2026-05-04 |
| 11 | Álvaro Ferrán | 1 | 2026-05-08 |
| 17 | Diego Salom | 1 | 2026-07-21 |
| 18 | Lucía Vendrell | 1 | 2026-04-12 |
| 19 | Pau Miralles | 1 | 2026-07-25 |
| 12 | Sonia Quiroga | 1 | 2026-05-19 |
| 20 | Elena Roig | 0 | (NULL) |
| 13 | Ramón Etxebarri | 0 | (NULL) |
Este es el informe completo de actividad de socios, con los inactivos incluidos. Compáralo con el anti-join de la lección anterior: allí solo obteníamos quiénes no tenían préstamos; ahora tenemos la foto entera.
- El problema de las filas infladas por un
JOIN
JOINRetomamos el ejercicio que dejamos a medias en la lección 02-04. Un responsable de BiblioRed pide: "cuántos socios y cuántos ejemplares hay en cada sucursal". La consulta que sale sola es esta:
SELECT su.nombre AS sucursal,
COUNT(s.socio_id) AS socios,
COUNT(e.ejemplar_id) AS ejemplares
FROM sucursales su
LEFT JOIN socios s ON s.sucursal_id = su.sucursal_id
LEFT JOIN ejemplares e ON e.sucursal_id = su.sucursal_id
GROUP BY su.sucursal_id, su.nombre
ORDER BY su.sucursal_id;| sucursal | socios | ejemplares |
|---|---|---|
| Centro | 24 | 24 |
| Norte | 12 | 12 |
| Sur | 6 | 6 |
| Este | 2 | 2 |
Todos los números están mal, y el hecho de que las dos columnas sean idénticas es la señal de alarma. Centro tiene 4 socios y 6 ejemplares, no 24 y 24.
La causa: socios y ejemplares no están relacionadas entre sí; ambas cuelgan de sucursales de forma independiente. Al unirlas, dentro de cada sucursal se produce un producto cartesiano: 4 socios × 6 ejemplares = 24 filas, y cada COUNT cuenta esas 24. Es la explosión de filas (fan trap).
Las tres soluciones, de peor a mejor:
-- a) COUNT(DISTINCT ...): funciona, y es lo más rápido de escribir
SELECT su.nombre AS sucursal,
COUNT(DISTINCT s.socio_id) AS socios,
COUNT(DISTINCT e.ejemplar_id) AS ejemplares
FROM sucursales su
LEFT JOIN socios s ON s.sucursal_id = su.sucursal_id
LEFT JOIN ejemplares e ON e.sucursal_id = su.sucursal_id
GROUP BY su.sucursal_id, su.nombre
ORDER BY su.sucursal_id;| sucursal | socios | ejemplares |
|---|---|---|
| Centro | 4 | 6 |
| Norte | 3 | 4 |
| Sur | 2 | 3 |
| Este | 1 | 2 |
Correcto. Pero COUNT(DISTINCT) es caro y solo salva los recuentos: un SUM seguiría inflado, porque sumaría el mismo valor varias veces. Si en lugar de contar ejemplares sumaras importes, el resultado sería falso y DISTINCT no lo arreglaría.
-- b) Subconsultas escalares: cada cifra se calcula por separado
SELECT su.nombre AS sucursal,
(SELECT COUNT(*) FROM socios s WHERE s.sucursal_id = su.sucursal_id) AS socios,
(SELECT COUNT(*) FROM ejemplares e WHERE e.sucursal_id = su.sucursal_id) AS ejemplares
FROM sucursales su
ORDER BY su.sucursal_id;
-- c) Agregar cada rama por separado y unir después: la forma canónica
WITH socios_por_sucursal AS (
SELECT sucursal_id, COUNT(*) AS socios FROM socios GROUP BY sucursal_id
),
ejemplares_por_sucursal AS (
SELECT sucursal_id, COUNT(*) AS ejemplares FROM ejemplares GROUP BY sucursal_id
)
SELECT su.nombre AS sucursal,
COALESCE(sp.socios, 0) AS socios,
COALESCE(ep.ejemplares, 0) AS ejemplares
FROM sucursales su
LEFT JOIN socios_por_sucursal sp ON sp.sucursal_id = su.sucursal_id
LEFT JOIN ejemplares_por_sucursal ep ON ep.sucursal_id = su.sucursal_id
ORDER BY su.sucursal_id;Las tres devuelven la tabla correcta. La opción c) es la que debes interiorizar: cuando haya que resumir dos ramas independientes, agrega cada una por su lado y une los resúmenes. Es la única que escala a cualquier número de ramas y a cualquier agregado, no solo a COUNT.
Cómo detectar el problema en la práctica: si un total te parece sospechosamente grande, quita los agregados y ejecuta la consulta con SELECT *. Si salen muchas más filas de las que esperabas, tienes una explosión.
COALESCE y CASE WHEN dentro de agregados
COALESCE y CASE WHEN dentro de agregadosCOALESCE: sustituir NULL por un valor
COALESCE(a, b, c, ...) devuelve el primer argumento que no sea NULL. Es la herramienta para decidir explícitamente qué hacer con la ausencia de valor.
SELECT s.socio_id,
s.apellidos,
COUNT(p.prestamo_id) AS prestamos,
COALESCE(SUM(p.recargo), 0) AS recargo_total
FROM socios s
LEFT JOIN prestamos p ON p.socio_id = s.socio_id
GROUP BY s.socio_id, s.apellidos
ORDER BY recargo_total DESC, s.socio_id;| socio_id | apellidos | prestamos | recargo_total |
|---|---|---|---|
| 12 | Quiroga | 1 | 4.20 |
| 15 | Pereda | 2 | 1.40 |
| 18 | Vendrell | 1 | 1.40 |
| 11 | Ferrán | 1 | 0.00 |
| 13 | Etxebarri | 0 | 0.00 |
| 14 | Alsina | 3 | 0.00 |
| 16 | Bastos | 2 | 0.00 |
| 17 | Salom | 1 | 0.00 |
| 19 | Miralles | 1 | 0.00 |
| 20 | Roig | 0 | 0.00 |
Sin el COALESCE, cuatro socios mostrarían NULL en lugar de 0.00: los dos que no tienen ningún préstamo (Etxebarri y Roig) y los dos cuyo único préstamo sigue abierto y por tanto tiene el recargo sin calcular (Salom y Miralles). Un informe con NULL en una columna de dinero es un informe que nadie sabe leer.
Fíjate en el orden en que se aplica: COALESCE envuelve al agregado, no al revés. SUM(COALESCE(p.recargo, 0)) también funcionaría y sería incluso más preciso semánticamente (trata cada NULL individual como cero), pero para el total da lo mismo… salvo con AVG, donde sí cambia el resultado, porque altera el número de valores promediados. Compruébalo:
SELECT ROUND(AVG(recargo), 4) AS avg_ignorando_nulos, -- 0.8750
ROUND(AVG(COALESCE(recargo, 0)), 4) AS avg_tratando_nulos_cero -- 0.5833
FROM prestamos;CASE WHEN: contar condicionalmente
CASE es la estructura condicional de SQL:
Metido dentro de un SUM o un COUNT, permite tabular: producir varias columnas que cuentan cosas distintas del mismo grupo. Es la forma de construir un cuadro de mando en una sola consulta.
SELECT su.nombre AS sucursal,
COUNT(*) AS total,
SUM(CASE WHEN e.estado = 'disponible' THEN 1 ELSE 0 END) AS disponibles,
SUM(CASE WHEN e.estado = 'prestado' THEN 1 ELSE 0 END) AS prestados,
SUM(CASE WHEN e.estado IN ('reparacion','baja') THEN 1 ELSE 0 END) AS fuera_de_servicio
FROM ejemplares e
INNER JOIN sucursales su ON su.sucursal_id = e.sucursal_id
GROUP BY su.sucursal_id, su.nombre
ORDER BY su.nombre;| sucursal | total | disponibles | prestados | fuera_de_servicio |
|---|---|---|---|---|
| Centro | 6 | 4 | 2 | 0 |
| Este | 2 | 1 | 0 | 1 |
| Norte | 4 | 2 | 2 | 0 |
| Sur | 3 | 2 | 0 | 1 |
Esto es exactamente la rejilla completa que GROUP BY sucursal_id, estado no podía dar: aquí sí aparecen los ceros, porque las columnas están fijadas por la consulta y no dependen de los datos.
Cómo funciona: para cada fila, el CASE produce 1 o 0, y SUM los suma. Una alternativa muy usada es COUNT(CASE WHEN condición THEN 1 END) —sin ELSE, de modo que las filas que no cumplen dan NULL y COUNT las ignora—.
PostgreSQL ofrece además una sintaxis estándar más elegante, la cláusula FILTER:
SELECT su.nombre AS sucursal,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE e.estado = 'disponible') AS disponibles,
COUNT(*) FILTER (WHERE e.estado = 'prestado') AS prestados
FROM ejemplares e
INNER JOIN sucursales su ON su.sucursal_id = e.sucursal_id
GROUP BY su.sucursal_id, su.nombre
ORDER BY su.nombre;Mismo resultado, mucho más legible. FILTER no existe en SQLite ni en MySQL; ahí toca CASE WHEN. Y CASE WHEN funciona en todas partes, así que es la opción segura.
- Introducción a las funciones de ventana
Terminamos con una capacidad que resuelve un problema que GROUP BY no puede: calcular un agregado sin perder el detalle.
Fíjate en la limitación. Esta consulta te dice cuántos préstamos tiene cada socio, pero pierde los préstamos individuales:
¿Y si quieres ver cada préstamo y, al lado, cuántos tiene ese socio en total? Ahí entran las funciones de ventana (window functions): calculan un agregado sobre un conjunto de filas relacionadas, pero devuelven una fila por cada fila original.
SELECT s.apellidos,
p.prestamo_id,
p.fecha_prestamo,
COUNT(*) OVER (PARTITION BY p.socio_id) AS prestamos_del_socio
FROM prestamos p
INNER JOIN socios s ON s.socio_id = p.socio_id
ORDER BY s.apellidos, p.fecha_prestamo;| apellidos | prestamo_id | fecha_prestamo | prestamos_del_socio |
|---|---|---|---|
| Alsina | 1 | 2026-03-02 | 3 |
| Alsina | 4 | 2026-04-06 | 3 |
| Alsina | 9 | 2026-07-14 | 3 |
| Bastos | 3 | 2026-03-11 | 2 |
| Bastos | 6 | 2026-05-04 | 2 |
| Ferrán | 7 | 2026-05-08 | 1 |
| Miralles | 12 | 2026-07-25 | 1 |
| Pereda | 2 | 2026-03-05 | 2 |
| Pereda | 10 | 2026-07-18 | 2 |
| Quiroga | 8 | 2026-05-19 | 1 |
| Salom | 11 | 2026-07-21 | 1 |
| Vendrell | 5 | 2026-04-12 | 1 |
Doce filas, una por préstamo, cada una con el total de su socio repetido al lado. Eso es imposible con GROUP BY.
GROUP BY |
Función de ventana (OVER) |
|
|---|---|---|
| Filas del resultado | Una por grupo | Una por fila original |
| ¿Se pierde el detalle? | Sí | No |
| Sintaxis | COUNT(*) ... GROUP BY socio_id |
COUNT(*) OVER (PARTITION BY socio_id) |
| Para qué sirve | Informes de totales | Comparar cada fila con su grupo, numerar, rankings |
PARTITION BY es a las ventanas lo que GROUP BY es a los agregados: define los subconjuntos. Si lo omites (OVER ()), la ventana es la tabla entera.
Numerar y clasificar: ROW_NUMBER y RANK
SELECT s.apellidos,
p.fecha_prestamo,
ROW_NUMBER() OVER (PARTITION BY p.socio_id ORDER BY p.fecha_prestamo) AS n_prestamo
FROM prestamos p
INNER JOIN socios s ON s.socio_id = p.socio_id
ORDER BY s.apellidos, n_prestamo;| apellidos | fecha_prestamo | n_prestamo |
|---|---|---|
| Alsina | 2026-03-02 | 1 |
| Alsina | 2026-04-06 | 2 |
| Alsina | 2026-07-14 | 3 |
| Bastos | 2026-03-11 | 1 |
| Bastos | 2026-05-04 | 2 |
| Ferrán | 2026-05-08 | 1 |
| Miralles | 2026-07-25 | 1 |
| Pereda | 2026-03-05 | 1 |
| Pereda | 2026-07-18 | 2 |
| Quiroga | 2026-05-19 | 1 |
| Salom | 2026-07-21 | 1 |
| Vendrell | 2026-04-12 | 1 |
"El préstamo número N de cada socio". Este patrón —numerar dentro de cada grupo y quedarse con el n_prestamo = 1— es la forma estándar de obtener "el registro más reciente de cada X", una consulta que sin ventanas es sorprendentemente incómoda.
RANK es como ROW_NUMBER pero empata:
WITH conteo AS (
SELECT l.libro_id, l.titulo, COUNT(*) AS prestamos
FROM prestamos p
INNER JOIN ejemplares e ON e.ejemplar_id = p.ejemplar_id
INNER JOIN libros l ON l.libro_id = e.libro_id
GROUP BY l.libro_id, l.titulo
)
SELECT titulo,
prestamos,
RANK() OVER (ORDER BY prestamos DESC) AS puesto,
DENSE_RANK() OVER (ORDER BY prestamos DESC) AS puesto_denso,
ROW_NUMBER() OVER (ORDER BY prestamos DESC, titulo) AS orden
FROM conteo
ORDER BY prestamos DESC, titulo;| titulo | prestamos | puesto | puesto_denso | orden |
|---|---|---|---|---|
| El mapa del tiempo | 4 | 1 | 1 | 1 |
| Los pilares de la Tierra | 3 | 2 | 2 | 2 |
| Álgebra para impacientes | 1 | 3 | 3 | 3 |
| Cuadernos de Ravena | 1 | 3 | 3 | 4 |
| El invierno de los pájaros | 1 | 3 | 3 | 5 |
| La casa de las mareas | 1 | 3 | 3 | 6 |
| Rutas del delta | 1 | 3 | 3 | 7 |
Las tres funciones se diferencian justo en los empates:
ROW_NUMBERnunca empata: numera 1, 2, 3, 4, 5, 6, 7 aunque los valores sean iguales.RANKempata y salta: los cinco libros con un préstamo son todos "puesto 3"; si hubiera un sexto valor distinto, sería el puesto 8.DENSE_RANKempata y no salta: el siguiente valor distinto sería el puesto 4.
Aquí lo dejamos. Las funciones de ventana dan para mucho más —medias móviles, sumas acumuladas, LAG y LEAD para comparar con la fila anterior, marcos de ventana con ROWS BETWEEN— y son el pan de cada día del análisis de datos. Están disponibles en PostgreSQL desde la versión 8.4 y en SQLite desde la 3.25.
Errores Comunes y Consejos
- Olvidar una columna en el
GROUP BY. En PostgreSQL da error; en SQLite se ejecuta y devuelve un valor arbitrario, que es infinitamente peor. Escribe siempre elGROUP BYcompleto. - Usar
COUNT(*)con unLEFT JOIN. Devuelve 1 donde debería devolver 0, porque cuenta la fila rellena deNULL. UsaCOUNT(columna_de_la_tabla_derecha). - Contar sobre un
JOINque infla filas. Si dos tablas independientes cuelgan de una tercera, cadaCOUNTcuenta el producto cartesiano. Agrega cada rama por separado. - Poner una condición con agregado en
WHERE. Error seguro: los agregados van enHAVING. - Poner en
HAVINGuna condición que no usa agregados. Funciona, pero filtra más tarde de lo necesario y confunde a quien lo lea. Va enWHERE. - Usar un alias del
SELECTdentro delWHERE. ElSELECTse evalúa después. EnORDER BYsí se puede. - Suponer que
SUMde purosNULLda cero. DaNULL. Envuélvelo enCOALESCE(SUM(x), 0)si el informe necesita un número. - Comparar
AVGsin decidir qué hacer con losNULL.AVG(x)yAVG(COALESCE(x, 0))dan cifras distintas y ambas pueden ser correctas: la pregunta es qué significa la ausencia. - Creer que
MIN(a)yMAX(b)vienen de la misma fila. No lo hacen. Cada agregado se calcula por su cuenta. - Consejo: cuando un agregado te dé un número que no te cuadre, quita las funciones y ejecuta la consulta en modo detalle con
SELECT *. Contar las filas a ojo revela las explosiones al instante. - Consejo: escribe siempre el
GROUP BYempezando por la clave primaria de la tabla que estás resumiendo (GROUP BY l.libro_id, l.titulo, no soloGROUP BY l.titulo). Si hubiera dos libros con el mismo título, agrupar por el título los fundiría en uno.
Ejercicios
Ejercicio 1: Recuentos y totales
- ¿Cuántos socios hay en cada sucursal? Muestra el nombre de la sucursal, incluidas las que no tuvieran ninguno.
- ¿Cuántos libros hay por idioma?
- ¿Cuántos ejemplares tiene cada libro? Incluye el título y ordena de más a menos.
- ¿Cuál es el recargo total cobrado en cada sucursal (por la sucursal del ejemplar prestado)? Muestra
0.00donde no haya habido ninguno.
Ejercicio 2: Agrupar y filtrar grupos
- ¿Qué autores tienen más de una obra en el fondo?
- ¿Qué libros tienen ejemplares en tres o más sucursales distintas?
- ¿Qué socios han devuelto algún libro con retraso, y cuántas veces? Ordena de más a menos.
- ¿Qué sucursales tienen tres o más ejemplares disponibles?
Ejercicio 3: Informes completos
- Construye el cuadro de préstamos por mes: año-mes, número de préstamos y recargo total. Pista: en PostgreSQL,
TO_CHAR(fecha_prestamo, 'YYYY-MM'); en SQLite,strftime('%Y-%m', fecha_prestamo). - Para cada socio, muestra su nombre, el número de préstamos, el número de reservas y el recargo acumulado, en una sola consulta y con las cifras correctas. Ten cuidado con la explosión de filas.
- Usando funciones de ventana, muestra cada préstamo con: el socio, la fecha, el recargo, y el recargo total acumulado por ese socio.
Soluciones
Solución 1
-- 1
SELECT su.nombre AS sucursal, COUNT(s.socio_id) AS socios
FROM sucursales su
LEFT JOIN socios s ON s.sucursal_id = su.sucursal_id
GROUP BY su.sucursal_id, su.nombre
ORDER BY socios DESC, su.nombre;| sucursal | socios |
|---|---|
| Centro | 4 |
| Norte | 3 |
| Sur | 2 |
| Este | 1 |
| idioma | libros |
|---|---|
| es | 8 |
| ca | 1 |
-- 3
SELECT l.titulo, COUNT(e.ejemplar_id) AS ejemplares
FROM libros l
LEFT JOIN ejemplares e ON e.libro_id = l.libro_id
GROUP BY l.libro_id, l.titulo
ORDER BY ejemplares DESC, l.titulo;| titulo | ejemplares |
|---|---|
| El mapa del tiempo | 3 |
| Álgebra para impacientes | 2 |
| El invierno de los pájaros | 2 |
| Los pilares de la Tierra | 2 |
| Manual de jardinería urbana | 2 |
| Cuadernos de Ravena | 1 |
| La casa de las mareas | 1 |
| Memoria del Ensanche (1904) | 1 |
| Rutas del delta | 1 |
-- 4
SELECT su.nombre AS sucursal, COALESCE(SUM(p.recargo), 0) AS recargo_total
FROM sucursales su
LEFT JOIN ejemplares e ON e.sucursal_id = su.sucursal_id
LEFT JOIN prestamos p ON p.ejemplar_id = e.ejemplar_id
GROUP BY su.sucursal_id, su.nombre
ORDER BY recargo_total DESC;| sucursal | recargo_total |
|---|---|
| Este | 4.20 |
| Norte | 1.40 |
| Sur | 1.40 |
| Centro | 0.00 |
Total: 7,00 €, que coincide con el SUM(recargo) global del apartado 1. Aquí no hay explosión de filas, porque las tres tablas están encadenadas en línea (sucursal → ejemplar → préstamo), no colgando en paralelo.
Solución 2
-- 1
SELECT a.apellidos, COUNT(*) AS obras
FROM libros l
INNER JOIN autores a ON a.autor_id = l.autor_id
GROUP BY a.autor_id, a.apellidos
HAVING COUNT(*) > 1;| apellidos | obras |
|---|---|
| Barreda | 2 |
-- 2
SELECT l.titulo, COUNT(DISTINCT e.sucursal_id) AS sucursales
FROM libros l
INNER JOIN ejemplares e ON e.libro_id = l.libro_id
GROUP BY l.libro_id, l.titulo
HAVING COUNT(DISTINCT e.sucursal_id) >= 3;| titulo | sucursales |
|---|---|
| El mapa del tiempo | 3 |
El DISTINCT es imprescindible: si un libro tuviera dos ejemplares en la misma sucursal, COUNT(e.sucursal_id) los contaría dos veces.
-- 3
SELECT s.nombre || ' ' || s.apellidos AS socio, COUNT(*) AS retrasos
FROM prestamos p
INNER JOIN socios s ON s.socio_id = p.socio_id
WHERE p.fecha_devolucion > p.fecha_devolucion_prevista
GROUP BY s.socio_id, s.nombre, s.apellidos
ORDER BY retrasos DESC, socio;| socio | retrasos |
|---|---|
| Iván Pereda | 1 |
| Lucía Vendrell | 1 |
| Sonia Quiroga | 1 |
Nota: la condición va en WHERE porque filtra filas (préstamos), no grupos. Es el filtrado eficiente.
-- 4
SELECT su.nombre AS sucursal, COUNT(*) AS disponibles
FROM ejemplares e
INNER JOIN sucursales su ON su.sucursal_id = e.sucursal_id
WHERE e.estado = 'disponible'
GROUP BY su.sucursal_id, su.nombre
HAVING COUNT(*) >= 3
ORDER BY disponibles DESC;| sucursal | disponibles |
|---|---|
| Centro | 4 |
Solución 3
-- 1 (PostgreSQL)
SELECT TO_CHAR(fecha_prestamo, 'YYYY-MM') AS mes,
COUNT(*) AS prestamos,
COALESCE(SUM(recargo), 0) AS recargo
FROM prestamos
GROUP BY TO_CHAR(fecha_prestamo, 'YYYY-MM')
ORDER BY mes;
-- SQLite: sustituye TO_CHAR(...) por strftime('%Y-%m', fecha_prestamo)| mes | prestamos | recargo |
|---|---|---|
| 2026-03 | 3 | 1.40 |
| 2026-04 | 2 | 1.40 |
| 2026-05 | 3 | 4.20 |
| 2026-07 | 4 | 0.00 |
Los cuatro préstamos de julio están abiertos, así que su recargo es NULL y SUM devuelve NULL; el COALESCE lo convierte en 0.00. Y fíjate en que junio no aparece: no hubo ningún préstamo ese mes y GROUP BY no inventa grupos vacíos. Para que salga con un 0 habría que generar la serie de meses y hacerle un LEFT JOIN.
-- 2 Tres ramas independientes: hay que agregar cada una por separado
WITH prest AS (
SELECT socio_id, COUNT(*) AS prestamos, SUM(recargo) AS recargo
FROM prestamos GROUP BY socio_id
),
resv AS (
SELECT socio_id, COUNT(*) AS reservas
FROM reservas GROUP BY socio_id
)
SELECT s.socio_id,
s.nombre || ' ' || s.apellidos AS socio,
COALESCE(pr.prestamos, 0) AS prestamos,
COALESCE(rv.reservas, 0) AS reservas,
COALESCE(pr.recargo, 0) AS recargo
FROM socios s
LEFT JOIN prest pr ON pr.socio_id = s.socio_id
LEFT JOIN resv rv ON rv.socio_id = s.socio_id
ORDER BY s.socio_id;| socio_id | socio | prestamos | reservas | recargo |
|---|---|---|---|---|
| 11 | Álvaro Ferrán | 1 | 1 | 0.00 |
| 12 | Sonia Quiroga | 1 | 0 | 4.20 |
| 13 | Ramón Etxebarri | 0 | 0 | 0.00 |
| 14 | Marta Alsina | 3 | 1 | 0.00 |
| 15 | Iván Pereda | 2 | 1 | 1.40 |
| 16 | Nuria Bastos | 2 | 1 | 0.00 |
| 17 | Diego Salom | 1 | 0 | 0.00 |
| 18 | Lucía Vendrell | 1 | 1 | 1.40 |
| 19 | Pau Miralles | 1 | 0 | 0.00 |
| 20 | Elena Roig | 0 | 0 | 0.00 |
Si lo hubieras resuelto con dos LEFT JOIN directos a prestamos y reservas, Marta Alsina habría salido con 3 préstamos y 3 reservas (3 × 1 = 3 filas), y Nuria Bastos con 2 y 2. La explosión de filas en estado puro.
-- 3
SELECT s.apellidos,
p.fecha_prestamo,
COALESCE(p.recargo, 0) AS recargo,
SUM(COALESCE(p.recargo, 0)) OVER (PARTITION BY p.socio_id
ORDER BY p.fecha_prestamo) AS acumulado
FROM prestamos p
INNER JOIN socios s ON s.socio_id = p.socio_id
ORDER BY s.apellidos, p.fecha_prestamo;| apellidos | fecha_prestamo | recargo | acumulado |
|---|---|---|---|
| Alsina | 2026-03-02 | 0.00 | 0.00 |
| Alsina | 2026-04-06 | 0.00 | 0.00 |
| Alsina | 2026-07-14 | 0.00 | 0.00 |
| Bastos | 2026-03-11 | 0.00 | 0.00 |
| Bastos | 2026-05-04 | 0.00 | 0.00 |
| Ferrán | 2026-05-08 | 0.00 | 0.00 |
| Miralles | 2026-07-25 | 0.00 | 0.00 |
| Pereda | 2026-03-05 | 1.40 | 1.40 |
| Pereda | 2026-07-18 | 0.00 | 1.40 |
| Quiroga | 2026-05-19 | 4.20 | 4.20 |
| Salom | 2026-07-21 | 0.00 | 0.00 |
| Vendrell | 2026-04-12 | 1.40 | 1.40 |
Al añadir ORDER BY dentro del OVER, SUM deja de ser un total y pasa a ser una suma acumulada: cada fila incluye todas las anteriores de su partición. Es el mecanismo detrás de cualquier gráfico de evolución acumulada.
Conclusión
Con esta lección BiblioRed ya puede responder no solo "qué hay", sino "cuánto hay":
- Las funciones de agregado
COUNT,SUM,AVG,MINyMAXcondensan muchas filas en un valor. SinGROUP BY, la tabla entera es un solo grupo. COUNTtiene tres formas:COUNT(*)cuenta filas,COUNT(columna)cuenta valores no nulos yCOUNT(DISTINCT columna)cuenta valores distintos.- Todos los agregados ignoran los
NULLsalvoCOUNT(*), ySUMde puros nulos devuelveNULL, no cero. Decidir qué significa la ausencia es una decisión de negocio, no técnica. GROUP BYparte el resultado en grupos y devuelve una fila por grupo; toda columna delSELECTque no sea agregado debe estar en elGROUP BY(y SQLite no lo comprueba, lo cual es una trampa seria).HAVINGfiltra grupos;WHEREfiltra filas. Lo que se pueda poner enWHERE, va enWHERE.- El orden lógico de ejecución —
FROM→JOIN→WHERE→GROUP BY→HAVING→SELECT→DISTINCT→ORDER BY→LIMIT— explica por quéWHEREno ve los alias ni los agregados yORDER BYsí. - La agregación combinada con
JOINproduce los informes reales: préstamos por sucursal, libros más prestados, actividad por socio. Con una regla de oro: conLEFT JOIN,COUNT(columna), nuncaCOUNT(*). - La explosión de filas infla los recuentos cuando dos tablas independientes cuelgan de una tercera. Se detecta porque las cifras salen sospechosamente altas e idénticas, y se resuelve agregando cada rama por separado con CTE.
COALESCEconvierteNULLen un valor presentable yCASE WHEN(oFILTERen PostgreSQL) permite tabular varias columnas condicionales en una sola pasada.- Las funciones de ventana con
OVER (PARTITION BY ...)calculan agregados sin perder el detalle, yROW_NUMBER,RANKyDENSE_RANKnumeran y clasifican con distinto tratamiento de los empates.
Ya sabemos definir el esquema, poblarlo, consultarlo, cruzarlo y resumirlo. Falta la pregunta que sostiene todo lo demás: ¿quién garantiza que estos datos sigan siendo ciertos dentro de cinco años? Nuestros informes son fiables solo porque todos los socio_id de prestamos apuntan a socios que existen, y todos los libro_id de ejemplares, a libros del catálogo.
Eso lo garantiza la integridad referencial, y es el tema de la lección 02-06, con la que cerramos el módulo: cómo se declaran las claves ajenas, qué comprueba el gestor en cada INSERT, UPDATE y DELETE, qué hacer cuando se borra una fila de la que dependen otras (CASCADE, RESTRICT, SET NULL…), cómo detectar y limpiar las filas huérfanas que ya existen, y por qué SQLite no protege nada si no se lo pides expresamente.
Fundamentos de Bases de Datos
Módulo 1: Introducción a las Bases de Datos
- Conceptos Básicos de Bases de Datos
- Tipos de Bases de Datos
- Historia y Evolución de las Bases de Datos
- Sistemas Gestores de Bases de Datos y Arquitectura
Módulo 2: Bases de Datos Relacionales
- Modelo Relacional
- Lenguaje SQL
- Operaciones Básicas en SQL
- Consultas Multitabla: JOIN y Subconsultas
- Agregación y Agrupación de Datos
- Integridad Referencial
Módulo 3: Bases de Datos No Relacionales
- Introducción a NoSQL
- Tipos de Bases de Datos NoSQL
- Modelado de Datos en NoSQL
- Comparación entre Bases de Datos Relacionales y No Relacionales
Módulo 4: Diseño de Esquemas
- Principios de Diseño de Esquemas
- Diagramas Entidad-Relación (ER)
- Transformación de Diagramas ER a Esquemas Relacionales
- Tipos de Datos y Restricciones
Módulo 5: Normalización
Módulo 6: Transacciones, Rendimiento y Seguridad
- Transacciones y Propiedades ACID
- Concurrencia y Niveles de Aislamiento
- Índices y Optimización de Consultas
- Seguridad, Permisos y Copias de Seguridad
Módulo 7: Ejercicios Prácticos
- Ejercicios de SQL
- Ejercicios de Diseño de Esquemas
- Ejercicios de Normalización
- Ejercicios de Consultas Avanzadas y Transacciones
Módulo 8: Casos de Estudio
- Caso de Estudio: Base de Datos Relacional
- Caso de Estudio: Base de Datos No Relacional
- Caso de Estudio: Persistencia Políglota
