Rozmowa techniczna na juniora BA. Kandydatka sprawnie pisze SELECT-y z WHERE, aż pada prośba: „proszę pokazać klientów, którzy złożyli co najmniej trzy zamówienia”. Długa cisza. To zadanie wykłada więcej osób niż JOIN-y, a sprawdza jedno: czy rozumiesz GROUP BY i HAVING, czy tylko je kojarzysz.
Cały tekst pracuje na jednym zestawie danych: 10 wierszy, komplet agregacji, trzy zadania z rozmów rekrutacyjnych i cztery ćwiczenia na koniec. Skrypt z danymi znajdziesz na dole.
Jedno zastrzeżenie, zanim zaczniemy. SQL to w pracy analityka biznesowego narzędzie pomocnicze, nie rdzeń zawodu - na naszym job boardzie pojawia się w 15% aktywnych ofert, mniej niż BPMN (22%) i UML (20%). Ile SQL-a realnie potrzebujesz, opisałem w tekście o SQL dla analityka biznesowego; a jeśli zapytania kręcą Cię bardziej niż wymagania i procesy, sprawdź porównanie analityka biznesowego z analitykiem danych. Tu wchodzimy w jeden konkretny klocek, za to do dna.
GROUP BY i HAVING w praktyce - z pojedynczych wierszy robimy odpowiedzi biznesowe
Baza przechowuje pojedyncze zdarzenia: to zamówienie, ta kwota, ten klient. Biznes pyta inaczej: „ile zarobiliśmy w maju?”, „którzy klienci wracają?”, „która kategoria ciągnie wynik w dół?”. GROUP BY jest mostem między jednym a drugim.
Nasza tabela to zamowienia: 10 zamówień z trzech miesięcy, czterech klientów, trzy kategorie usług. Kolumnę z miesiącem trzymam osobno (w realnej bazie policzysz ją z daty), żeby zapytania były czytelne:
| id | klient | kategoria | kwota | miesiac | kod_rabatowy |
|---|---|---|---|---|---|
| 1 | Alfatech | licencje | 12 000 | 2026-04 | WIOSNA10 |
| 2 | Borex | szkolenia | 6 000 | 2026-04 | NULL |
| 3 | Alfatech | wdrozenie | 28 000 | 2026-04 | NULL |
| 4 | Celuma | licencje | 15 000 | 2026-05 | WIOSNA10 |
| 5 | Borex | wdrozenie | 35 000 | 2026-05 | NULL |
| 6 | Alfatech | szkolenia | 8 000 | 2026-05 | NULL |
| 7 | Dratex | licencje | 9 000 | 2026-06 | LATO5 |
| 8 | Celuma | wdrozenie | 38 000 | 2026-06 | NULL |
| 9 | Alfatech | licencje | 11 000 | 2026-06 | NULL |
| 10 | Borex | szkolenia | 5 000 | 2026-06 | LATO5 |
Pierwsze pytanie biznesowe: jaki mieliśmy przychód w każdym miesiącu?
SELECT miesiac, SUM(kwota) AS przychod
FROM zamowienia
GROUP BY miesiac;
| miesiac | przychod |
|---|---|
| 2026-04 | 46 000 |
| 2026-05 | 58 000 |
| 2026-06 | 63 000 |
GROUP BY skleił wiersze o tej samej wartości miesiąca w trzy grupy, a SUM zamienił każdą grupę w jedną liczbę. Z 10 wierszy zostały 3. Cała mechanika sprowadza się do zdania: jedna grupa = jeden wiersz wyniku.
I zasada, która ratuje przed połową błędów: każda kolumna w SELECT musi albo stać w GROUP BY, albo siedzieć w funkcji agregującej. Trzeciej opcji nie ma - a co się dzieje, gdy silnik mimo wszystko przepuści takie zapytanie, pokazuję w sekcji o błędach.
Funkcje agregujące w komplecie: COUNT, SUM, AVG, MIN, MAX
Pięć funkcji pokrywa większość codziennych pytań o dane. Zamiast pięciu osobnych zapytań - jedno, per kategoria:
SELECT kategoria,
COUNT(*) AS zamowienia,
SUM(kwota) AS przychod,
AVG(kwota) AS srednia,
MIN(kwota) AS najmniejsze,
MAX(kwota) AS najwieksze
FROM zamowienia
GROUP BY kategoria;
| kategoria | zamowienia | przychod | srednia | najmniejsze | najwieksze |
|---|---|---|---|---|---|
| licencje | 4 | 47 000 | 11 750 | 9 000 | 15 000 |
| szkolenia | 3 | 19 000 | 6 333 | 5 000 | 8 000 |
| wdrozenie | 3 | 101 000 | 33 667 | 28 000 | 38 000 |
Teraz czytanie biznesowe. Wdrożenia to 101 tys. ze 167 tys. całego przychodu: trzy zamówienia robią 60% wyniku. Licencje mają najwięcej zamówień, ale średnia 11 750 zł to jedna trzecia średniej wdrożeń. MIN i MAX pokazują rozstrzał: wdrożenia siedzą stabilnie w widełkach 28-38 tys., szkolenia to drobnica 5-8 tys.
Pułapka, na której wykładają się nawet doświadczeni: COUNT(*) i COUNT(kolumna) to nie to samo.
SELECT COUNT(*) AS wszystkie,
COUNT(kod_rabatowy) AS z_rabatem
FROM zamowienia;
Wynik: 10 i 4. COUNT(*) liczy wiersze. COUNT(kod_rabatowy) liczy tylko te wiersze, w których kolumna nie jest NULL-em - u nas cztery zamówienia z kodem rabatowym. To bywa dokładnie tym, czego chcesz („ile zamówień miało rabat?”), i bywa cichym błędem, gdy liczysz „wszystkie zamówienia” po kolumnie z dziurami. Trzeci wariant: COUNT(DISTINCT klient) zwróci 4 - liczbę unikalnych klientów, nie zamówień.
HAVING - filtr na grupach (i czym różni się od WHERE)
Spróbujmy odfiltrować miesiące słabsze niż 50 tys. na czuja, przez WHERE:
-- to zapytanie NIE zadziala
SELECT miesiac, SUM(kwota) AS przychod
FROM zamowienia
WHERE SUM(kwota) > 50000
GROUP BY miesiac;
PostgreSQL odpowie: „aggregate functions are not allowed in WHERE”. I ma rację. WHERE filtruje pojedyncze wiersze, zanim jakakolwiek grupa powstanie - a skoro grup jeszcze nie ma, nie ma czego sumować. Do filtrowania grup służy HAVING, który działa dopiero PO grupowaniu.
Moim zdaniem najlepszy sposób, żeby to zapamiętać raz na zawsze (i obronić na rozmowie), to kolejność wykonania zapytania - bo różnica między WHERE a HAVING wynika z niej sama, bez wkuwania regułek:
KOLEJNOSC WYKONANIA ZAPYTANIA
1. FROM wez tabele (i wykonaj JOIN-y)
2. WHERE odfiltruj pojedyncze WIERSZE
3. GROUP BY sklej wiersze w grupy
4. HAVING odfiltruj cale GRUPY
5. SELECT policz agregaty, wybierz kolumny
6. ORDER BY posortuj wynik
7. LIMIT utnij
Zapytanie czytasz od góry, od SELECT-a, ale silnik wykonuje je w tej kolejności. Stąd druga konsekwencja, o którą lubią dopytywać rekruterzy: alias nadany w SELECT (np. AS przychod) w wielu silnikach nie działa w HAVING, bo SELECT wykonuje się później. Pytanie o kolejność wykonania regularnie wraca na rozmowach - zebrałem je razem z innymi w darmowym zestawie pytań rekrutacyjnych dla BA.
| WHERE | HAVING | |
|---|---|---|
| Co filtruje | pojedyncze wiersze | całe grupy |
| Kiedy działa | przed GROUP BY | po GROUP BY |
| Widzi agregaty (SUM, COUNT...) | nie | tak |
| Typowe użycie | zakres dat, status, kategoria | progi na sumach i licznikach |
W praktyce oba filtry najczęściej pracują razem, w jednym zapytaniu:
SELECT miesiac, SUM(kwota) AS przychod
FROM zamowienia
WHERE kategoria <> 'szkolenia' -- najpierw odpadaja WIERSZE ze szkoleniami
GROUP BY miesiac
HAVING SUM(kwota) > 40000; -- potem odpadaja GRUPY ponizej progu
Przychód bez szkoleń wynosi: kwiecień 40 tys. (odpada na HAVING), maj 50 tys., czerwiec 58 tys. W wyniku zostają dwa wiersze.
Trzy realne zadania analityka - rozwiązane krok po kroku
Zadanie 1: „Które miesiące przekroczyły 50 tys. przychodu?”
Pytanie biznesowe: controlling chce listę miesięcy powyżej progu, od którego zespół sprzedaży dostaje premię.
SELECT miesiac, SUM(kwota) AS przychod
FROM zamowienia
GROUP BY miesiac
HAVING SUM(kwota) > 50000;
Wynik: 2026-05 z 58 000 i 2026-06 z 63 000. Kwiecień (46 000) istnieje w danych, ale HAVING wyciął jego grupę. Dla biznesu: próg premiowy padł w dwóch z trzech miesięcy, trend rosnący.
Zadanie 2: „Którzy klienci złożyli co najmniej 3 zamówienia?” (klasyk rekrutacyjny)
Pytanie biznesowe: marketing szuka stałych klientów do programu lojalnościowego. Na rozmowach to zadanie pada tak często, bo w jednej linijce sprawdza, czy odróżniasz filtr na wierszach od filtru na grupach.
SELECT klient, COUNT(*) AS liczba_zamowien
FROM zamowienia
GROUP BY klient
HAVING COUNT(*) >= 3;
Wynik: Alfatech (4 zamówienia) i Borex (3). Celuma z dwoma i Dratex z jednym odpadają. Najczęstszy błąd kandydatów? Pisanie WHERE COUNT(*) >= 3 - teraz już wiesz, dlaczego silnik to odrzuci: w momencie działania WHERE żadnych grup jeszcze nie ma.
Zadanie 3: „W których kategoriach średnia wartość zamówienia spada miesiąc do miesiąca?”
Pytanie biznesowe: szef sprzedaży podejrzewa, że w części oferty klienci składają coraz drobniejsze zamówienia. Krok pierwszy: średnia per kategoria i miesiąc.
SELECT kategoria, miesiac, AVG(kwota) AS srednia
FROM zamowienia
GROUP BY kategoria, miesiac
ORDER BY kategoria, miesiac;
GROUP BY po dwóch kolumnach tworzy grupę dla każdej pary kategoria + miesiąc. Dla licencji: 12 000 w kwietniu, 15 000 w maju, 10 000 w czerwcu. Krok drugi: porównanie każdego miesiąca z poprzednim. Tu GROUP BY przestaje wystarczać - potrzebujemy dołożyć CTE i funkcję okienkową LAG:
WITH srednie AS (
SELECT kategoria, miesiac, AVG(kwota) AS srednia
FROM zamowienia
GROUP BY kategoria, miesiac
),
z_poprzednim AS (
SELECT kategoria, miesiac, srednia,
LAG(srednia) OVER (PARTITION BY kategoria ORDER BY miesiac) AS poprzednia
FROM srednie
)
SELECT kategoria, miesiac, srednia, poprzednia
FROM z_poprzednim
WHERE srednia < poprzednia;
| kategoria | miesiac | srednia | poprzednia |
|---|---|---|---|
| licencje | 2026-06 | 10 000 | 15 000 |
| szkolenia | 2026-06 | 5 000 | 8 000 |
Interpretacja: wdrożenia rosną, ale w licencjach i szkoleniach czerwcowa średnia spadła o jedną trzecią. Podejrzenie szefa sprzedaży potwierdzone i zawężone do dwóch kategorii. CTE i funkcje okienkowe to materiał na osobny tekst - tu wystarczy zapowiedź, dokąd GROUP BY prowadzi dalej.
3 błędy, które kompilują się, ale kłamią
Błąd 1: kolumna w SELECT poza GROUP BY
SELECT klient, kategoria, SUM(kwota)
FROM zamowienia
GROUP BY klient;
PostgreSQL uczciwie odrzuci to zapytanie. Ale SQLite i MySQL w luźnym trybie je wykonają - i dla Alfatechu pokażą jedną z jego trzech kategorii, wybraną arbitralnie. Raport wygląda wiarygodnie i jest zmyślony. Jeżeli po dodaniu kolumny do SELECT silnik nagle żąda jej w GROUP BY, to nie złośliwość - to pytanie „a którą wartość z grupy mam niby pokazać?”. Odpowiedz agregatem albo dodaj kolumnę do grupowania świadomie.
Błąd 2: HAVING tam, gdzie wystarczy WHERE
-- dziala, ale zle
SELECT miesiac, SUM(kwota) AS przychod
FROM zamowienia
GROUP BY miesiac
HAVING miesiac <> '2026-04';
-- lepiej
SELECT miesiac, SUM(kwota) AS przychod
FROM zamowienia
WHERE miesiac <> '2026-04'
GROUP BY miesiac;
Wynik identyczny, więc gdzie kłamstwo? W koszcie i w intencji. Wersja z HAVING każe bazie zgrupować wszystkie wiersze, także te, które zaraz wyrzuci. Na 10 wierszach różnicy nie zmierzysz, na 10 milionach już tak. Reguła: jeśli warunek nie dotyka agregatu, jego miejsce jest w WHERE.
Błąd 3: agregacja po zJOIN-owanych duplikatach
Najgroźniejszy z trójki, bo wynik wygląda zwyczajnie. Dołóżmy tabelę płatności, w której zamówienie Borexu na 35 tys. rozbito na dwie raty. JOIN zamówień z płatnościami zwróci ten wiersz zamówienia dwa razy - i SUM(kwota) policzy 70 tys. zamiast 35. Przychód „urósł” dwukrotnie, a zapytanie nie zgłosiło żadnego błędu.
Obrona jest prosta: agreguj każdą tabelę przed złączeniem (w podzapytaniu albo CTE) i porównuj sumy kontrolne przed JOIN-em i po nim. Skąd dokładnie biorą się zdublowane wiersze przy łączeniu tabel, rozbieram w tekście o SQL dla analityka biznesowego.
Przećwicz w sandboxie - 4 zadania
Czytanie o agregacjach to jeszcze nie umiejętność. Poniżej skrypt odtwarzający naszą tabelę - wklej go do dowolnej bazy albo ćwicz na platformie Analify, gdzie sandbox SQL działa w przeglądarce i niczego nie instalujesz.
CREATE TABLE zamowienia (
id INTEGER PRIMARY KEY,
klient TEXT,
kategoria TEXT,
kwota INTEGER,
miesiac TEXT,
kod_rabatowy TEXT
);
INSERT INTO zamowienia VALUES
(1, 'Alfatech', 'licencje', 12000, '2026-04', 'WIOSNA10'),
(2, 'Borex', 'szkolenia', 6000, '2026-04', NULL),
(3, 'Alfatech', 'wdrozenie', 28000, '2026-04', NULL),
(4, 'Celuma', 'licencje', 15000, '2026-05', 'WIOSNA10'),
(5, 'Borex', 'wdrozenie', 35000, '2026-05', NULL),
(6, 'Alfatech', 'szkolenia', 8000, '2026-05', NULL),
(7, 'Dratex', 'licencje', 9000, '2026-06', 'LATO5'),
(8, 'Celuma', 'wdrozenie', 38000, '2026-06', NULL),
(9, 'Alfatech', 'licencje', 11000, '2026-06', NULL),
(10, 'Borex', 'szkolenia', 5000, '2026-06', 'LATO5');
Cztery zadania, od najprostszego. Napisz zapytanie samodzielnie, zanim spojrzysz na rozwiązanie pod spodem.
Zadanie 1. Policz liczbę zamówień i łączny przychód w każdej kategorii, posortuj malejąco po przychodzie. Samokontrola: wdrożenia mają wyjść pierwsze, ze 101 000.
SELECT kategoria, COUNT(*) AS zamowienia, SUM(kwota) AS przychod
FROM zamowienia
GROUP BY kategoria
ORDER BY przychod DESC;
Zadanie 2. Pokaż klientów, których łączna wartość zamówień przekracza 40 tys. Samokontrola: trzech klientów, bez Dratexu.
SELECT klient, SUM(kwota) AS suma
FROM zamowienia
GROUP BY klient
HAVING SUM(kwota) > 40000;
Zadanie 3. Ile zamówień w każdym miesiącu miało kod rabatowy? Uwaga na NULL-e. Samokontrola: 1, 1, 2.
SELECT miesiac, COUNT(kod_rabatowy) AS z_rabatem
FROM zamowienia
GROUP BY miesiac;
Zadanie 4. Znajdź miesiące, w których średnia wartość zamówienia przekroczyła 16 tys. Samokontrola: tylko jeden miesiąc.
SELECT miesiac, AVG(kwota) AS srednia
FROM zamowienia
GROUP BY miesiac
HAVING AVG(kwota) > 16000;
Maj wygrywa ze średnią 19 333 zł - dwa duże zamówienia przy zaledwie trzech łącznie. I to jest dobre domknięcie tematu: GROUP BY odpowiada na pytanie „ile”, ale interpretacja („bo dwa duże kontrakty zawyżyły średnią”) należy już do Ciebie. Narzędzie liczy, analityk rozumie.
Chcesz sprawdzić, na ile to już siedzi? Rozwiąż darmowy test SQL - bez zakładania konta, wynik od razu.