Fins ara, totes les nostres consultes han retornat detall: una fila del resultat per cada fila de la base de dades. Això respon preguntes del tipus "què?" —quins préstecs hi ha oberts, quins exemplars no ha tocat ningú—, però la direcció de BiblioRed en fa unes altres: quants préstecs vam fer el trimestre passat?, quina sucursal presta més?, quin és el títol estrella?, quant hem recaptat en recàrrecs?, quins socis superen els dos préstecs?.

Aquestes preguntes exigeixen passar del detall al resum: condensar moltes files en una sola xifra. És el que fan les funcions d'agregat i la clàusula GROUP BY, i és el pas que converteix una base de dades en una eina de gestió.

Aquesta lliçó també aclareix una cosa que arrossegues des de la primera consulta: en quin ordre s'executa realment una instrucció SQL. No és l'ordre en què s'escriu, i entendre-ho resol de cop mitja dotzena d'errors aparentment inexplicables.

Donem per sabuts els JOIN i les subconsultes de la lliçó 02-04: els farem servir sense tornar-los a explicar.

Contingut

  1. Les funcions d'agregat
  2. COUNT i les seves tres formes
  3. SUM, AVG, MIN i MAX
  4. Com tracten els agregats els valors NULL
  5. GROUP BY: agregar per grups
  6. La regla de què pot aparèixer al SELECT
  7. GROUP BY per diverses columnes
  8. HAVING davant de WHERE
  9. L'ordre lògic d'execució d'una consulta
  10. Agregació combinada amb JOIN
  11. El problema de les files inflades per un JOIN
  12. COALESCE i CASE WHEN dins d'agregats
  13. Introducció a les funcions de finestra
  14. Errors habituals i consells
  15. Exercicis
  16. Conclusió

  1. Les funcions d'agregat

Una funció d'agregat rep molts valors i en retorna un de sol. Les cinc de l'estàndard SQL, presents a tots els gestors:

Funció Què retorna Tipus acceptats
COUNT Nombre de files o de valors Qualsevol
SUM Suma Numèrics
AVG Mitjana aritmètica Numèrics
MIN Valor mínim Numèrics, text, dates
MAX Valor màxim Numèrics, text, dates

Sense GROUP BY, una funció d'agregat tracta tota la taula com un únic grup i retorna una sola fila:

SELECT COUNT(*) FROM prestecs;
 count
-------
    12

En pots combinar diverses a la mateixa consulta, sempre que totes resumeixin el mateix conjunt de files:

SELECT COUNT(*)              AS total_prestecs,
       MIN(data_prestec)     AS primer_prestec,
       MAX(data_prestec)     AS ultim_prestec,
       SUM(recarrec)         AS recarrecs_cobrats
FROM prestecs;
total_prestecs primer_prestec ultim_prestec recarrecs_cobrats
12 2026-03-02 2026-07-25 7.00

Dotze préstecs entre el 2 de març i el 25 de juliol de 2026, amb 7,00 € recaptats en recàrrecs.

Fixa't en un detall essencial: el resultat té una fila, no dotze. La consulta ja no parla de préstecs individuals, parla del conjunt. Per això no pots barrejar lliurement agregats amb columnes de detall; ho veurem a l'apartat 6.

  1. COUNT i les seves tres formes

COUNT sembla la funció més simple i és la que més confusió genera, perquè té tres variants que compten coses diferents.

SELECT COUNT(*)                    AS files,
       COUNT(email)                AS amb_email,
       COUNT(DISTINCT sucursal_id) AS sucursals_diferents
FROM socis;
files amb_email sucursals_diferents
10 9 4
Forma Compta Resultat aquí
COUNT(*) Files, sense mirar el contingut 10 socis
COUNT(columna) Valors no nuls d'aquesta columna 9 (Pau Miralles no té correu)
COUNT(DISTINCT columna) Valors no nuls diferents 4 sucursals diferents

La diferència entre les dues primeres és la que més vegades es passa per alt, i a prestecs és especialment eloqüent:

SELECT COUNT(*)                          AS prestecs,
       COUNT(data_devolucio)             AS tancats,
       COUNT(*) - COUNT(data_devolucio)  AS oberts,
       COUNT(DISTINCT soci_id)           AS socis_diferents
FROM prestecs;
prestecs tancats oberts socis_diferents
12 8 4 8

COUNT(data_devolucio) compta 8 perquè els quatre préstecs oberts tenen aquesta columna a NULL. És un truc molt útil: comptar els no nuls d'una columna equival a comptar les files que compleixen certa condició, si aquesta condició es reflecteix en la nul·litat.

I COUNT(DISTINCT soci_id) retorna 8, no 12: hi ha vuit socis diferents amb préstecs, perquè alguns en tenen més d'un.

Sobre el rendiment: existeix la llegenda que COUNT(1) és més ràpid que COUNT(*). És fals a PostgreSQL i a qualsevol gestor modern: són idèntics. COUNT(DISTINCT columna) sí que és notablement més car, perquè obliga a deduplicar.

  1. SUM, AVG, MIN i MAX

SELECT COUNT(*)             AS llibres,
       MIN(any_publicacio)  AS mes_antic,
       MAX(any_publicacio)  AS mes_recent,
       ROUND(AVG(any_publicacio), 2) AS any_mitja
FROM llibres;
llibres mes_antic mes_recent any_mitja
9 1904 2023 2000.89

ROUND(expressió, decimals) no és una funció d'agregat: arrodoneix el resultat. Sense ella, AVG sobre NUMERIC a PostgreSQL retorna un número amb moltíssims decimals (2000.8888888888888889), poc pràctic per a un informe.

MIN i MAX funcionen també sobre text (ordre alfabètic) i sobre dates (ordre cronològic):

SELECT MIN(cognoms)   AS primer_alfabeticament,
       MAX(cognoms)   AS ultim_alfabeticament,
       MIN(data_alta) AS soci_mes_antic,
       MAX(data_alta) AS soci_mes_recent
FROM socis;
primer_alfabeticament ultim_alfabeticament soci_mes_antic soci_mes_recent
Alsina Vendrell 2018-01-22 2024-10-01

Atenció a un parany clàssic: aquesta consulta et diu quin és el cognom mínim i quina és la data mínima, però no et diu que siguin de la mateixa persona. Cada agregat es calcula per separat. Per obtenir la fila completa del soci més antic cal una altra cosa:

SELECT soci_id, nom, cognoms, data_alta
FROM socis
WHERE data_alta = (SELECT MIN(data_alta) FROM socis);
soci_id nom cognoms data_alta
11 Álvaro Ferran 2018-01-22

És la subconsulta escalar de la lliçó anterior, ara amb un agregat a dins. A l'apartat 13 veurem una alternativa amb funcions de finestra.

  1. Com tracten els agregats els valors NULL

Aquí hi ha una regla i una excepció, i convé gravar-les:

Totes les funcions d'agregat ignoren els NULL. L'única excepció és COUNT(*), que compta files i no mira el contingut.

Vegem-ho amb la columna recarrec, que té vuit valors i quatre NULL:

SELECT COUNT(*)                        AS files,
       COUNT(recarrec)                 AS amb_recarrec,
       SUM(recarrec)                   AS suma,
       ROUND(AVG(recarrec), 4)         AS mitjana_ignorant_nuls,
       ROUND(SUM(recarrec) / COUNT(*), 4) AS mitjana_comptant_nuls_com_zero,
       MIN(recarrec)                   AS minim,
       MAX(recarrec)                   AS maxim
FROM prestecs;
files amb_recarrec suma mitjana_ignorant_nuls mitjana_comptant_nuls_com_zero minim maxim
12 8 7.00 0.8750 0.5833 0.00 4.20

Dues mitjanes diferents per a les mateixes dades, i cap de les dues no està malament: signifiquen coses diferents.

  • AVG(recarrec) = 7,00 / 8 = 0,875 €. És "el recàrrec mitjà dels préstecs ja tancats", perquè els oberts encara no tenen recàrrec calculat.
  • SUM(recarrec) / COUNT(*) = 7,00 / 12 = 0,583 €. És "el recàrrec mitjà per préstec realitzat", tractant els pendents com a zero.

La pregunta que t'has de fer sempre és: què significa el NULL en aquesta columna? Si significa "encara no se sap", ignorar-lo és correcte. Si significa "zero", ignorar-lo distorsiona la mitjana. A l'apartat 12 veurem COALESCE, l'eina per convertir explícitament NULL en zero quan aquesta és la semàntica correcta.

Un cas extrem que sorprèn:

SELECT SUM(recarrec), COUNT(recarrec), AVG(recarrec)
FROM prestecs
WHERE data_devolucio IS NULL;   -- els quatre préstecs oberts
sum count avg
(NULL) 0 (NULL)

SUM d'un conjunt on tots els valors són NULL retorna NULL, no zero. I el mateix si el conjunt és buit. COUNT, en canvi, retorna 0: és l'única funció d'agregat que mai no retorna NULL.

  1. GROUP BY: agregar per grups

Fins ara resumíem la taula sencera. GROUP BY la parteix en grups i calcula els agregats dins de cada grup, retornant una fila per grup.

SELECT sucursal_id, COUNT(*) AS exemplars
FROM exemplars
GROUP BY sucursal_id
ORDER BY sucursal_id;
sucursal_id exemplars
1 6
2 4
3 3
4 2

Quatre files, una per sucursal, i la suma dels recomptes és 15: el total d'exemplars. Conceptualment passa això:

flowchart LR
    T["exemplars<br/>15 files"] --> G1["sucursal_id = 1<br/>6 files"]
    T --> G2["sucursal_id = 2<br/>4 files"]
    T --> G3["sucursal_id = 3<br/>3 files"]
    T --> G4["sucursal_id = 4<br/>2 files"]
    G1 --> R["Resultat<br/>4 files,<br/>una per grup"]
    G2 --> R
    G3 --> R
    G4 --> R

Un altre exemple, agrupant per una columna de text:

SELECT estat, COUNT(*) AS quants
FROM exemplars
GROUP BY estat
ORDER BY quants DESC, estat;
estat quants
disponible 9
prestat 4
baixa 1
reparacio 1

Observa que es pot ordenar per l'agregat fent servir el seu àlies. I observa també que els grups surten de les dades que hi ha: si cap exemplar no estigués en reparació, aquesta fila simplement no apareixeria. GROUP BY no inventa mai grups buits.

Un tercer exemple, amb més d'un agregat per grup:

SELECT editorial,
       COUNT(*)            AS titols,
       MIN(any_publicacio) AS mes_antic,
       MAX(any_publicacio) AS mes_recent
FROM llibres
GROUP BY editorial
ORDER BY titols DESC, editorial;
editorial titols mes_antic mes_recent
Edicions Marlia 3 2012 2017
Editorial Andana 3 1989 2021
Premsa Tècnica Nord 2 2019 2023
Ajuntament de Vallmar 1 1904 1904

  1. La regla de què pot aparèixer al SELECT

Aquesta és la regla que més errors produeix en començar:

Tota columna que aparegui al SELECT i no estigui dins d'una funció d'agregat ha de figurar al GROUP BY.

El motiu és pur sentit comú. Si agrupes els exemplars per sucursal, cada fila del resultat representa sis exemplars diferents (els de la sucursal 1). Quin codi hauria de mostrar? El de quin dels sis? La pregunta no té resposta, així que el gestor la rebutja:

SELECT sucursal_id, codi, COUNT(*)
FROM exemplars
GROUP BY sucursal_id;
ERROR:  column "exemplars.codi" must appear in the GROUP BY clause
        or be used in an aggregate function

Les tres maneres legítimes d'arreglar-ho, segons el que vulguis de veritat:

-- a) Afegir la columna al GROUP BY: canvien els grups (i aquí ja no agrupa res,
--    perquè codi és únic)
SELECT sucursal_id, codi, COUNT(*) FROM exemplars GROUP BY sucursal_id, codi;

-- b) Embolcallar-la en un agregat: "el codi més petit de cada sucursal"
SELECT sucursal_id, MIN(codi) AS primer_codi, COUNT(*) AS exemplars
FROM exemplars GROUP BY sucursal_id ORDER BY sucursal_id;

-- c) Treure-la del SELECT
SELECT sucursal_id, COUNT(*) FROM exemplars GROUP BY sucursal_id;

Resultat de l'opció b):

sucursal_id primer_codi exemplars
1 EJ-3082 6
2 EJ-3081 4
3 EJ-3083 3
4 EJ-3088 2

L'excepció de la dependència funcional

PostgreSQL, des de la versió 9.1, admet una relaxació molt pràctica: si agrupes per la clau primària d'una taula, pots seleccionar qualsevol altra columna d'aquesta mateixa taula sense llistar-la, perquè la clau primària la determina de manera unívoca.

-- Legal a PostgreSQL: socis.cognoms depèn funcionalment de socis.soci_id
SELECT s.soci_id, s.nom, s.cognoms, COUNT(p.prestec_id) AS prestecs
FROM socis s
LEFT JOIN prestecs p ON p.soci_id = s.soci_id
GROUP BY s.soci_id
ORDER BY s.soci_id;

És una comoditat real, però no és portable: en altres gestors hauries d'escriure GROUP BY s.soci_id, s.nom, s.cognoms. Si el teu SQL ha de funcionar en diversos motors, llista totes les columnes.

I el perill de SQLite

SQLite no aplica aquesta regla en absolut. La consulta que a PostgreSQL dóna error, a SQLite s'executa i retorna un codi qualsevol dels del grup, escollit de manera no documentada. No hi ha error, no hi ha avís, i l'informe és incorrecte.

sqlite> SELECT sucursal_id, codi, COUNT(*) FROM exemplars GROUP BY sucursal_id;
1|EJ-3095|6
2|EJ-3090|4
...

Aquest EJ-3095 no representa res. És una de les diferències més perilloses entre tots dos gestors: la permissivitat de SQLite converteix un error en una dada falsa. Escriu el SQL com si PostgreSQL et vigilés, encara que siguis a SQLite.

  1. GROUP BY per diverses columnes

En agrupar per dues columnes, els grups són les combinacions diferents de totes dues:

SELECT sucursal_id, estat, COUNT(*) AS quants
FROM exemplars
GROUP BY sucursal_id, estat
ORDER BY sucursal_id, estat;
sucursal_id estat quants
1 disponible 4
1 prestat 2
2 disponible 2
2 prestat 2
3 baixa 1
3 disponible 2
4 disponible 1
4 reparacio 1

Vuit grups, els recomptes dels quals sumen 15. Igual que abans: només hi apareixen les combinacions que existeixen. La sucursal 1 no té cap exemplar en reparació, així que aquesta fila no hi és —no surt amb un 0—. Si necessites la graella completa amb zeros inclosos, hi ha dos camins: un CROSS JOIN que generi totes les combinacions (lliçó 02-04) i un LEFT JOIN contra les dades, o la tabulació amb CASE WHEN de l'apartat 12.

L'ordre de les columnes al GROUP BY no altera els grups (GROUP BY a, b i GROUP BY b, a produeixen els mateixos), però sí que convé que coincideixi amb l'ORDER BY perquè l'informe es llegeixi bé.

  1. HAVING davant de WHERE

WHERE filtra files, abans d'agrupar. HAVING filtra grups, després d'agrupar. La diferència és de moment, no de sintaxi.

Pregunta: quins socis tenen dos o més préstecs?

SELECT s.soci_id,
       s.nom || ' ' || s.cognoms AS soci,
       COUNT(*) AS prestecs
FROM prestecs p
INNER JOIN socis s ON s.soci_id = p.soci_id
GROUP BY s.soci_id, s.nom, s.cognoms
HAVING COUNT(*) >= 2
ORDER BY prestecs DESC, soci;
soci_id soci prestecs
14 Marta Alsina 3
15 Ivan Pereda 2
16 Núria Bastos 2

Intentar el mateix amb WHERE és impossible:

SELECT s.soci_id, COUNT(*) FROM prestecs p
INNER JOIN socis s ON s.soci_id = p.soci_id
WHERE COUNT(*) >= 2
GROUP BY s.soci_id;
ERROR:  aggregate functions are not allowed in WHERE

I és lògic: quan s'avalua el WHERE, els grups encara no existeixen, així que no hi ha res per comptar.

Fer servir tots dos alhora

És l'habitual, i cadascun fa la seva feina:

-- Files: només els préstecs amb recàrrec. Grups: només els socis que superen 1 €.
SELECT s.soci_id,
       s.cognoms,
       COUNT(*)        AS prestecs_amb_recarrec,
       SUM(p.recarrec) AS total_recarrec
FROM prestecs p
INNER JOIN socis s ON s.soci_id = p.soci_id
WHERE p.recarrec > 0
GROUP BY s.soci_id, s.cognoms
HAVING SUM(p.recarrec) > 1.00
ORDER BY total_recarrec DESC;
soci_id cognoms prestecs_amb_recarrec total_recarrec
12 Quiroga 1 4.20
15 Pereda 1 1.40
18 Vendrell 1 1.40
Clàusula Què filtra Quan actua Admet agregats?
WHERE Files individuals Abans de GROUP BY No
HAVING Grups ja formats Després de GROUP BY

Regla pràctica d'eficiència: si una condició es pot expressar en WHERE, posa-la al WHERE. Filtrar abans d'agrupar significa agrupar menys files. Posar al HAVING una condició que no fa servir agregats (HAVING s.sucursal_id = 2) funciona en molts gestors, però és més lent i més confús.

  1. L'ordre lògic d'execució d'una consulta

Ara s'entén tot. Una consulta SQL no s'avalua en l'ordre en què s'escriu. L'ordre lògic és aquest:

flowchart TD
    A["1. FROM<br/>pren les taules de partida"] --> B["2. JOIN ... ON<br/>aparella files"]
    B --> C["3. WHERE<br/>descarta files individuals"]
    C --> D["4. GROUP BY<br/>parteix el resultat en grups"]
    D --> E["5. HAVING<br/>descarta grups sencers"]
    E --> F["6. SELECT<br/>calcula columnes i agregats,<br/>aplica els àlies"]
    F --> G["7. DISTINCT<br/>elimina files repetides"]
    G --> H["8. ORDER BY<br/>ordena el resultat"]
    H --> I["9. LIMIT / OFFSET<br/>retalla"]

Aquest diagrama explica d'un cop d'ull quatre comportaments que fins ara semblaven capricis:

  1. WHERE no pot fer servir agregats. S'executa al pas 3; els grups es formen al 4.
  2. WHERE no pot fer servir els àlies del SELECT. El SELECT és el pas 6, posterior. WHERE prestecs >= 2 dóna error de columna inexistent.
  3. ORDER BY sí que pot fer servir els àlies del SELECT. És el pas 8, posterior al 6. Per això ORDER BY prestecs DESC funcionava.
  4. LIMIT és l'últim. Retalla el resultat final, no les files llegides: LIMIT 3 en una consulta amb GROUP BY retorna tres grups, no tres files de la taula.
-- ERROR: 'prestecs' és un àlies definit al pas 6, i WHERE és el pas 3
SELECT s.soci_id, COUNT(*) AS prestecs
FROM prestecs p JOIN socis s ON s.soci_id = p.soci_id
WHERE prestecs >= 2
GROUP BY s.soci_id;
ERROR:  column "prestecs" does not exist

Insistim en això de lògic: és l'ordre en què cal raonar la consulta. L'optimitzador (lliçó 01-04) és lliure d'executar les coses en un altre ordre físic mentre el resultat sigui el mateix.

  1. Agregació combinada amb JOIN

Aquí és on les dues últimes lliçons s'ajunten i BiblioRed comença a produir informes de veritat.

Préstecs per sucursal

Compte amb el matís: la sucursal d'un préstec és la de l'exemplar prestat, que no té per què ser la d'alta del soci.

SELECT su.nom AS sucursal,
       COUNT(*) AS prestecs
FROM prestecs p
INNER JOIN exemplars e  ON e.exemplar_id = p.exemplar_id
INNER JOIN sucursals su ON su.sucursal_id = e.sucursal_id
GROUP BY su.sucursal_id, su.nom
ORDER BY prestecs DESC;
sucursal prestecs
Nord 6
Centre 4
Sud 1
Est 1

La sucursal Nord concentra la meitat de l'activitat. Sumen 12 ✓.

Els llibres més prestats

SELECT l.titol,
       COUNT(*) AS vegades_prestat
FROM prestecs p
INNER JOIN exemplars e ON e.exemplar_id = p.exemplar_id
INNER JOIN llibres l   ON l.llibre_id   = e.llibre_id
GROUP BY l.llibre_id, l.titol
ORDER BY vegades_prestat DESC, l.titol
LIMIT 5;
titol vegades_prestat
El mapa del temps 4
Els pilars de la Terra 3
Àlgebra per a impacients 1
La casa de les marees 1
L'hivern dels ocells 1

Important: en aquest llistat falten dos llibres. "Manual de jardineria urbana" i "Memòria de l'Eixample (1904)" no s'han prestat mai, i l'INNER JOIN els elimina abans d'agrupar. Si l'informe els ha d'incloure amb un 0, cal combinar LEFT JOIN amb COUNT(columna):

SELECT l.titol,
       COUNT(p.prestec_id) AS vegades_prestat
FROM llibres l
LEFT JOIN exemplars e ON e.llibre_id   = l.llibre_id
LEFT JOIN prestecs  p ON p.exemplar_id = e.exemplar_id
GROUP BY l.llibre_id, l.titol
ORDER BY vegades_prestat DESC, l.titol;
titol vegades_prestat
El mapa del temps 4
Els pilars de la Terra 3
Àlgebra per a impacients 1
La casa de les marees 1
L'hivern dels ocells 1
Quaderns de Ravenna 1
Rutes del delta 1
Manual de jardineria urbana 0
Memòria de l'Eixample (1904) 0

Aquí hi ha una lliçó que val per si sola: COUNT(*) hauria retornat 1 en lloc de 0 per als dos últims, perquè el LEFT JOIN genera una fila plena de NULL i COUNT(*) compta files. COUNT(p.prestec_id) compta valors no nuls, i allà no n'hi ha cap.

Amb LEFT JOIN, mai COUNT(*): sempre COUNT(columna_de_la_taula_dreta).

Préstecs per soci, inclosos els que no en tenen cap

SELECT s.soci_id,
       s.nom || ' ' || s.cognoms AS soci,
       COUNT(p.prestec_id) AS prestecs,
       MAX(p.data_prestec) AS ultim_prestec
FROM socis s
LEFT JOIN prestecs p ON p.soci_id = s.soci_id
GROUP BY s.soci_id, s.nom, s.cognoms
ORDER BY prestecs DESC, soci;
soci_id soci prestecs ultim_prestec
14 Marta Alsina 3 2026-07-14
15 Ivan Pereda 2 2026-07-18
16 Núria Bastos 2 2026-05-04
11 Álvaro Ferran 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)

Aquest és l'informe complet d'activitat de socis, amb els inactius inclosos. Compara'l amb l'anti-join de la lliçó anterior: allà només obteníem qui no tenia préstecs; ara tenim la foto sencera.

  1. El problema de les files inflades per un JOIN

Reprenem l'exercici que vam deixar a mitges a la lliçó 02-04. Un responsable de BiblioRed demana: "quants socis i quants exemplars hi ha a cada sucursal". La consulta que surt sola és aquesta:

SELECT su.nom AS sucursal,
       COUNT(s.soci_id)     AS socis,
       COUNT(e.exemplar_id) AS exemplars
FROM sucursals su
LEFT JOIN socis s     ON s.sucursal_id = su.sucursal_id
LEFT JOIN exemplars e ON e.sucursal_id = su.sucursal_id
GROUP BY su.sucursal_id, su.nom
ORDER BY su.sucursal_id;
sucursal socis exemplars
Centre 24 24
Nord 12 12
Sud 6 6
Est 2 2

Tots els números són incorrectes, i el fet que les dues columnes siguin idèntiques és el senyal d'alarma. Centre té 4 socis i 6 exemplars, no 24 i 24.

La causa: socis i exemplars no estan relacionades entre si; totes dues pengen de sucursals de manera independent. En unir-les, dins de cada sucursal es produeix un producte cartesià: 4 socis × 6 exemplars = 24 files, i cada COUNT compta aquestes 24. És l'explosió de files (fan trap).

Les tres solucions, de pitjor a millor:

-- a) COUNT(DISTINCT ...): funciona, i és el més ràpid d'escriure
SELECT su.nom AS sucursal,
       COUNT(DISTINCT s.soci_id)     AS socis,
       COUNT(DISTINCT e.exemplar_id) AS exemplars
FROM sucursals su
LEFT JOIN socis s     ON s.sucursal_id = su.sucursal_id
LEFT JOIN exemplars e ON e.sucursal_id = su.sucursal_id
GROUP BY su.sucursal_id, su.nom
ORDER BY su.sucursal_id;
sucursal socis exemplars
Centre 4 6
Nord 3 4
Sud 2 3
Est 1 2

Correcte. Però COUNT(DISTINCT) és car i només salva els recomptes: un SUM continuaria inflat, perquè sumaria el mateix valor diverses vegades. Si en lloc de comptar exemplars sumessis imports, el resultat seria fals i DISTINCT no ho arreglaria.

-- b) Subconsultes escalars: cada xifra es calcula per separat
SELECT su.nom AS sucursal,
       (SELECT COUNT(*) FROM socis s     WHERE s.sucursal_id = su.sucursal_id) AS socis,
       (SELECT COUNT(*) FROM exemplars e WHERE e.sucursal_id = su.sucursal_id) AS exemplars
FROM sucursals su
ORDER BY su.sucursal_id;

-- c) Agregar cada branca per separat i unir després: la forma canònica
WITH socis_per_sucursal AS (
    SELECT sucursal_id, COUNT(*) AS socis FROM socis GROUP BY sucursal_id
),
exemplars_per_sucursal AS (
    SELECT sucursal_id, COUNT(*) AS exemplars FROM exemplars GROUP BY sucursal_id
)
SELECT su.nom AS sucursal,
       COALESCE(sp.socis, 0)     AS socis,
       COALESCE(ep.exemplars, 0) AS exemplars
FROM sucursals su
LEFT JOIN socis_per_sucursal     sp ON sp.sucursal_id = su.sucursal_id
LEFT JOIN exemplars_per_sucursal ep ON ep.sucursal_id = su.sucursal_id
ORDER BY su.sucursal_id;

Les tres retornen la taula correcta. L'opció c) és la que has d'interioritzar: quan calgui resumir dues branques independents, agrega cadascuna pel seu costat i uneix els resums. És l'única que escala a qualsevol nombre de branques i a qualsevol agregat, no només a COUNT.

Com detectar el problema a la pràctica: si un total et sembla sospitosament gran, treu els agregats i executa la consulta amb SELECT *. Si en surten moltes més files de les que esperaves, tens una explosió.

  1. COALESCE i CASE WHEN dins d'agregats

COALESCE: substituir NULL per un valor

COALESCE(a, b, c, ...) retorna el primer argument que no sigui NULL. És l'eina per decidir explícitament què fer amb l'absència de valor.

SELECT s.soci_id,
       s.cognoms,
       COUNT(p.prestec_id)          AS prestecs,
       COALESCE(SUM(p.recarrec), 0) AS recarrec_total
FROM socis s
LEFT JOIN prestecs p ON p.soci_id = s.soci_id
GROUP BY s.soci_id, s.cognoms
ORDER BY recarrec_total DESC, s.soci_id;
soci_id cognoms prestecs recarrec_total
12 Quiroga 1 4.20
15 Pereda 2 1.40
18 Vendrell 1 1.40
11 Ferran 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

Sense el COALESCE, quatre socis mostrarien NULL en lloc de 0.00: els dos que no tenen cap préstec (Etxebarri i Roig) i els dos que tenen l'únic préstec encara obert i per tant amb el recàrrec sense calcular (Salom i Miralles). Un informe amb NULL en una columna de diners és un informe que ningú no sap llegir.

Fixa't en l'ordre en què s'aplica: COALESCE embolcalla l'agregat, no al revés. SUM(COALESCE(p.recarrec, 0)) també funcionaria i seria fins i tot més precís semànticament (tracta cada NULL individual com a zero), però per al total tant se val… llevat que sigui amb AVG, on sí que canvia el resultat, perquè altera el nombre de valors promitjats. Comprova-ho:

SELECT ROUND(AVG(recarrec), 4)              AS avg_ignorant_nuls,     -- 0.8750
       ROUND(AVG(COALESCE(recarrec, 0)), 4) AS avg_tractant_nuls_zero -- 0.5833
FROM prestecs;

CASE WHEN: comptar condicionalment

CASE és l'estructura condicional de SQL:

CASE WHEN condició1 THEN valor1
     WHEN condició2 THEN valor2
     ELSE valor_per_defecte
END

Ficat dins d'un SUM o d'un COUNT, permet tabular: produir diverses columnes que compten coses diferents del mateix grup. És la manera de construir un quadre de comandament en una sola consulta.

SELECT su.nom AS sucursal,
       COUNT(*) AS total,
       SUM(CASE WHEN e.estat = 'disponible' THEN 1 ELSE 0 END) AS disponibles,
       SUM(CASE WHEN e.estat = 'prestat'    THEN 1 ELSE 0 END) AS prestats,
       SUM(CASE WHEN e.estat IN ('reparacio','baixa') THEN 1 ELSE 0 END) AS fora_de_servei
FROM exemplars e
INNER JOIN sucursals su ON su.sucursal_id = e.sucursal_id
GROUP BY su.sucursal_id, su.nom
ORDER BY su.nom;
sucursal total disponibles prestats fora_de_servei
Centre 6 4 2 0
Est 2 1 0 1
Nord 4 2 2 0
Sud 3 2 0 1

Això és exactament la graella completa que GROUP BY sucursal_id, estat no podia donar: aquí sí que hi apareixen els zeros, perquè les columnes estan fixades per la consulta i no depenen de les dades.

Com funciona: per a cada fila, el CASE produeix 1 o 0, i SUM els suma. Una alternativa molt utilitzada és COUNT(CASE WHEN condició THEN 1 END) —sense ELSE, de manera que les files que no la compleixen donen NULL i COUNT les ignora—.

PostgreSQL ofereix a més una sintaxi estàndard més elegant, la clàusula FILTER:

SELECT su.nom AS sucursal,
       COUNT(*) AS total,
       COUNT(*) FILTER (WHERE e.estat = 'disponible') AS disponibles,
       COUNT(*) FILTER (WHERE e.estat = 'prestat')    AS prestats
FROM exemplars e
INNER JOIN sucursals su ON su.sucursal_id = e.sucursal_id
GROUP BY su.sucursal_id, su.nom
ORDER BY su.nom;

Mateix resultat, molt més llegible. FILTER no existeix a SQLite ni a MySQL; allà toca CASE WHEN. I CASE WHEN funciona a tot arreu, així que és l'opció segura.

  1. Introducció a les funcions de finestra

Acabem amb una capacitat que resol un problema que GROUP BY no pot: calcular un agregat sense perdre el detall.

Fixa't en la limitació. Aquesta consulta et diu quants préstecs té cada soci, però perd els préstecs individuals:

SELECT soci_id, COUNT(*) FROM prestecs GROUP BY soci_id;   -- 8 files

I si vols veure cada préstec i, al costat, quants en té aquest soci en total? Aquí entren les funcions de finestra (window functions): calculen un agregat sobre un conjunt de files relacionades, però retornen una fila per cada fila original.

SELECT s.cognoms,
       p.prestec_id,
       p.data_prestec,
       COUNT(*) OVER (PARTITION BY p.soci_id) AS prestecs_del_soci
FROM prestecs p
INNER JOIN socis s ON s.soci_id = p.soci_id
ORDER BY s.cognoms, p.data_prestec;
cognoms prestec_id data_prestec prestecs_del_soci
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
Ferran 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

Dotze files, una per préstec, cadascuna amb el total del seu soci repetit al costat. Això és impossible amb GROUP BY.

GROUP BY Funció de finestra (OVER)
Files del resultat Una per grup Una per fila original
Es perd el detall? No
Sintaxi COUNT(*) ... GROUP BY soci_id COUNT(*) OVER (PARTITION BY soci_id)
Per a què serveix Informes de totals Comparar cada fila amb el seu grup, numerar, rànquings

PARTITION BY és a les finestres el que GROUP BY és als agregats: defineix els subconjunts. Si l'omets (OVER ()), la finestra és la taula sencera.

Numerar i classificar: ROW_NUMBER i RANK

SELECT s.cognoms,
       p.data_prestec,
       ROW_NUMBER() OVER (PARTITION BY p.soci_id ORDER BY p.data_prestec) AS n_prestec
FROM prestecs p
INNER JOIN socis s ON s.soci_id = p.soci_id
ORDER BY s.cognoms, n_prestec;
cognoms data_prestec n_prestec
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
Ferran 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éstec número N de cada soci". Aquest patró —numerar dins de cada grup i quedar-se amb el n_prestec = 1— és la manera estàndard d'obtenir "el registre més recent de cada X", una consulta que sense finestres és sorprenentment incòmoda.

RANK és com ROW_NUMBER però empata:

WITH recompte AS (
    SELECT l.llibre_id, l.titol, COUNT(*) AS prestecs
    FROM prestecs p
    INNER JOIN exemplars e ON e.exemplar_id = p.exemplar_id
    INNER JOIN llibres l   ON l.llibre_id   = e.llibre_id
    GROUP BY l.llibre_id, l.titol
)
SELECT titol,
       prestecs,
       RANK()       OVER (ORDER BY prestecs DESC) AS posicio,
       DENSE_RANK() OVER (ORDER BY prestecs DESC) AS posicio_densa,
       ROW_NUMBER() OVER (ORDER BY prestecs DESC, titol) AS ordre
FROM recompte
ORDER BY prestecs DESC, titol;
titol prestecs posicio posicio_densa ordre
El mapa del temps 4 1 1 1
Els pilars de la Terra 3 2 2 2
Àlgebra per a impacients 1 3 3 3
La casa de les marees 1 3 3 4
L'hivern dels ocells 1 3 3 5
Quaderns de Ravenna 1 3 3 6
Rutes del delta 1 3 3 7

Les tres funcions es diferencien justament en els empats:

  • ROW_NUMBER no empata mai: numera 1, 2, 3, 4, 5, 6, 7 encara que els valors siguin iguals.
  • RANK empata i salta: els cinc llibres amb un préstec són tots "posició 3"; si hi hagués un sisè valor diferent, seria la posició 8.
  • DENSE_RANK empata i no salta: el següent valor diferent seria la posició 4.

Aquí ho deixem. Les funcions de finestra donen per a molt més —mitjanes mòbils, sumes acumulades, LAG i LEAD per comparar amb la fila anterior, marcs de finestra amb ROWS BETWEEN— i són el pa de cada dia de l'anàlisi de dades. Estan disponibles a PostgreSQL des de la versió 8.4 i a SQLite des de la 3.25.

Errors Habituals i Consells

  • Oblidar una columna al GROUP BY. A PostgreSQL dóna error; a SQLite s'executa i retorna un valor arbitrari, que és infinitament pitjor. Escriu sempre el GROUP BY complet.
  • Fer servir COUNT(*) amb un LEFT JOIN. Retorna 1 on hauria de retornar 0, perquè compta la fila plena de NULL. Fes servir COUNT(columna_de_la_taula_dreta).
  • Comptar sobre un JOIN que infla files. Si dues taules independents pengen d'una tercera, cada COUNT compta el producte cartesià. Agrega cada branca per separat.
  • Posar una condició amb agregat al WHERE. Error segur: els agregats van al HAVING.
  • Posar al HAVING una condició que no fa servir agregats. Funciona, però filtra més tard del necessari i confon qui ho llegeixi. Va al WHERE.
  • Fer servir un àlies del SELECT dins del WHERE. El SELECT s'avalua després. A ORDER BY sí que es pot.
  • Suposar que SUM de purs NULL dóna zero. Dóna NULL. Embolcalla'l en COALESCE(SUM(x), 0) si l'informe necessita un número.
  • Comparar AVG sense decidir què fer amb els NULL. AVG(x) i AVG(COALESCE(x, 0)) donen xifres diferents i totes dues poden ser correctes: la pregunta és què significa l'absència.
  • Creure que MIN(a) i MAX(b) vénen de la mateixa fila. No ho fan. Cada agregat es calcula pel seu compte.
  • Consell: quan un agregat et doni un número que no et quadri, treu les funcions i executa la consulta en mode detall amb SELECT *. Comptar les files a ull revela les explosions a l'instant.
  • Consell: escriu sempre el GROUP BY començant per la clau primària de la taula que estàs resumint (GROUP BY l.llibre_id, l.titol, no només GROUP BY l.titol). Si hi hagués dos llibres amb el mateix títol, agrupar pel títol els fondria en un.

Exercicis

Exercici 1: Recomptes i totals

  1. Quants socis hi ha a cada sucursal? Mostra el nom de la sucursal, incloses les que no en tinguessin cap.
  2. Quants llibres hi ha per idioma?
  3. Quants exemplars té cada llibre? Inclou el títol i ordena de més a menys.
  4. Quin és el recàrrec total cobrat a cada sucursal (per la sucursal de l'exemplar prestat)? Mostra 0.00 on no n'hi hagi hagut cap.

Exercici 2: Agrupar i filtrar grups

  1. Quins autors tenen més d'una obra al fons?
  2. Quins llibres tenen exemplars a tres o més sucursals diferents?
  3. Quins socis han retornat algun llibre amb retard, i quantes vegades? Ordena de més a menys.
  4. Quines sucursals tenen tres o més exemplars disponibles?

Exercici 3: Informes complets

  1. Construeix el quadre de préstecs per mes: any-mes, nombre de préstecs i recàrrec total. Pista: a PostgreSQL, TO_CHAR(data_prestec, 'YYYY-MM'); a SQLite, strftime('%Y-%m', data_prestec).
  2. Per a cada soci, mostra el seu nom, el nombre de préstecs, el nombre de reserves i el recàrrec acumulat, en una sola consulta i amb les xifres correctes. Vés amb compte amb l'explosió de files.
  3. Fent servir funcions de finestra, mostra cada préstec amb: el soci, la data, el recàrrec i el recàrrec total acumulat per aquest soci.

Solucions

Solució 1

-- 1
SELECT su.nom AS sucursal, COUNT(s.soci_id) AS socis
FROM sucursals su
LEFT JOIN socis s ON s.sucursal_id = su.sucursal_id
GROUP BY su.sucursal_id, su.nom
ORDER BY socis DESC, su.nom;
sucursal socis
Centre 4
Nord 3
Sud 2
Est 1
-- 2
SELECT idioma, COUNT(*) AS llibres FROM llibres GROUP BY idioma ORDER BY llibres DESC;
idioma llibres
es 8
ca 1
-- 3
SELECT l.titol, COUNT(e.exemplar_id) AS exemplars
FROM llibres l
LEFT JOIN exemplars e ON e.llibre_id = l.llibre_id
GROUP BY l.llibre_id, l.titol
ORDER BY exemplars DESC, l.titol;
titol exemplars
El mapa del temps 3
Àlgebra per a impacients 2
Els pilars de la Terra 2
L'hivern dels ocells 2
Manual de jardineria urbana 2
La casa de les marees 1
Memòria de l'Eixample (1904) 1
Quaderns de Ravenna 1
Rutes del delta 1
-- 4
SELECT su.nom AS sucursal, COALESCE(SUM(p.recarrec), 0) AS recarrec_total
FROM sucursals su
LEFT JOIN exemplars e ON e.sucursal_id = su.sucursal_id
LEFT JOIN prestecs  p ON p.exemplar_id = e.exemplar_id
GROUP BY su.sucursal_id, su.nom
ORDER BY recarrec_total DESC;
sucursal recarrec_total
Est 4.20
Nord 1.40
Sud 1.40
Centre 0.00

Total: 7,00 €, que coincideix amb el SUM(recarrec) global de l'apartat 1. Aquí no hi ha explosió de files, perquè les tres taules estan encadenades en línia (sucursal → exemplar → préstec), no penjant en paral·lel.

Solució 2

-- 1
SELECT a.cognoms, COUNT(*) AS obres
FROM llibres l
INNER JOIN autors a ON a.autor_id = l.autor_id
GROUP BY a.autor_id, a.cognoms
HAVING COUNT(*) > 1;
cognoms obres
Barreda 2
-- 2
SELECT l.titol, COUNT(DISTINCT e.sucursal_id) AS sucursals
FROM llibres l
INNER JOIN exemplars e ON e.llibre_id = l.llibre_id
GROUP BY l.llibre_id, l.titol
HAVING COUNT(DISTINCT e.sucursal_id) >= 3;
titol sucursals
El mapa del temps 3

El DISTINCT és imprescindible: si un llibre tingués dos exemplars a la mateixa sucursal, COUNT(e.sucursal_id) els comptaria dues vegades.

-- 3
SELECT s.nom || ' ' || s.cognoms AS soci, COUNT(*) AS retards
FROM prestecs p
INNER JOIN socis s ON s.soci_id = p.soci_id
WHERE p.data_devolucio > p.data_devolucio_prevista
GROUP BY s.soci_id, s.nom, s.cognoms
ORDER BY retards DESC, soci;
soci retards
Ivan Pereda 1
Lucía Vendrell 1
Sonia Quiroga 1

Nota: la condició va al WHERE perquè filtra files (préstecs), no grups. És el filtratge eficient.

-- 4
SELECT su.nom AS sucursal, COUNT(*) AS disponibles
FROM exemplars e
INNER JOIN sucursals su ON su.sucursal_id = e.sucursal_id
WHERE e.estat = 'disponible'
GROUP BY su.sucursal_id, su.nom
HAVING COUNT(*) >= 3
ORDER BY disponibles DESC;
sucursal disponibles
Centre 4

Solució 3

-- 1  (PostgreSQL)
SELECT TO_CHAR(data_prestec, 'YYYY-MM') AS mes,
       COUNT(*)                         AS prestecs,
       COALESCE(SUM(recarrec), 0)       AS recarrec
FROM prestecs
GROUP BY TO_CHAR(data_prestec, 'YYYY-MM')
ORDER BY mes;
-- SQLite: substitueix TO_CHAR(...) per strftime('%Y-%m', data_prestec)
mes prestecs recarrec
2026-03 3 1.40
2026-04 2 1.40
2026-05 3 4.20
2026-07 4 0.00

Els quatre préstecs de juliol són oberts, així que el seu recàrrec és NULL i SUM retorna NULL; el COALESCE el converteix en 0.00. I fixa't que el juny no hi apareix: no hi va haver cap préstec aquell mes i GROUP BY no inventa grups buits. Perquè surti amb un 0 caldria generar la sèrie de mesos i fer-hi un LEFT JOIN.

-- 2  Tres branques independents: cal agregar cadascuna per separat
WITH prest AS (
    SELECT soci_id, COUNT(*) AS prestecs, SUM(recarrec) AS recarrec
    FROM prestecs GROUP BY soci_id
),
resv AS (
    SELECT soci_id, COUNT(*) AS reserves
    FROM reserves GROUP BY soci_id
)
SELECT s.soci_id,
       s.nom || ' ' || s.cognoms AS soci,
       COALESCE(pr.prestecs, 0) AS prestecs,
       COALESCE(rv.reserves, 0) AS reserves,
       COALESCE(pr.recarrec, 0) AS recarrec
FROM socis s
LEFT JOIN prest pr ON pr.soci_id = s.soci_id
LEFT JOIN resv  rv ON rv.soci_id = s.soci_id
ORDER BY s.soci_id;
soci_id soci prestecs reserves recarrec
11 Álvaro Ferran 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 Ivan Pereda 2 1 1.40
16 Núria 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 ho haguessis resolt amb dos LEFT JOIN directes a prestecs i reserves, la Marta Alsina hauria sortit amb 3 préstecs i 3 reserves (3 × 1 = 3 files), i la Núria Bastos amb 2 i 2. L'explosió de files en estat pur.

-- 3
SELECT s.cognoms,
       p.data_prestec,
       COALESCE(p.recarrec, 0) AS recarrec,
       SUM(COALESCE(p.recarrec, 0)) OVER (PARTITION BY p.soci_id
                                          ORDER BY p.data_prestec) AS acumulat
FROM prestecs p
INNER JOIN socis s ON s.soci_id = p.soci_id
ORDER BY s.cognoms, p.data_prestec;
cognoms data_prestec recarrec acumulat
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
Ferran 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

En afegir ORDER BY dins de l'OVER, SUM deixa de ser un total i passa a ser una suma acumulada: cada fila inclou totes les anteriors de la seva partició. És el mecanisme que hi ha darrere de qualsevol gràfic d'evolució acumulada.

Conclusió

Amb aquesta lliçó BiblioRed ja pot respondre no només "què hi ha", sinó "quant n'hi ha":

  • Les funcions d'agregat COUNT, SUM, AVG, MIN i MAX condensen moltes files en un valor. Sense GROUP BY, la taula sencera és un sol grup.
  • COUNT té tres formes: COUNT(*) compta files, COUNT(columna) compta valors no nuls i COUNT(DISTINCT columna) compta valors diferents.
  • Tots els agregats ignoren els NULL llevat de COUNT(*), i SUM de purs nuls retorna NULL, no zero. Decidir què significa l'absència és una decisió de negoci, no tècnica.
  • GROUP BY parteix el resultat en grups i retorna una fila per grup; tota columna del SELECT que no sigui agregat ha de ser al GROUP BY (i SQLite no ho comprova, cosa que és un parany seriós).
  • HAVING filtra grups; WHERE filtra files. El que es pugui posar al WHERE, va al WHERE.
  • L'ordre lògic d'execucióFROMJOINWHEREGROUP BYHAVINGSELECTDISTINCTORDER BYLIMIT— explica per què WHERE no veu els àlies ni els agregats i ORDER BY sí.
  • L'agregació combinada amb JOIN produeix els informes reals: préstecs per sucursal, llibres més prestats, activitat per soci. Amb una regla d'or: amb LEFT JOIN, COUNT(columna), mai COUNT(*).
  • L'explosió de files infla els recomptes quan dues taules independents pengen d'una tercera. Es detecta perquè les xifres surten sospitosament altes i idèntiques, i es resol agregant cada branca per separat amb CTE.
  • COALESCE converteix NULL en un valor presentable i CASE WHEN (o FILTER a PostgreSQL) permet tabular diverses columnes condicionals en una sola passada.
  • Les funcions de finestra amb OVER (PARTITION BY ...) calculen agregats sense perdre el detall, i ROW_NUMBER, RANK i DENSE_RANK numeren i classifiquen amb un tractament diferent dels empats.

Ja sabem definir l'esquema, poblar-lo, consultar-lo, creuar-lo i resumir-lo. Falta la pregunta que sosté tota la resta: qui garanteix que aquestes dades continuïn sent certes d'aquí a cinc anys? Els nostres informes són fiables només perquè tots els soci_id de prestecs apunten a socis que existeixen, i tots els llibre_id d'exemplars, a llibres del catàleg.

Això ho garanteix la integritat referencial, i és el tema de la lliçó 02-06, amb la qual tanquem el mòdul: com es declaren les claus foranes, què comprova el gestor a cada INSERT, UPDATE i DELETE, què fer quan s'esborra una fila de la qual en depenen d'altres (CASCADE, RESTRICT, SET NULL…), com detectar i netejar les files òrfenes que ja existeixen, i per què SQLite no protegeix res si no li ho demanes expressament.

© Copyright 2026. Tots els drets reservats