KajoData / SQL
SQL WHERE i GROUP BY: od wierszy do sensownego raportu
Na sześciu zamówieniach pokazuję kolejność WHERE, GROUP BY i HAVING, wpływ NULL na liczniki i średnią oraz kontrolę wyniku przed wysłaniem raportu.

Kiedy zaczynasz pisać raporty w SQL, szybko pojawiają się dwa pytania: które rekordy mam wziąć i jak je podsumować? WHERE pomaga odpowiedzieć na pierwsze, GROUP BY na drugie. Ja zaczynam jeszcze krok wcześniej: ustalam, co oznacza jeden wiersz źródła i jeden wiersz wyniku. Bez tego można napisać poprawny SQL i policzyć zupełnie inną rzecz, niż zamówił odbiorca.
W tym przykładzie przygotuję zestawienie opłaconych zamówień według miasta. Pokażę też, co się zmienia po dodaniu dat, brakującej kwoty i progu dla całej grupy. Wszystkie dane są syntetyczne, a przykłady mają składnię PostgreSQL 18. Wyniki opisuję na podstawie dokumentacji i ręcznego przejścia przez sześć wierszy.
Jeżeli zapis SELECT ... FROM ... jest jeszcze nowy, zacznij od podstaw SELECT. Tutaj interesuje mnie droga od danych wejściowych do raportu. Każdy etap ma odpowiadać na konkretne pytanie, a mały zbiór pozwala zauważyć błąd bez wielogodzinnego wpatrywania się w ekran.
Najpierw znaczenie pojedynczego wiersza
Jeden wiersz tabeli orders to jedno zamówienie. order_id jednoznacznie je identyfikuje. city wskazuje miasto, status stan zamówienia, amount umowną wartość liczbową, a ordered_on datę bez godziny. Nie przypisuję tym liczbom konkretnej waluty. Chodzi o mechanizm filtrowania i agregowania, który da się sprawdzić bez dodatkowej wiedzy o rozliczeniach.
Przygotowanie tworzy tabelę tymczasową w pustym połączeniu ćwiczeniowym. Kolejne przykłady zakładają jej obecność. Jeśli uruchamiasz ćwiczenie samodzielnie, nie wklejaj poleceń tworzących tabelę do produkcyjnej bazy. Możesz też po prostu czytać tabelę i wyprowadzać wynik na kartce; do zrozumienia kolejności to wystarczy.
CREATE TEMP TABLE orders (
order_id integer PRIMARY KEY,
city text,
status text,
amount numeric,
ordered_on date
);
INSERT INTO orders VALUES
(1, 'Krakow', 'paid', 100, DATE '2026-10-01'),
(2, 'Krakow', 'paid', 50, DATE '2026-10-02'),
(3, 'Warsaw', 'paid', 80, DATE '2026-10-02'),
(4, 'Krakow', 'cancelled', 20, DATE '2026-10-03'),
(5, 'Warsaw', 'paid', NULL, DATE '2026-10-04'),
(6, 'Warsaw', 'paid', 40, DATE '2026-11-01');
Celowo zostawiłem zamówienie anulowane, listopadową datę i brak kwoty. Gdyby wszystkie wiersze były opłacone, z jednego dnia i z kompletnymi wartościami, wiele błędnych zapytań dałoby taki sam wynik jak poprawne. Dobry mały przykład powinien rozdzielać te sytuacje.
Wynik raportu będzie miał inną szczegółowość niż źródło: jeden wiersz na miasto. Nie oczekuję już pojedynczego numeru zamówienia w każdej pozycji raportu. To ważne, bo po zgrupowaniu kilka numerów źródłowych może odpowiadać jednej pozycji. Trzeba świadomie zdecydować, które informacje zachować, a które podsumować.
WHERE wybiera wiersze do analizy
Na początek wybiorę opłacone zamówienia z Krakowa. W wyniku chcę widzieć identyfikator i kwotę, żeby łatwo sprawdzić listę. Nazwa miasta w warunku musi odpowiadać zapisowi w danych; tutaj używam syntetycznej wartości Krakow, bez polskich znaków.
SELECT order_id, amount
FROM orders
WHERE city = 'Krakow'
AND status = 'paid'
ORDER BY order_id;
Oczekiwane wiersze to identyfikator 1 z kwotą 100 oraz identyfikator 2 z kwotą 50. Zamówienie 4 ma właściwe miasto, lecz status cancelled, więc odpada. Ten krok warto umieć opowiedzieć zwykłymi słowami: wybieram rekordy spełniające oba warunki.
W dokumentacji wyrażeń tabelowych PostgreSQL opisuje rolę WHERE w logicznym przetwarzaniu. Obraz „sprawdzam każdy wiersz” pomaga zrozumieć warunek, ale nie dowodzi, że silnik fizycznie czyta całą tabelę po kolei. Sposób dostępu do danych zależy od planu wykonania.
AND, OR i nawiasy
Wyobraź sobie polecenie: opłacone zamówienia z Krakowa albo Warszawy. Zapisuję warunek tak, żeby zakres alternatywy był od razu widoczny. Nawias obejmuje wybór miasta, a status dotyczy obu możliwości. Dzięki temu nie muszę prosić czytelnika o odtwarzanie priorytetów operatorów z pamięci.
SELECT order_id, city
FROM orders
WHERE status = 'paid'
AND (city = 'Krakow' OR city = 'Warsaw')
ORDER BY order_id;
W tym zbiorze przechodzą wiersze 1, 2, 3, 5 i 6. Bez nawiasu w wyrażeniu status = 'paid' AND city = 'Warsaw' OR city = 'Krakow' także anulowany Kraków spełniłby końcową alternatywę. To dobry przypadek do ręcznej kontroli, bo jeden rekord pokazuje różnicę między intencją a zapisem.
Przy dłuższych filtrach wypisuję składniki od nowych linii i czytam je jak wymaganie. Jeżeli w zdaniu pojawia się „oraz jedno z dwóch”, w kodzie zwykle chcę widzieć odpowiadający temu nawias. Koszt kilku dodatkowych znaków jest mały w porównaniu z wyjaśnianiem, dlaczego raport zawierał niewłaściwe zamówienia.
Daty: ustal początek i koniec okresu
Teraz wybieram opłacone zamówienia z października. Używam początku miesiąca włącznie i początku kolejnego miesiąca wyłącznie. Kolumna ma tutaj typ date, a literały dat zapisuję jawnie, zgodnie ze składnią wybranego silnika.
SELECT order_id, city, amount
FROM orders
WHERE status = 'paid'
AND ordered_on >= DATE '2026-10-01'
AND ordered_on < DATE '2026-11-01'
ORDER BY order_id;
Zostają rekordy 1, 2, 3 i 5. Listopadowe zamówienie 6 odpada dokładnie na górnej granicy. Taki zapis pomaga później przejść do danych zawierających godziny, ale wtedy trzeba dodatkowo ustalić typ czasu i strefę. Ten przykład rozstrzyga wyłącznie przypadek dat bez godziny.
Operator BETWEEN obejmuje oba końce przedziału, co potwierdza dokumentacja porównań. Nie jest automatycznie błędny, lecz wymaga dopasowania granic do pytania. Ja wolę spójny układ kolejnych okresów: koniec jednego jest początkiem drugiego i należy już do następnego raportu.
GROUP BY zmienia szczegółowość
Z opłaconych październikowych zamówień zrobię podsumowanie miast. GROUP BY city buduje grupy według miasta, a funkcje agregujące opisują rekordy w każdej grupie. W SELECT zostawiam miasto oraz obliczenia. Wynik ma być czytelny nawet dla osoby, która nie oglądała zapytania źródłowego.
SELECT
city,
COUNT(*) AS orders_count,
COUNT(amount) AS known_amounts,
SUM(amount) AS total_amount,
AVG(amount) AS average_known_amount
FROM orders
WHERE status = 'paid'
AND ordered_on >= DATE '2026-10-01'
AND ordered_on < DATE '2026-11-01'
GROUP BY city
ORDER BY city;
| Miasto | Zamówienia | Znane kwoty | Suma | Średnia znanych kwot |
|---|---|---|---|---|
| Krakow | 2 | 2 | 150 | 75 |
| Warsaw | 2 | 1 | 80 | 80 |
Dla Krakowa widzę 100 i 50, więc suma wynosi 150, a średnia 75. Dla Warszawy są dwa zamówienia, lecz tylko jedna znana kwota: 80. To celowo nie jest średnia 40. Jeżeli nie wytłumaczysz, jaki mianownik przyjął raport, odbiorca może rozsądnie odczytać tę samą liczbę w zupełnie inny sposób.
NULL, zero i trzy różne liczniki
Według dokumentacji agregatów COUNT(*) liczy wiersze, COUNT(amount) wartości niebędące NULL, a AVG uwzględnia znane wartości liczbowe. Brak kwoty nie staje się automatycznie zerem. Zero, jeśli występuje jako wartość, bierze udział w średniej.
Ja pokazuję oba liczniki obok sumy. Dzięki temu Warszawa z jedną brakującą kwotą nie wygląda jak kompletny zestaw dwóch obserwacji. Czy brak jest dopuszczalny, trzeba ustalić biznesowo. Samo zapytanie nie rozstrzygnie, czy zamówienie czeka na wycenę, czy eksport zgubił dane.
Do wyszukania braków użyj amount IS NULL. Porównanie amount = NULL nie jest testem braku. Przy filtrze porównawczym takim jak amount > 0 wiersz z NULL nie przejdzie, ponieważ WHERE zatrzymuje warunek prawdziwy. Jeżeli raport ma osobno wykazywać braki, zapisz ten wymóg jawnie.
Dla agregacji bez pasujących rekordów trzeba też rozróżnić brak grupy od grupy z brakującymi kwotami. Raport według miasta nie utworzy sam pozycji dla miasta, którego nie ma po filtrze. Uzgodnij z odbiorcą, czy potrzebuje tylko obserwowanych miast, czy pełnej listy z osobnym statusem braku danych.

HAVING sprawdza wynik grupy
Załóżmy, że chcesz pokazać tylko miasta, których suma opłaconych październikowych zamówień przekracza 100. To warunek na całą grupę. Umieszczam go w HAVING, zachowując wcześniejszy filtr statusu i dat.
SELECT city, SUM(amount) AS total_amount
FROM orders
WHERE status = 'paid'
AND ordered_on >= DATE '2026-10-01'
AND ordered_on < DATE '2026-11-01'
GROUP BY city
HAVING SUM(amount) > 100
ORDER BY total_amount DESC, city;
Zostaje Kraków z sumą 150. Warszawa ma sumę znanych kwot 80 i nie spełnia progu. Przeniesienie warunku do WHERE amount > 100 zmieniłoby pytanie: wybierałbyś pojedyncze duże zamówienia przed zsumowaniem. W naszym październikowym zbiorze żadne zamówienie nie przekracza 100, więc wynik byłby pusty.
To porównanie jest ważniejsze niż zapamiętanie definicji. Zanim wybiorę klauzulę, pytam: próg ma dotyczyć rekordu czy zbioru rekordów? Jeżeli odpowiedź brzmi „łącznego wyniku miasta”, chcę sprawdzić agregat. Szczegóły GROUP BY i HAVING opisuje wspomniana dokumentacja wyrażeń tabelowych.
Grupowanie po dwóch kolumnach
Jeżeli dodasz status do GROUP BY, jeden wiersz wyniku będzie oznaczał parę miasto i status. Bez filtra na październik i płatność Kraków ma dwie grupy: paid z dwoma rekordami oraz cancelled z jednym. Warszawa ma jedną grupę paid z trzema rekordami.
SELECT city, status, COUNT(*) AS orders_count
FROM orders
GROUP BY city, status
ORDER BY city, status;
Nie powstaje automatycznie warszawska grupa cancelled z zerem. W źródle nie ma takiej kombinacji. To częsta różnica pomiędzy raportem z danych a tabelą oczekiwaną przez biznes, który chce widzieć wszystkie możliwe warianty. Dopełnienie brakujących kombinacji jest osobnym zadaniem, wymagającym listy miast i statusów.
PostgreSQL dopuszcza grupowanie przez numery pozycji SELECT, opisane w dokumentacji SELECT. Na początku używam jednak nazw city, status. Są odporniejsze na przestawienie kolumn i łatwiejsze do przeglądu. Nie traktuj zapisu GROUP BY 1, 2 jako gwarantowanej składni każdego silnika SQL.
Kolejność wyniku i czytelność zapytania
GROUP BY nie jest obietnicą sortowania. Jeśli raport ma zaczynać się od największej sumy, dopisz ORDER BY. Dokumentacja sortowania wyjaśnia też kolejne klucze rozstrzygające remisy. Ja dodaję miasto po sumie, żeby ustalić kolejność równych wyników w tym małym raporcie.
Nazwy wynikowych kolumn powinny mówić, co zostało policzone. average_known_amount jest dłuższe od avg, lecz przypomina, że brakujące kwoty nie trafiły do mianownika. W raporcie dla odbiorcy można pokazać przyjaźniejszą etykietę. Ważne, żeby opis miary przetrwał przeniesienie wyniku do dashboardu albo arkusza.
Nie dodaję przypadkowej kolumny do SELECT, gdy nie ma jasnego znaczenia dla grupy. Numer zamówienia obok sumy miasta wymagałby odpowiedzi: którego zamówienia? Jeśli potrzebujesz listy szczegółów i sumy jednocześnie, rozważ osobny raport szczegółowy albo dalszą naukę funkcji okna. Unikniesz pozornego skrótu, który zaciemnia wynik.
Ćwiczenie i kontrola przed oddaniem raportu
Przed wysłaniem zestawienia zapisuję trzy rzeczy: definicję wiersza źródła, warunki włączenia oraz definicję wiersza wyniku. Następnie wybieram jedno miasto i odtwarzam jego licznik oraz sumę z listy szczegółowej. Dzięki temu sprawdzam sens transformacji, a nie tylko brak komunikatu błędu.
W ćwiczeniu zmień warunek HAVING na COUNT(*) >= 2. Dla opłaconych październikowych zamówień powinny zostać oba miasta. Potem porównaj go z COUNT(amount) >= 2: zostanie tylko Kraków. Zapisz własnymi słowami, dlaczego zmiana jednego argumentu licznika zmienia decyzję o całej grupie.
Dodaj też pytanie o listopad. Czy po rozszerzeniu zakresu Warszawa ma większą sumę, czy większą liczbę znanych kwot, czy obie wartości? Z tabeli wejściowej widać dodatkowe 40 i jedno zamówienie. Taki ręczny punkt odniesienia pomaga ocenić, czy nowa wersja raportu odpowiada na zmieniony zakres.
Przy prawdziwych danych sprawdź jeszcze duplikaty identyfikatora, regułę przypisania miasta i kompletność źródła. Jeżeli przed grupowaniem dołączasz inne tabele, upewnij się, że nie mnożysz zamówień. Sam GROUP BY może schować nadmiar wierszy w jednej eleganckiej sumie. Ładna tabela wynikowa nie zwalnia z kontroli danych, które do niej weszły.
Uzgodnij sumę z listą szczegółową
Dla opłaconych zamówień październikowych lista szczegółowa zawiera cztery rekordy. W podsumowaniu miasta mają po dwa zamówienia, więc liczniki powinny odtworzyć tę samą liczbę. Znane kwoty wynoszą 100, 50 oraz 80, a ich suma to 230. Suma dwóch pozycji raportu, 150 i 80, również powinna dawać 230. To dwa proste punkty kontrolne do zapisania obok ćwiczenia.
Nie każda miara daje się jednak tak połączyć. Średnich miast nie dodajemy i nie liczymy ich zwykłej średniej, jeśli chcemy otrzymać średnią ze wszystkich znanych kwot. Kraków wnosi dwie obserwacje, Warszawa jedną. Równe potraktowanie obu średnich dałoby miastom równe znaczenie, chociaż liczba kwot jest różna. Najpierw ustal, czy pytasz o przeciętne miasto, czy przeciętne zamówienie ze znaną kwotą.
Podobny kłopot pojawia się przy liczbie unikalnych klientów. Jeżeli klient zamawiał w dwóch miastach, suma liczników unikalnych klientów z miast może policzyć go dwukrotnie. W tym ćwiczeniu nie ma kolumny klienta, dlatego nie dopisuję takiej miary do gotowej tabeli. Przy rozszerzeniu raportu potrzebujesz najpierw danych oraz definicji identyfikatora, który rzeczywiście reprezentuje klienta.
Taką kontrolę robię po każdej istotnej zmianie zakresu. Dodanie nowego filtra powinno dać się wyjaśnić przez konkretne rekordy. Jeżeli suma rośnie po zawężeniu tego samego zbioru dodatnich kwot, szukam różnicy w zapytaniu albo w danych. Nie tłumaczę jej automatycznie tym, że baza „tak policzyła”. To sygnał do powrotu do listy szczegółowej.
Pusty wynik też wymaga opisu
Ustaw w ćwiczeniu rok, dla którego nie ma zamówień. Zapytanie grupujące według miasta nie pokaże żadnej pozycji. Osoba otrzymująca raport musi wiedzieć, czy oznacza to brak zdarzeń, brak dostarczonego pliku czy niewłaściwy filtr. Te trzy sytuacje mogą wyglądać identycznie na ekranie, a wymagają innych działań.
W raporcie cyklicznym zapisuję okres oraz moment dostępności źródła obok wyniku. Dzięki temu pustej tabeli nie trzeba interpretować na podstawie pamięci autora. Jeżeli odbiorca oczekuje także miast bez zdarzeń, potrzebujesz listy referencyjnej. Sama agregacja po zaobserwowanych rekordach nie zna wszystkich miast, które powinny istnieć w organizacji.
Warto też prześledzić brak wszystkich kwot w pojedynczej grupie. Liczba zamówień może być dodatnia, a liczba znanych kwot równa zero. W takim przypadku odpowiedź o łącznej wartości pozostaje niepełna. Dopisanie zera bez uzgodnionej reguły zmieniłoby sens danych: „nie znamy kwoty” zaczęłoby wyglądać jak „znamy kwotę i wynosi zero”.
Przy odbiorze raportu zadaję więc pytanie o działania. Co ma zrobić osoba, która widzi brak danych albo niepełną grupę? Czy ma czekać na kolejny eksport, zgłosić problem czy skontaktować się z właścicielem procesu? Krótki opis takiego przypadku jest często bardziej przydatny niż kolejna kolumna techniczna. Pozwala korzystać z wyniku bez przypisywania mu pewności, której źródło nie dostarcza.
Wersję oddawaną odbiorcy opisuję także datą zakresu, a nie samym tytułem „zamówienia”. Październik i październik z dołączonym listopadem to różne raporty, choć mają identyczny zestaw kolumn. Przy porównywaniu dwóch eksportów sprawdzam najpierw filtry oraz kompletność źródła. Dopiero później interpretuję różnicę sum jako zmianę zachowania klientów. Taka kolejność chroni przed wyciąganiem wniosków z przypadkowo zmienionego zakresu.
Powiązany film
Zostawiam polski materiał o filtrowaniu i grupowaniu. Do przećwiczenia aktualnego tekstu wystarczy sześć zamówień powyżej; nie potrzebujesz dawnych zrzutów ekranu ani dodatkowego pliku. Gdy umiesz obronić każdy wiersz wyniku, dopiero wtedy warto zwiększać skalę.
Najważniejsze w pigułce
Co działa wcześniej: WHERE czy GROUP BY?
W logicznym przetwarzaniu WHERE wybiera wiersze wejściowe, a GROUP BY buduje z nich grupy. To model znaczenia zapytania; fizyczny plan może korzystać z optymalizacji.
Czym HAVING różni się od WHERE?
WHERE ogranicza wiersze przed agregacją. HAVING ogranicza grupy, na przykład zostawia tylko miasta z SUM(amount) większym od ustalonego progu.
Czy GROUP BY sortuje wynik?
Nie daje takiej gwarancji. Jeśli potrzebujesz określonej kolejności, użyj ORDER BY i określ sposób rozstrzygania remisów.
Przy pracy nad artykułem użyto w zautomatyzowany sposób sztucznej inteligencji. Jeśli też chcesz tak umieć, sprawdź ten projekt.
