Błąd GROUP BY w PostgreSQL: o co właściwie pyta baza
Dopisanie kolumny do GROUP BY usuwa komunikat, ale zmienia pytanie. Na tych samych danych zapytanie gubi klientkę, która wydała 590.
Trzech klientów, osiem zamówień i jedno pytanie: ile wydał każdy klient i co kupował. Pierwsza wersja zapytania wygląda rozsądnie:
SELECT customer_id, product, sum(amount) AS total
FROM orders
GROUP BY customer_id;
PostgreSQL 17 odpowiada tak:
ERROR: column "orders.product" must appear in the GROUP BY clause or be used in an aggregate function
LINE 1: SELECT customer_id, product, sum(amount) AS total FROM order...
^
To najczęstszy błąd GROUP BY, na jaki trafia ktoś, kto dopiero poznaje
agregację. Daszek wskazuje kolumnę product, a komunikat podsuwa dwa wyjścia.
Pierwsze z nich, dopisanie kolumny do GROUP BY, sprawia, że błąd znika.
Właśnie dlatego jest groźne.
Co zgłasza PostgreSQL
GROUP BY zamienia wiele wierszy w jeden wiersz na grupę. Klient numer 1 ma
trzy zamówienia, więc w wyniku dostaje jeden wiersz, a w nim jedno pole
total. Z sumą nie ma kłopotu: trzy kwoty dodane do siebie dają jedną liczbę.
Z kolumną product jest inaczej.
Te trzy zamówienia to klawiatura, myszka i druga klawiatura. Baza ma wpisać do jednego pola jedną nazwę produktu i nie wie, którą. Mogłaby wybrać dowolną, ale wtedy wynik zależałby od kolejności, w jakiej wiersze leżą na dysku, a takiego wyniku nie da się powtórzyć. PostgreSQL woli odmówić.
Po przełączeniu psql w tryb \set VERBOSITY verbose ten sam błąd pokazuje
też swój kod i miejsce w kodzie źródłowym serwera:
ERROR: 42803: column "orders.product" must appear in the GROUP BY clause or be used in an aggregate function
LOCATION: check_ungrouped_columns_walker, parse_agg.c:1492
SQLSTATE 42803 to grouping_error. Błąd powstaje na etapie analizy
zapytania, zanim serwer przeczyta choćby jeden wiersz, więc pusta tabela
zgłosi go tak samo jak pełna. Treść komunikatu jest po angielsku, bo
standardowy obraz Dockera postgres:17 nie ma polskiej lokalizacji. Na
serwerze z polskimi komunikatami brzmi inaczej, ale kod zostaje ten sam.
Ta sama reguła obejmuje ORDER BY. Zapytanie, które w SELECT ma już tylko klienta i sumę, ale sortuje po produkcie, kończy się tym samym błędem, a daszek wskazuje wtedy koniec linii:
ERROR: column "orders.product" must appear in the GROUP BY clause or be used in an aggregate function
LINE 1: ... AS total FROM orders GROUP BY customer_id ORDER BY product;
^
Sortowanie grup po produkcie ma ten sam problem co wyświetlanie produktu: grupa ma ich kilka, więc nie wiadomo, po którym ją ustawić.
WHERE tej reguły nie zna, bo działa wcześniej. Filtruje pojedyncze wiersze, zanim powstaną grupy, więc może używać dowolnej kolumny tabeli. Zapytanie, które sumuje wydatki klientów tylko na myszki, przechodzi bez błędu i zwraca dwóch klientów, po 90 każdy. Odwrotny kierunek nie działa. Suma istnieje dopiero po zgrupowaniu, więc warunek na sumie w WHERE kończy się błędem z tym samym kodem SQLSTATE:
ERROR: 42803: aggregate functions are not allowed in WHERE
LINE 1: SELECT customer_id, sum(amount) FROM orders WHERE sum(amount...
^
Oba komunikaty opisują jedną kolejność. Najpierw WHERE wybiera wiersze, potem GROUP BY składa je w grupy, a dopiero SELECT, HAVING i ORDER BY widzą wynik grupowania. Kto ma tę kolejność w głowie, rzadko trafia na którykolwiek z nich.
Komunikat nie mówi zatem „brakuje słowa w składni”. Mówi: dla tej kolumny w jednej grupie jest kilka wartości, a Ty prosisz o jedną.
Jak to wygląda w bazie, która nie odmawia
Łatwo uznać ten błąd za nadgorliwość PostgreSQL. SQLite pokazuje, co dzieje się bez niego. To samo zapytanie na tych samych danych w SQLite 3.50.4 przechodzi bez słowa:
(1, 'keyboard', 590)
(2, 'monitor', 1125)
(3, 'mouse', 425)
Wygląda na wynik. Teraz te same osiem zamówień wstawione w odwrotnej kolejności i to samo zapytanie:
(1, 'keyboard', 590)
(2, 'cable', 1125)
(3, 'cable', 425)
Klient numer 2 kupił raz monitor, a raz kabel. SQLite wziął do kolumny
product wartość z któregoś wiersza grupy, a z którego, zależało od
kolejności, w jakiej przeglądał tabelę. Kolejność wstawiania zmieniła raport.
Sumy się zgadzają, nazwy produktów nie znaczą nic.
Odruchowa poprawka i siedem wierszy zamiast trzech
Najkrótsza droga do zapytania bez błędu to zrobić to, co podpowiada pierwsza połowa komunikatu:
SELECT customer_id, product, sum(amount) AS total
FROM orders
GROUP BY customer_id, product;
Wynik:
customer_id | product | total
-------------+----------+---------
1 | keyboard | 500.00
1 | mouse | 90.00
2 | cable | 25.00
2 | monitor | 1100.00
3 | cable | 25.00
3 | headset | 310.00
3 | mouse | 90.00
(7 rows)
Komunikat zniknął, a razem z nim pytanie, które zadałeś. Grupą nie jest już klient, tylko para klient i produkt. Zapytanie liczy teraz wydatki klienta osobno na każdy produkt. Trzy wiersze zamieniły się w siedem.
Na krótkiej liście widać to od razu. Gorzej, gdy wynik idzie dalej, na przykład do filtra. Załóżmy, że chcesz klientów, którzy wydali w sumie więcej niż 500. Wersja z grupowaniem po kliencie:
SELECT customer_id, sum(amount) AS total
FROM orders
GROUP BY customer_id
HAVING sum(amount) > 500;
customer_id | total
-------------+---------
1 | 590.00
2 | 1125.00
(2 rows)
Ten sam filtr na zapytaniu po odruchowej poprawce:
customer_id | product | total
-------------+---------+---------
2 | monitor | 1100.00
(1 row)
Klientka numer 1, Anna, wydała 590 i z listy wypadła. Na żaden pojedynczy produkt nie wydała więcej niż 500, a HAVING sprawdza teraz sumę dla pary klient i produkt, nie dla klienta. Nie ma błędu ani ostrzeżenia. Jest tylko lista, która wygląda wiarygodnie i jest błędna.
Błąd GROUP BY zatrzymuje zapytanie, zanim zwróci cokolwiek. Źle dobrana grupa przechodzi i zwraca zły wynik, który ktoś potem wkleja do arkusza.
Ten sam odruch psuje raporty miesięczne. Zamówienia mają kolumnę
ordered_at, a pytanie brzmi: ile sprzedaliśmy w każdym miesiącu. Grupowanie
po date_trunc('month', ordered_at) jest poprawne, ale jeśli w SELECT zostanie
surowa data, wraca znajomy komunikat:
ERROR: column "orders.ordered_at" must appear in the GROUP BY clause or be used in an aggregate function
Dopisanie ordered_at do GROUP BY daje osiem wierszy, po jednym na każdy dzień
z zamówieniem, bo każde zamówienie ma inną datę. Raport miesięczny zamienia
się w dzienny. Bez surowej daty zostają trzy miesiące:
month | orders | total
------------+--------+---------
2026-09-01 | 3 | 590.00
2026-10-01 | 3 | 1215.00
2026-11-01 | 2 | 335.00
(3 rows)
Którą wartość chciałeś dostać
Poprawka zależy od tego, co miała zawierać kolumna product. Odpowiedzi są
cztery i każda prowadzi do innego zapytania.
Wszystkie produkty klienta w jednym polu. Wtedy kolumna też potrzebuje
funkcji agregującej, tak jak amount. string_agg skleja wartości w napis:
SELECT customer_id,
string_agg(product, ', ' ORDER BY product) AS products,
sum(amount) AS total
FROM orders
GROUP BY customer_id;
customer_id | products | total
-------------+---------------------------+---------
1 | keyboard, keyboard, mouse | 590.00
2 | cable, monitor | 1125.00
3 | cable, headset, mouse | 425.00
(3 rows)
Trzy wiersze, tak jak w pytaniu.
Uwaga na max(product), który też ucisza błąd. Dla Anny zwraca mouse, bo
„mouse” jest ostatnie alfabetycznie, choć najdroższą rzeczą w jej koszyku była
klawiatura. Pytanie o najdroższy zakup wymaga innego narzędzia niż funkcja
agregująca.
Produkt z największego zamówienia. To pytanie o jeden konkretny wiersz
z każdej grupy, a do tego PostgreSQL ma DISTINCT ON. Klauzula zostawia
pierwszy wiersz każdej grupy według podanej kolejności:
SELECT DISTINCT ON (customer_id) customer_id, product, amount
FROM orders
ORDER BY customer_id, amount DESC;
customer_id | product | amount
-------------+----------+---------
1 | keyboard | 250.00
2 | monitor | 1100.00
3 | headset | 310.00
(3 rows)
Tym razem Anna dostaje klawiaturę, bo to ona była najdroższa.
Anna kupiła jednak dwie klawiatury po 250, więc dwa jej wiersze mają tę samą
kwotę. DISTINCT ON weźmie jeden z nich i przy remisie nie gwarantuje, który.
Jeśli wynik ma być powtarzalny, dopisz do ORDER BY kolumnę rozstrzygającą,
na przykład id.
Dane o kliencie, nie o zamówieniu. To najczęstszy przypadek w praktyce:
chcesz obok sumy pokazać imię i miasto z tabeli customers. Tu PostgreSQL ma
regułę, którą łatwo przeoczyć. Jeśli grupujesz po kluczu głównym
tabeli, możesz wybierać pozostałe jej kolumny bez dopisywania ich do GROUP BY,
bo klucz główny wyznacza je jednoznacznie:
SELECT c.id, c.name, c.city, sum(o.amount) AS total
FROM customers c
JOIN orders o ON o.customer_id = c.id
GROUP BY c.id;
id | name | city | total
----+--------+--------+---------
1 | Anna | Kraków | 590.00
2 | Bartek | Gdańsk | 1125.00
3 | Celina | Poznań | 425.00
(3 rows)
Wystarczy grupować po imieniu zamiast po c.id, żeby błąd wrócił:
ERROR: column "c.city" must appear in the GROUP BY clause or be used in an aggregate function
Imię nie jest kluczem. Dwie Anny z różnych miast to w tej tabeli całkiem możliwy stan, a baza nie zgaduje.
Reguła dotyczy klucza głównego i tylko jego. Po dodaniu do tabeli customers
kolumny email z ograniczeniem UNIQUE NOT NULL adres też wyznacza klienta
jednoznacznie, a mimo to grupowanie po nim nie wystarcza:
ERROR: column "c.name" must appear in the GROUP BY clause or be used in an aggregate function
LINE 1: SELECT c.email, c.name, sum(o.amount) AS total FROM customer...
^
PostgreSQL rozpoznaje zależność funkcyjną wyłącznie od klucza głównego. Gdy grupujesz po czymś innym, a potrzebujesz kolumn klienta, zostaje albo dopisać je do GROUP BY świadomie, wiedząc, że niczego nie rozbiją, albo policzyć sumy w podzapytaniu i dołączyć do nich klientów:
SELECT c.name, c.city, t.total
FROM customers c
JOIN (SELECT customer_id, sum(amount) AS total
FROM orders
GROUP BY customer_id) t ON t.customer_id = c.id;
Wynik to te same trzy wiersze z imieniem, miastem i sumą. Podzapytanie robi jedną rzecz, czyli liczy, a zewnętrzne zapytanie dokleja opis. Przy dłuższych raportach taki podział czyta się łatwiej niż jeden GROUP BY z dziesięcioma kolumnami, z których połowa jest tam tylko po to, żeby uciszyć parser.
Każde zamówienie osobno, z sumą klienta obok. Jeśli chcesz zachować wszystkie wiersze, GROUP BY jest złym narzędziem, bo jego zadaniem jest zwijanie wierszy. Funkcja okna liczy sumę dla grupy i nie zwija niczego:
SELECT customer_id, product, amount,
sum(amount) OVER (PARTITION BY customer_id) AS customer_total
FROM orders;
customer_id | product | amount | customer_total
-------------+----------+---------+----------------
1 | keyboard | 250.00 | 590.00
1 | mouse | 90.00 | 590.00
1 | keyboard | 250.00 | 590.00
2 | monitor | 1100.00 | 1125.00
2 | cable | 25.00 | 1125.00
3 | mouse | 90.00 | 425.00
3 | headset | 310.00 | 425.00
3 | cable | 25.00 | 425.00
(8 rows)
Osiem wierszy, czyli wszystkie zamówienia, i przy każdym poprawna suma
klienta. Kolumna product nie musi tu stać w żadnej klauzuli, bo żaden wiersz
nie został z niczym połączony i każdy zachowuje własne wartości.
Jedno pytanie przed dopisaniem kolumny
Zanim dopiszesz kolumnę do GROUP BY, nazwij jednym zdaniem, czym ma być jeden
wiersz wyniku. „Jeden wiersz to jeden klient” oznacza, że w GROUP BY stoi
klient, a każda inna kolumna jest albo agregatem, albo zależy od klucza
klienta. „Jeden wiersz to jedno zamówienie” oznacza funkcję okna, a „jeden
wiersz to wybrane zamówienie klienta” oznacza DISTINCT ON. Dopisanie kolumny
do GROUP BY jest poprawne tylko wtedy, gdy ta kolumna należy do odpowiedzi na
pytanie, czym jest jeden wiersz, a nie wtedy, gdy jej brak blokuje wykonanie
zapytania.
Agregacja i funkcje okna to w programie kursu DevJourney for SQL dwa osobne rozdziały, „Agregacja” i „Funkcje okna”. Zapytania z obu piszesz na PostgreSQL 17 wbudowanym w aplikację, na bazie, która ma już dane.