Wszystkie artykuły

KajoData / SQL

SELECT DISTINCT w SQL: unikalne wartości, pary i pułapki

Pokazuję, dlaczego lista krajów ma cztery wiersze, ich licznik trzy, a dodanie miasta zmienia wynik. Sprawdzisz też, kiedy DISTINCT ukrywa błąd.

Kajo RudzińskiOpublikowano Aktualizacja 11 min czytania

Sorter odkłada po jednym żółtym klocku każdego kształtu do osobnych tac

Masz tabelę klientów i chcesz zobaczyć, z jakich krajów pochodzą. Wybierasz kolumnę country, dostajesz długą listę powtórzeń, dopisujesz DISTINCT i wynik wygląda rozsądnie. To dobry początek. Kłopoty zaczynają się wtedy, gdy ten sam ruch staje się uniwersalną metodą naprawiania raportów.

Ja przed dopisaniem tego słowa ustalam, co ma oznaczać jeden wiersz wyniku. Kraj? Klienta? Parę kraj i miasto? Od odpowiedzi zależy, które powtórzenia wolno pominąć. W tym poradniku przejdę przez małe dane syntetyczne. Składnię i zachowanie opisuję dla PostgreSQL 18, żeby wynik nie zależał od domysłów dotyczących silnika.

Co robi SELECT DISTINCT

SELECT DISTINCT pozostawia po jednym wystąpieniu każdego różnego wiersza wyniku. Porównanie obejmuje wszystkie wyrażenia wybrane w SELECT. Jeśli choć jedna wybrana wartość się różni, oba wiersze mogą pozostać. Tak definiuje tę operację dokumentacja PostgreSQL.

To operacja na wyniku zapytania. Nie kasuje rekordów, nie zmienia tabeli klientów i nie ustanawia reguły unikalności na przyszłość. Ktoś inny może chwilę później odczytać pełną tabelę ze wszystkimi powtórzeniami. Jeżeli Twoim zadaniem jest naprawa źródła, samo dodanie DISTINCT do raportu go nie wykonuje.

Pomyśl o liście dostępnych filtrów na dashboardzie. Chcesz pokazać kraj raz, niezależnie od tego, ilu klientów w nim mieszka. To sensowne zastosowanie. Jeśli natomiast przygotowujesz listę wszystkich zakupów i dwa mają tę samą kwotę, ich podobieństwo nie upoważnia do wyrzucenia jednego zakupu.

Mały zbiór, który da się przeczytać w całości

Przyjmuję sześciu klientów. Identyfikator klienta jest unikalny. NULL w tabeli poniżej oznacza brak ustalonego kraju lub miasta, a nie tekst składający się z czterech liter.

customer_id country city
1 PL Warsaw
2 PL Warsaw
3 PL Krakow
4 DE Berlin
5 ES Madrid
6 NULL NULL

Pierwsze zapytanie jest samodzielne. CTE tworzy dane syntetyczne tylko na potrzeby tego zapytania; nie zakłada istnienia tabeli w Twojej bazie.

WITH customers(customer_id, country, city) AS (
    VALUES
        (1, 'PL', 'Warsaw'),
        (2, 'PL', 'Warsaw'),
        (3, 'PL', 'Krakow'),
        (4, 'DE', 'Berlin'),
        (5, 'ES', 'Madrid'),
        (6, NULL, NULL)
)
SELECT DISTINCT country
FROM customers
ORDER BY country NULLS LAST;

Wynik zawiera DE, ES, PL oraz jeden NULL. Trzy wystąpienia PL stały się jednym, bo wybraliśmy wyłącznie kraj. Numer klienta i miasto nie uczestniczą w tym porównaniu. Ich obecność w źródle nie oznacza, że muszą różnicować wynik.

W kolejnych fragmentach customers oznacza ten sam zbiór. Jeśli chcesz wykonywać je niezależnie, poprzedź każdy tym samym blokiem WITH. CTE nie jest trwałą tabelą i nie pozostaje dostępne po zakończeniu poprzedniego polecenia.

Dodanie kolumny zmienia definicję unikalności

Teraz potrzebuję listy par kraj i miasto. Zmieniam listę wyjściową, zachowując te same dane.

SELECT DISTINCT country, city
FROM customers
ORDER BY country NULLS LAST, city NULLS LAST;

Otrzymuję pięć par: DE/Berlin, ES/Madrid, PL/Krakow, PL/Warsaw i NULL/NULL. Dwie warszawskie osoby dają jedną parę, ale Kraków i Warszawa pozostają osobnymi pozycjami. Sam fakt, że oba miasta leżą w Polsce, nie wystarcza do ich połączenia.

Jeżeli dodam customer_id, otrzymam sześć wierszy. Każdy identyfikator jest inny. To częsta przyczyna zdziwienia: dopisałem DISTINCT, a raport się nie skrócił. Sprawdź wtedy cały wybierany zestaw, szczególnie identyfikatory, znaczniki czasu i kolumny techniczne.

Nie da się tą konstrukcją jednocześnie otrzymać jednej Polski i zachować wszystkich jej różnych miast jako pojedynczej zwykłej wartości. Musisz wybrać inną postać odpowiedzi: listę par, agregowaną listę miast albo jeden wiersz wybrany według jawnej reguły. To decyzja o znaczeniu raportu.

Lupa obejmuje całe pary klocków i ujawnia różne kombinacje

Lista wartości i liczba wartości odpowiadają na inne pytania

SELECT DISTINCT country zwraca listę. COUNT(DISTINCT country) zwraca liczbę różnych niepustych wartości. W naszym przykładzie lista ma cztery wiersze, ale licznik wynosi trzy, ponieważ COUNT w tym wariancie pomija NULL. Zasady opisuje dokumentacja wyrażeń agregujących.

SELECT COUNT(*) AS customer_rows,
       COUNT(country) AS known_country_rows,
       COUNT(DISTINCT country) AS known_countries
FROM customers;

Wynik to odpowiednio 6, 5 i 3. Pierwsza liczba obejmuje wszystkich klientów. Druga mówi, ilu ma wypełniony kraj. Trzecia mówi, ile różnych znanych krajów reprezentują. Żadna z nich nie jest zamiennikiem pozostałych, chociaż wszystkie dotyczą tej samej kolumny.

Jeżeli potrzebuję policzyć wiersze samej listy, mogę potraktować ją jako podzapytanie i policzyć COUNT(*) na zewnątrz. Wtedy wynik wyniesie cztery. Podobnie mogę policzyć pięć różnych par kraj i miasto. Unikam w ten sposób prezentowania wieloargumentowej składni licznika jako uniwersalnej dla wszystkich dialektów SQL.

W opisie metryki zapisałbym „liczba znanych krajów klientów”. Krótkie „kraje: 3” pomija informację o brakach. Czytelnik może uznać, że dane geograficzne są kompletne. Dobry licznik potrzebuje równie dobrej etykiety.

Gdy źródło nie zawiera żadnego wiersza, lista unikalnych krajów również będzie pusta. Licznik różnych krajów zwróci zero. Odróżnij ten przypadek od jednego klienta z nieznanym krajem: lista pokaże wtedy brak, choć licznik nadal wynosi zero.

Liczby różnych krajów nie można też bezmyślnie sumować między segmentami. Wyobraź sobie listę klientów sklepu internetowego z krajami PL i DE oraz listę klientów sklepu stacjonarnego z krajami PL i ES. Każdy kanał ma dwa znane kraje, ale wspólna lista ma trzy. Polska występuje w obu kanałach. Dla całości trzeba ponownie ustalić zbiór różnych wartości, zamiast dodawać gotowe liczniki. Podobne pytanie warto zadać przy łączeniu miesięcznych raportów klientów: ta sama osoba może wrócić w kolejnym miesiącu.

NULL, pusta wartość i reguła braków

Dla eliminowania powtórzeń PostgreSQL traktuje wartości NULL jako jednakowe. Gdyby dwóch klientów miało brak kraju, na liście pojedynczych krajów nadal pojawiłby się jeden NULL. Nie przenoś tej reguły na zwykły warunek country = NULL: do sprawdzania braku używamy IS NULL.

Pusty tekst '' jest w PostgreSQL inną wartością niż NULL. Kraj zawierający spację to jeszcze inny przypadek. Jeśli dane z importu używają wszystkich tych reprezentacji, unikalna lista może mieć więcej pozycji, niż oczekujesz. Wtedy najpierw ustal, które reprezentacje naprawdę znaczą to samo.

Nie zastępuję automatycznie braków napisem unknown, zanim poznam dane. Jeśli taki napis występuje też jako rzeczywista wartość źródłowa, połączę dwie kategorie. W analizie jakości często warto zachować je osobno: brak techniczny, pusty tekst i jawnie zadeklarowana odpowiedź mają różne przyczyny.

Pomaga prosty raport pomocniczy z licznością każdej reprezentacji. Zanim cokolwiek ujednolicisz, zobacz, ile rekordów dotknie zmiana. Lista DISTINCT pozwala obejrzeć warianty, lecz sama nie pokazuje skali problemu.

Kiedy wybrać GROUP BY

Gdy chcę wiedzieć, ilu klientów przypada na kraj, wybieram grupowanie. Po zastosowaniu tej samej reguły braków mogę porównać liczności i zobaczyć, że powtórzenie ma uzasadnienie.

SELECT country, COUNT(*) AS customers_in_country
FROM customers
GROUP BY country
ORDER BY country NULLS LAST;

Dostaję DE: 1, ES: 1, PL: 3 i NULL: 1. Lista krajów zyskała informację o liczbie rekordów. GROUP BY zbiera wiersze w grupy, na których można liczyć agregaty. DISTINCT służy tu do prostszego zadania: pokazania różnych wynikowych wierszy.

Samo GROUP BY country bez agregatów może dać taką samą listę jak SELECT DISTINCT country. Wybieram zapis, który najlepiej komunikuje pytanie. Nie zamieniam tych konstrukcji mechanicznie, gdy zapytanie ma dodatkowe kolumny, agregaty lub warunki. Zmiana jednego słowa może wtedy zmienić wynik albo uczynić zapytanie błędnym.

Powtórzenia po JOIN wymagają diagnozy

Wyobraź sobie klienta z dwoma zamówieniami. Po połączeniu klientów z zamówieniami jego identyfikator pojawi się dwa razy. To naturalny wynik relacji jeden do wielu. Jeśli wybierzesz tylko identyfikator klienta i zastosujesz DISTINCT, uzyskasz listę klientów, którzy mają dopasowanie.

Jeśli jednak chcesz sumować sprzedaż, problem jest inny. Połączenie zamówień z pozycjami i jeszcze z płatnościami może zwielokrotnić kwoty. DISTINCT na finalnym raporcie nie odtworzy automatycznie prawidłowej sumy, szczególnie gdy agregowanie nastąpiło wcześniej. Dwie różne transakcje mogą przecież mieć identyczne kwoty.

Ja sprawdzam wtedy identyfikatory po obu stronach i oczekiwaną liczbę dopasowań. Czy złączenie używa całego klucza? Czy klient ma kilka wersji w wymiarze? Czy raport miesza poziom zamówienia i pozycji? Te pytania prowadzą do przyczyny. Skrócenie tabeli wyniku może tylko schować objaw.

Jeżeli potrzebuję jedynie klientów, którzy mają zamówienie, naturalnym zapisem bywa WHERE EXISTS ze skorelowanym podzapytaniem. Dokumentacja opisuje EXISTS jako sprawdzenie obecności wiersza. Nie muszę zwracać każdego dopasowanego zamówienia. Więcej o samych relacjach znajdziesz w poradniku INNER JOIN.

Kolejność oraz wybór najnowszego rekordu

DISTINCT nie zapewnia uporządkowanej prezentacji. Do tego służy ORDER BY. Jeżeli w przykładzie kraje wyglądają na posortowane bez tej klauzuli, nie buduj na tym reguły eksportu ani interfejsu. Plan wykonania może się zmienić.

Osobnym zadaniem jest wybór najnowszego rekordu klienta. Zwykłe DISTINCT customer_id, updated_at zachowa różne daty tego samego klienta. Dodanie daty do ORDER BY nie zmieni definicji porównywanych wierszy. Potrzebujesz reguły wyboru, która uwzględni datę i rozstrzygnie remisy.

PostgreSQL ma rozszerzenie DISTINCT ON. Wymaga ono dopasowania początkowych wyrażeń sortowania do klucza wyboru, a dalsze kryteria określają preferowany rekord. W innych silnikach często stosuje się ROW_NUMBER z filtrem w zapytaniu zewnętrznym. Nie przedstawiam rozszerzenia PostgreSQL jako przenośnego SQL.

Przed wyborem najnowszego wiersza ustal jeszcze znaczenie daty. Czas importu, czas zdarzenia i czas korekty opisują różne rzeczy. Gdy dwa rekordy mają tę samą datę, stabilny dodatkowy identyfikator rozwiązuje remis techniczny, lecz nie zastępuje decyzji, który rekord jest biznesowo właściwy.

Tekst i koszt zapytania

Przy tekstach znaczenie równości zależy także od typu i ustawień porównywania. Kolacje PostgreSQL pozwalają określić reguły porównań, w tym warianty nieuwzględniające niektórych różnic. Nie zakładam zatem, że każdy silnik potraktuje wielkie litery, akcenty czy końcowe spacje identycznie.

Jeżeli firma uznaje PL i pl za jeden kod, warto zapisać i stosować tę regułę w kontrolowanym przekształceniu. Zachowaj źródło, bo normalizacja może utracić potrzebne rozróżnienia. Nazwy produktów, identyfikatory i odpowiedzi ankietowe nie muszą mieć tych samych zasad.

Eliminowanie powtórzeń ma koszt. Jego wysokość zależy od planu, liczby wierszy, szerokości wyniku, indeksów i zasobów. Nie obiecuję, że DISTINCT jest zawsze wolne ani że zamiana na GROUP BY wszystko przyspieszy. Do oceny konkretnego zapytania służy EXPLAIN i pomiar w odpowiednim środowisku.

Unikalny klient a unikalna nazwa klienta

Wróć do pytania, co identyfikuje obiekt. Dwie osoby mogą nazywać się tak samo. Jedna osoba może zmienić nazwisko. Jeśli policzysz różne nazwy, dostaniesz liczbę różnych zapisów nazw, a nie automatycznie liczbę klientów. Nazwa jest dobrym elementem prezentacji, lecz zwykle potrzebujesz stabilnego klucza do liczenia osób.

W naszym zbiorze klient numer jeden i klient numer dwa mieszkają w tym samym mieście. Gdyby obaj mieli też to samo imię, nadal mogliby być dwiema różnymi osobami. Zmniejszenie wyniku do jednego wiersza byłoby poprawne dla listy imion, a błędne dla listy uczestników programu. Kod może działać bez błędu składni i odpowiadać na niewłaściwe pytanie.

Podobna pułapka pojawia się przy nazwach produktów. Dwa warianty mogą mieć wspólną nazwę marketingową, ale różne kody i opakowania. Przed zliczeniem „unikalnych produktów” ustalam więc, czy chodzi o model, wariant, jednostkę magazynową czy ofertę sprzedawcy. Każdy poziom wymaga innego zestawu pól. DISTINCT wykona wybraną definicję; nie wybierze jej za autora raportu.

Jeżeli w źródle brakuje stabilnego identyfikatora, nazwij ograniczenie w wyniku. Możesz przygotować listę kandydatów do ręcznej weryfikacji według kilku cech, ale nie ogłaszaj ich pewnymi duplikatami. Zgodność kraju, miasta i nazwiska to poszlaka. Dwie legalne osoby nadal mogą mieć wszystkie te atrybuty wspólne.

DISTINCT wewnątrz sumy może usunąć legalną sprzedaż

Załóżmy osobny, syntetyczny przykład: trzy zamówienia mają kwoty 100, 100 i 50. Każde ma inny identyfikator. Łączna sprzedaż wynosi 250. Chociaż dwie kwoty są równe, dotyczą dwóch oddzielnych zakupów, które powinny pozostać w raporcie.

Jeżeli zastosujesz SUM z DISTINCT na samej kwocie, do agregatu trafią różne kwoty 100 i 50. Dostaniesz 150. To poprawna suma różnych wartości liczbowych, ale niewłaściwy przychód z tych zamówień. Reguła wyboru różnych argumentów agregatu wynika z przywołanej dokumentacji wyrażeń. Wartość transakcji nie jest jej identyfikatorem.

Przy diagnozie zwielokrotnienia po złączeniu porównuję więc liczność i sumę na poziomie identyfikatora zdarzenia. Jeśli zamówienie ma trzy pozycje, kwota nagłówka może zostać powtórzona trzy razy. Rozwiązaniem może być wcześniejsze agregowanie pozycji lub połączenie źródeł na zgodnym poziomie szczegółowości. Wybór zależy od pytania, które ma obsługiwać raport.

Zachowuję też mały przypadek kontrolny z dwiema równymi, legalnymi kwotami. Taki przykład szybko ujawnia pomysł naprawiania każdej sumy przez DISTINCT. Jeśli po zmianie z raportu znika jedno z dwóch różnych zamówień, poprawka nie spełnia definicji. Mniejsza liczba sama w sobie nie świadczy o lepszym wyniku.

Jak przekazać wynik dalej

Gdy oddaję listę krajów innej osobie, dołączam informację o źródle i zakresie. Czy lista obejmuje wszystkich klientów, czy tylko aktywnych? Czy kraj pochodzi z adresu dostawy, czy z profilu? Czy opisuje stan bieżący, czy historię zamówień? Każda z tych decyzji może zmienić zbiór bez zmiany słowa DISTINCT.

Nie dołączam do takiej listy przypadkowego miasta tylko po to, żeby eksport wyglądał bogaciej. Dodatkowa kolumna zmienia jednostkę wiersza, a odbiorca może nadal zakładać, że każdy kraj występuje raz. Jeśli potrzebne są oba widoki, przygotowuję dwie jawnie nazwane tabele: kraje oraz pary kraj i miasto.

Przed przekazaniem porównaj liczbę wierszy z deklarowaną definicją. Sprawdź również braki oraz jeden przykład częstego i rzadkiego wariantu. W małym zbiorze z tego tekstu wystarczy przeczytać sześć rekordów. W większym raporcie ten sam sposób myślenia pomaga zaplanować kontrolę, zamiast ufać, że krótki wynik musi być prawidłowy.

Ćwiczenie: zanim dopiszesz DISTINCT

Dodaj do danych klienta 7 z krajem PL i miastem Gdansk. Przewidź liczbę unikalnych krajów, liczbę par kraj i miasto oraz liczbę wierszy po wybraniu wszystkich trzech kolumn. Potem uzasadnij każdą odpowiedź, wskazując dokładnie porównywany zestaw.

Lista krajów nadal ma cztery wiersze, a licznik znanych krajów nadal wynosi trzy. Lista par rośnie do sześciu. Pełny wynik z identyfikatorem ma siedem wierszy. Nowy klient wnosi nowe miasto i nowy identyfikator, ale nie wnosi nowego kraju.

Na własnym raporcie wykonaj podobny krok: zapisz jednym zdaniem znaczenie wiersza, obejrzyj powtórzenia wraz z kluczami i zdecyduj, czy są poprawne. Dopiero potem wybierz DISTINCT, grupowanie albo zmianę złączenia. Jeśli potrzebujesz przypomnienia budowy zapytania, przejdź do SELECT w SQL.

Najważniejsze w pigułce

Czy DISTINCT usuwa rekordy z tabeli?

Nie. Ogranicza powtarzające się wiersze wyniku SELECT. Dane źródłowe pozostają bez zmian.

Czy DISTINCT działa osobno na każdej kolumnie?

Porównuje komplet wartości wybranych w SELECT. Para kraj i miasto może się różnić, nawet jeśli kraj jest taki sam.

Dlaczego COUNT(DISTINCT country) daje mniej niż lista DISTINCT?

COUNT(DISTINCT country) pomija NULL. SELECT DISTINCT może pokazać jeden wiersz z NULL obok różnych niepustych wartości.

Przy pracy nad artykułem użyto w zautomatyzowany sposób sztucznej inteligencji. Jeśli też chcesz tak umieć, sprawdź ten projekt.

Kajo Rudziński

Newsletter KajoData

Zostańmy w kontakcie.

Piszę o analizie danych, uczeniu się i zmianie pracy. Dzielę się tym, co pomaga mi ogarniać technologię i zawodowe decyzje. Jeśli lubisz ten sposób myślenia, zapraszam do mojego newslettera.