Baza wiedzy / SQL
SUM w SQL: poprawne sumy, NULL, HAVING i duplikaty
Jak poprawnie sumować dane w SQL: grupowanie, HAVING, NULL i puste zbiory, duplikaty po JOIN oraz sumy narastające. Przykłady PostgreSQL i SQLite.

SUM dodaje wartości, ale poprawny wynik zaczyna się wcześniej: od ustalenia, co oznacza jeden wiersz. Jeśli po złączeniu jedno zamówienie pojawia się dwa razy, funkcja sumująca grzecznie policzy je dwa razy. Jeśli kwota jest nieznana, samo wyświetlenie zera nie sprawi, że ją poznasz.
Pokażę Ci podstawową sumę, grupowanie, filtrowanie, braki i sumę narastającą na małych danych syntetycznych. Wspólne przykłady wykonałem w PostgreSQL 17.11 i SQLite 3.53.4. Osobny blok z pg_typeof dotyczy wyłącznie PostgreSQL. Nie przenoszę tych wyników automatycznie na SQL Server, MySQL ani inne silniki.
Dane i pierwsza suma
Uruchom poniższy blok raz w pustej sesji testowej. Tworzy wyłącznie tabele tymczasowe, które znikają po zakończeniu sesji. Kolejne zapytania wykonuj w tym samym połączeniu. Nazwy i dane są identyczne w artykule PL oraz EN.
sales zawiera trzy pozycje sprzedaży. quantity to liczba sztuk, a unit_price jest ceną jednostkową w umownych całych jednostkach. Nie ma tu rabatów, podatków, zwrotów ani wielu walut. Druga tabela, events, to niezależne zdarzenia z wartością amount; jeden wpis nie ma znanej wartości. Tekstowe daty ISO służą tylko do prostego porządkowania przykładu, nie do obliczania czasu.
-- example: fixture
CREATE TEMP TABLE sales (
id INTEGER PRIMARY KEY,
product TEXT,
quantity INTEGER,
unit_price INTEGER
);
INSERT INTO sales VALUES
(1, 'Phone', 2, 500),
(2, 'Laptop', 1, 1200),
(3, 'Tablet', 3, 300);
CREATE TEMP TABLE events (
id INTEGER PRIMARY KEY,
segment TEXT,
event_day TEXT,
amount INTEGER
);
INSERT INTO events VALUES
(1, 'A', '2026-01-01', 200),
(2, 'A', '2026-01-01', NULL),
(3, 'B', '2026-01-02', 500),
(4, 'B', '2026-01-02', 200);
Najpierw mnożę liczbę sztuk przez cenę dla każdego wiersza, a dopiero potem dodaję wyniki:
-- example: total
SELECT SUM(quantity * unit_price) AS total
FROM sales;
| total |
|---|
| 3100 |
Rachunek kontrolny to 2 × 500 + 1 × 1200 + 3 × 300 = 3100. Tę prostą kontrolę warto robić ręcznie na małym wycinku danych. Wynik 2900 nie wynika z podanej tabeli.
SUM(quantity * unit_price) i SUM(quantity) * SUM(unit_price) odpowiadają na różne pytania. Pierwsza forma sumuje wartości pozycji; druga mnoży dwie niezależne sumy i na tych danych daje 12000. Nie zastępuję jednej drugą w ramach „uproszczenia” zapytania.
W prawdziwym raporcie doprecyzuj jednostkę i poziom danych. Jeśli tabela przechowuje pozycje zamówienia, kwota całego zamówienia skopiowana do każdej pozycji nie jest bezpieczną kolumną do sumowania. SQL nie rozpozna tej pomyłki po nazwie pola. To ja muszę sprawdzić, jaka wielkość występuje raz, a jaka powtarza się w wielu rekordach.
GROUP BY, WHERE i HAVING
GROUP BY dzieli rekordy na grupy i daje jedną sumę na produkt:
-- example: groups
SELECT product, SUM(quantity * unit_price) AS total
FROM sales
GROUP BY product
ORDER BY product;
| product | total |
|---|---|
| Laptop | 1200 |
| Phone | 1000 |
| Tablet | 900 |
Jawne ORDER BY ustala kolejność wyniku. Bez niego nie zakładam, że grupy pojawią się zawsze tak samo. W tym małym zbiorze każdy produkt ma jedną pozycję, ale zapytanie zsumuje także wiele pozycji tego samego produktu.
Jeżeli interesują mnie produkty z sumą większą niż 1000, filtruję grupy:
-- example: having
SELECT product, SUM(quantity * unit_price) AS total
FROM sales
GROUP BY product
HAVING SUM(quantity * unit_price) > 1000
ORDER BY product;
| product | total |
|---|---|
| Laptop | 1200 |
Przechodzi tylko Laptop. Phone ma dokładnie 1000, a warunek to > 1000, nie >= 1000. Błąd na granicy progu łatwo przeoczyć, gdy wynik wygląda wiarygodnie.
W HAVING powtarzam wyrażenie agregujące. PostgreSQL nie pozwala użyć w tym miejscu aliasu total utworzonego w tej samej liście SELECT. Inne silniki mogą dopuszczać taki skrót; nie jest to wystarczający powód, by opisywać go jako przenośny SQL. Reguły aliasów opisuje dokumentacja SELECT.
WHERE wykonuje inne zadanie: wybiera wiersze, które wejdą do agregacji.
-- example: where
SELECT SUM(quantity * unit_price) AS total
FROM sales
WHERE quantity >= 2;
| total |
|---|
| 1900 |
Zostają Phone i Tablet, bo obie pozycje mają co najmniej dwie sztuki. Dostajemy 1000 + 900 = 1900. Przeniesienie warunku z WHERE do HAVING bez zastanowienia może zmienić pytanie biznesowe. Filtr pojedynczych pozycji i filtr sumy grupy nie są zamienne.
NULL, pusty zbiór i COALESCE
Teraz korzystam z events:
-- example: nulls
SELECT COUNT(*) AS rows_count,
COUNT(amount) AS known_count,
SUM(amount) AS total
FROM events;
| rows_count | known_count | total |
|---|---|---|
| 4 | 3 | 900 |
Są cztery zdarzenia, ale tylko trzy znane wartości. SUM pomija NULL, więc wynik 900 opisuje sumę znanych wartości. Nie oznacza, że brakujący wpis miał zero. Dlatego obok sumy pokazuję COUNT(*) i COUNT(amount). O różnicy między tymi licznikami przeczytasz też w haśle COUNT.
Dla jednego istniejącego wiersza z samym brakiem:
-- example: all-null
SELECT COUNT(*) AS rows_count,
SUM(amount) AS total,
SUM(COALESCE(amount, 0)) AS inner_zero,
COALESCE(SUM(amount), 0) AS outer_zero
FROM events
WHERE id = 2;
| rows_count | total | inner_zero | outer_zero |
|---|---|---|---|
| 1 | NULL | 0 | 0 |
Dla pustego wyniku filtrowania:
-- example: empty
SELECT COUNT(*) AS rows_count,
SUM(amount) AS total,
SUM(COALESCE(amount, 0)) AS inner_zero,
COALESCE(SUM(amount), 0) AS outer_zero
FROM events
WHERE id > 9;
| rows_count | total | inner_zero | outer_zero |
|---|---|---|---|
| 0 | NULL | NULL | 0 |
To ważne rozróżnienie. SUM(COALESCE(amount, 0)) zamienia brak w każdym istniejącym wierszu na zero. Jeśli nie ma żadnego wiersza, nie ma też czego zamienić i wynik nadal wynosi NULL. COALESCE(SUM(amount), 0) działa na końcowym wyniku agregacji, więc zamieni także sumę pustego zbioru na zero.
W tabelach wynikowych zapis NULL oznacza brak, nie tekst składający się z czterech liter. Klient SQL może wyświetlić go jako pustą komórkę. Ustal sposób prezentacji, żeby czytelnik nie pomylił nieznanej wartości z uszkodzonym wydrukiem.
Który wariant wybrać? Najpierw rozstrzygam znaczenie. Jeśli raport ma pokazywać zero przy braku zdarzeń, zewnętrzne COALESCE może być właściwe. Jeśli brak oznacza niekompletne dane, zachowuję tę informację oraz licznik braków. Zamiana na zero jest decyzją o interpretacji, nie neutralnym zabiegiem kosmetycznym. Zachowanie agregatów przy pustym zbiorze opisuje dokumentacja PostgreSQL.
Zapytanie bez GROUP BY zwraca w tych przykładach jeden wiersz agregatu nawet dla pustego wejścia. Grupowanie pustego wejścia nie utworzy natomiast automatycznie grupy dla każdego produktu. Jeśli potrzebujesz pokazać również kategorie bez zdarzeń, zacznij od listy kategorii i świadomie połącz ją ze zdarzeniami. To osobne zagadnienie od samego podstawienia zera w wyniku sumy.
Sumowanie warunkowe
Gdy potrzebuję kilku sum w jednym wierszu, mogę umieścić CASE wewnątrz agregatu:
-- example: conditional
SELECT SUM(CASE WHEN segment = 'A' THEN amount ELSE 0 END) AS total_a,
SUM(CASE WHEN segment = 'B' THEN amount ELSE 0 END) AS total_b,
SUM(CASE WHEN segment = 'C' THEN amount ELSE 0 END) AS total_c
FROM events;
| total_a | total_b | total_c |
|---|---|---|
| 200 | 700 | 0 |
Dla segmentu C nie ma zdarzeń, ale w wejściu są inne wiersze. Każdy z nich daje w trzecim wyrażeniu ELSE 0, więc wynik to zero. Gdyby całe wejście było puste, sam ten zapis nie zapewniałby zera; nadal obowiązuje reguła pustego zbioru.
Zwróć uwagę na segment A. Jedna jego wartość jest nieznana, ale suma znanej części wynosi 200. CASE nie naprawia kompletności. Jeśli odbiorca potrzebuje pełnej wartości segmentu, pokaż liczbę braków lub oznacz wynik jako częściowy. W ten sposób nie tworzysz pozornej dokładności przez samą obecność liczby w raporcie.
DISTINCT i duplikaty po JOIN
SUM(DISTINCT amount) sumuje różne wartości, a nie różne zdarzenia:
-- example: distinct
SELECT SUM(amount) AS total,
SUM(DISTINCT amount) AS distinct_values_total
FROM events;
| total | distinct_values_total |
|---|---|
| 900 | 700 |
Dwa osobne zdarzenia mają wartość 200. Zwykła suma uwzględnia oba, a DISTINCT pozostawia 200 tylko raz: 200 + 500 = 700. To poprawne wykonanie funkcji, ale niepoprawna odpowiedź na pytanie o sumę wszystkich zdarzeń.

Podobny problem pojawia się po złączeniu relacji jeden do wielu. Dodaję syntetyczne tagi tylko na czas zapytania:
-- example: join
WITH tags(event_id, tag) AS (
VALUES (1, 'x'), (1, 'y'), (3, 'x'), (4, 'x')
)
SELECT SUM(e.amount) AS inflated_total
FROM events AS e
JOIN tags AS t ON t.event_id = e.id;
| inflated_total |
|---|
| 1100 |
Pierwsze zdarzenie ma dwa tagi, więc jego 200 zostaje policzone dwa razy. Wynik 1100 jest zawyżony. Dodanie DISTINCT amount nie jest naprawą: usunęłoby również drugie prawidłowe zdarzenie o wartości 200.
Jeśli potrzebuję wyłącznie zdarzeń mających przynajmniej jeden tag, używam warunku istnienia:
-- example: exists
WITH tags(event_id, tag) AS (
VALUES (1, 'x'), (1, 'y'), (3, 'x'), (4, 'x')
)
SELECT SUM(e.amount) AS total
FROM events AS e
WHERE EXISTS (SELECT 1 FROM tags AS t WHERE t.event_id = e.id);
| total |
|---|
| 900 |
EXISTS sprawdza obecność dopasowania i nie mnoży wierszy events. Wynik wraca do 900. Jeżeli potrzebujesz sum według tagów, musisz dodatkowo ustalić regułę przypisania zdarzenia do wielu kategorii. Suma takich kategorii może celowo nie być addytywna. Żaden uniwersalny DISTINCT nie podejmie tej decyzji za Ciebie.
Suma narastająca
Funkcja okienkowa zachowuje wiersze i dodaje do nich wynik liczony w określonym oknie:
-- example: running
SELECT id, event_day, amount,
SUM(amount) OVER (
ORDER BY event_day, id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM events
ORDER BY event_day, id;
| id | event_day | amount | running_total |
|---|---|---|---|
| 1 | 2026-01-01 | 200 | 200 |
| 2 | 2026-01-01 | NULL | 200 |
| 3 | 2026-01-02 | 500 | 700 |
| 4 | 2026-01-02 | 200 | 900 |
Dostajesz kolejno 200, 200, 700 i 900. Brak w drugim wierszu nie dodaje wartości, ale nie usuwa wiersza. Klucz id rozstrzyga kolejność zdarzeń z tego samego dnia, a ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW określa sumowanie od początku do bieżącego wiersza.
Nie pomijam tych szczegółów. Domyślna ramka z samym ORDER BY event_day obejmuje również inne rekordy o tej samej wartości porządkującej. Dwa zdarzenia z drugiego dnia mogą wtedy oba pokazać 900. Taki wynik może być właściwy dla sumy do końca dnia, lecz nie oznacza sumy po każdym kolejnym zdarzeniu. Przewodnik po funkcjach okienkowych PostgreSQL wyjaśnia tę różnicę.
Kolejność wewnątrz OVER steruje obliczeniem. Końcowe ORDER BY steruje prezentacją. Zostawiam oba, bo pełnią różne role. Jeśli dodasz filtr WHERE, okno zobaczy tylko wybrane wiersze; nie będzie pamiętać wcześniejszych zdarzeń usuniętych z wejścia zapytania.
Typy, przepełnienie i dokładność
Nasze małe wartości nie przekraczają zakresów typów. Przy większych danych sprawdzam jednak zarówno typ sumy, jak i typ wyrażenia przekazywanego do niej. W PostgreSQL mnożenie dwóch integer może przepełnić się, zanim SUM dostanie wynik. Rzutowanie jednego czynnika przed mnożeniem poszerza tę operację:
-- example: widen
SELECT SUM(CAST(quantity AS BIGINT) * unit_price) AS total
FROM sales;
| total |
|---|
| 3100 |
To nie gwarantuje nieograniczonego zakresu; BIGINT także ma granice. W razie potrzeby wybierz odpowiedni typ dokładny dla swojego silnika. Rzutowanie dopiero wyniku quantity * unit_price następuje za późno, jeśli samo mnożenie już się nie powiodło.
Ten blok jest tylko dla PostgreSQL:
-- example: postgres-types
SELECT pg_typeof(SUM(quantity)) AS integer_sum_type,
pg_typeof(SUM(CAST(quantity AS BIGINT))) AS bigint_sum_type
FROM sales;
| integer_sum_type | bigint_sum_type |
|---|---|
| bigint | numeric |
Dla wejścia integer suma ma typ bigint, a dla bigint typ numeric. SQLite ma inne reguły; suma całkowita może zgłosić przepełnienie. Nie używaj testu SQLite jako dowodu zachowania typów PostgreSQL. Różnice opisuje dokumentacja agregatów SQLite.
Przy liczbach zmiennoprzecinkowych wynik jest przybliżeniem i może zależeć od kolejności obliczeń. Jeśli potrzebujesz dokładnych wartości dziesiętnych, dobierz typ i skalę świadomie. Nie obiecuję dokładności finansowej na podstawie tego, że przykład z małymi liczbami całkowitymi daje 3100. Także późniejsze dzielenie sumy wymaga kontroli typów: dzielenie całkowite może odrzucić część ułamkową. Sprawdź regułę w docelowym silniku.
Ćwiczenie i kontrola wyniku
Przewidź wynik przed wykonaniem zapytania:
-- example: exercise
SELECT segment, COUNT(*) AS rows_count,
COUNT(amount) AS known_count, SUM(amount) AS total
FROM events
GROUP BY segment
HAVING SUM(amount) >= 200
ORDER BY segment;
| segment | rows_count | known_count | total |
|---|---|---|---|
| A | 2 | 1 | 200 |
| B | 2 | 2 | 700 |
Oba segmenty przechodzą próg >= 200, ale A ma tylko jedną znaną wartość na dwa zdarzenia. Zmiana progu na > 200 pozostawiłaby tylko B. To mały test, który jednocześnie sprawdza granicę, grupowanie i kompletność danych.
Przed dodaniem sumy do raportu odpowiadam sobie na cztery pytania: co reprezentuje wiersz, które rekordy weszły do obliczenia, ile wartości jest nieznanych i czy złączenie nie pomnożyło danych. Dopiero potem oceniam samą liczbę. Mały ręcznie policzony przypadek jest tu bardzo dobrym punktem odniesienia dla większego zapytania.
Najważniejsze w pigułce
Czy SUM zwraca zero dla pustej tabeli?
W PostgreSQL i SQLite zwraca NULL, jeśli nie ma niepustych wartości do dodania. Jeśli interpretacja raportu wymaga zera, zastosuj COALESCE(SUM(...), 0) i zachowaj możliwość odróżnienia braku danych od rzeczywistych zer.
Czy SUM DISTINCT usuwa duplikaty zamówień?
Nie. Usuwa powtórzenia wartości wyrażenia, więc dwie prawidłowe kwoty 200 policzy raz. Duplikaty rekordów rozstrzygaj według właściwego identyfikatora i reguły biznesowej, przed sumowaniem.
Dlaczego suma po JOIN jest większa?
Jeden rekord mógł dopasować się do wielu rekordów drugiej tabeli. Sprawdź liczbę wystąpień klucza po złączeniu. Jeśli potrzebujesz tylko sprawdzić istnienie dopasowania, rozważ EXISTS.
