Wszystkie artykuły

KajoData / Praca i rozwój

SQL dla analityka: od pytania do sprawdzonego wyniku

Działający SELECT może dawać błędną odpowiedź. Na kompletnym przykładzie sprawdzam zakres, ziarno danych, JOIN-y i brakujące kwoty.

Kajo RudzińskiOpublikowano Aktualizacja 11 min czytania

Szafki z rekordami połączone żółtą ścieżką selekcji prowadzącą do tacy z wynikiem.

SQL przydaje mi się przede wszystkim wtedy, kiedy muszę przejść od pytania do wyniku, który potrafię wyjaśnić. Samo uruchomienie zapytania to połowa roboty. Druga połowa zaczyna się przy pytaniu: skąd wiem, że policzyłem właściwe rzeczy? Baza bardzo sprawnie wykona także zapytanie o błędnym znaczeniu. Zielony komunikat wykonania tego nie rozstrzyga.

W tym artykule przejdziemy przez małą analizę zamówień. Ustalimy zakres, odczytamy tabele, połączymy dane i sprawdzimy rezultat. Po drodze pojawią się pułapki, które łatwo przeoczyć: zwielokrotnienie kwoty przez JOIN, brak klienta w słowniku oraz kwota nieznana, która wygląda kusząco jak zero. Wszystkie dane są syntetyczne i służą wyłącznie do ćwiczenia.

Najpierw ustal, na jakie pytanie odpowiadasz

Przyjmijmy prośbę: pokaż październikowe zamówienia według kraju klienta. Zanim otworzę edytor, doprecyzowuję kilka rzeczy. Bierzemy zamówienia ze statusem paid i datą zamówienia w październiku 2026. Liczymy zamówienia oraz sumę znanych kwot. Osobno pokazujemy liczbę brakujących kwot. Zamówienie bez rozpoznanego klienta zostaje w wyniku jako unknown. Kraj pochodzi z aktualnej tabeli klientów w naszym ćwiczeniu.

To ostatnie zdanie ma konsekwencje. Nie odtwarzamy kraju klienta w dniu zakupu. Nie liczymy też przychodu księgowego, marży ani przepływów pieniężnych. Nie mamy danych o zwrotach, podatkach i historii statusów. W prawdziwym zadaniu każda z tych informacji może zmienić definicję raportu. Tutaj ograniczam pytanie tak, żeby dało się uczciwie odpowiedzieć na podstawie dostępnych kolumn.

Zapisz definicję obok zapytania. Gdy ktoś później zmieni status na shipped, od razu widać zmianę zakresu. Bez takiej notatki dwie poprawnie działające kwerendy potrafią przez tydzień produkować różne odpowiedzi na pozornie to samo pytanie. Rozmowę o liczbach zaczynam wtedy od ustaleń, a dopiero później od optymalizacji kodu.

Rozpoznaj ziarno danych i klucze

Ziarno mówi, co reprezentuje pojedynczy wiersz. W orders jest to jedno zamówienie, w customers jeden klient, a w order_items jedna pozycja zamówienia. Jedno zamówienie może mieć kilka pozycji. Ta prosta różnica decyduje o tym, czy po połączeniu tabel wolno bezpośrednio sumować kwotę zamówienia.

Zwróć uwagę na klucze. order_id identyfikuje zamówienie, customer_id klienta. W tabeli pozycji para order_id i sku jest unikalna tylko dlatego, że tak zdefiniowaliśmy ćwiczenie. W innym systemie ten sam produkt może występować w kilku osobnych pozycjach. Wtedy potrzebny byłby identyfikator pozycji. Nazwy kolumn bywają podobne, ale nie zastępują opisu danych.

Poniższy zestaw uruchom w pustej, lokalnej bazie SQLite do ćwiczeń. Kwoty zapisujemy jako całkowite najmniejsze jednostki jednej umownej waluty. Nie mieszamy walut ani nie potrzebujemy zaokrągleń podczas sumowania. Identyfikator klienta 99 celowo nie ma odpowiednika w customers. To kontrolowany błąd danych, dzięki któremu sprawdzimy zachowanie raportu.

-- example: fixture
CREATE TABLE customers (
  customer_id INTEGER PRIMARY KEY,
  country TEXT NOT NULL
);
CREATE TABLE orders (
  order_id INTEGER PRIMARY KEY,
  customer_id INTEGER,
  status TEXT NOT NULL,
  order_date TEXT NOT NULL,
  amount_cents INTEGER
);
CREATE TABLE order_items (
  order_id INTEGER NOT NULL,
  sku TEXT NOT NULL,
  quantity INTEGER NOT NULL,
  PRIMARY KEY (order_id, sku)
);
INSERT INTO customers VALUES
  (1,'PL'),(2,'DE'),(3,'PL'),(4,'FR');
INSERT INTO orders VALUES
  (101,1,'paid','2026-10-01',10000),
  (102,1,'paid','2026-10-03',NULL),
  (103,2,'paid','2026-10-02',0),
  (104,2,'cancelled','2026-10-04',5000),
  (105,3,'paid','2026-09-30',9000),
  (106,99,'paid','2026-10-05',2500),
  (107,NULL,'paid','2026-10-05',1500),
  (108,3,'paid','2026-11-01',7000),
  (109,1,'paid','2026-10-06',10000),
  (110,4,'pending','2026-10-07',8000);
INSERT INTO order_items VALUES
  (101,'A',1),(101,'B',2),(102,'A',1),
  (103,'C',1),(106,'D',1),(107,'E',1),
  (109,'A',1);

W projekcie zawodowym zacząłbym także od słownika kolumn i rozmowy z właścicielem źródła. Czy paid oznacza potwierdzoną płatność? Czy status nadpisuje wcześniejszy stan? Czy data oznacza dzień lokalny czy moment zapisany w UTC? Zapytanie nie odpowie na te pytania samą składnią. Odpowiedzi muszą pochodzić z definicji systemu i uzgodnień z odbiorcą analizy.

Filtruj zakres i obejrzyj konkretne rekordy

Pierwszy SELECT zwraca wyłącznie identyfikator i kwotę zamówień spełniających warunki. Dzięki temu łatwo porównać wynik z małą tabelą wejściową. Przed agregacją chcę zobaczyć sześć identyfikatorów: 101, 102, 103, 106, 107 i 109. Zamówienia sprzed października, z listopada, anulowane i oczekujące pozostają poza zakresem.

-- example: filtered
SELECT order_id, amount_cents
FROM orders
WHERE status = 'paid'
  AND order_date >= '2026-10-01'
  AND order_date < '2026-11-01'
ORDER BY order_id;

Stosuję przedział od pierwszego października włącznie do pierwszego listopada wyłącznie. W ćwiczeniu daty są jednakowo zapisanym tekstem ISO, więc ich porządek odpowiada porządkowi dni. Przy prawdziwych znacznikach czasu trzeba dodatkowo ustalić typ kolumny i strefę czasową. Nie kopiuj porównań tekstowych do źródła z datami zapisanymi na kilka różnych sposobów.

Lubię zaczynać od jawnej listy kolumn. Od razu widzę, czego potrzebuje dalsza analiza. SELECT z gwiazdką może być wygodny podczas oglądania małej próbki, ale szeroki eksport utrudnia ocenę wyniku i przenosi zbędne dane. LIMIT również pomaga przy rozpoznaniu tabeli, jednak mała próbka nie udowadnia poprawności całego zbioru ani jego kompletności.

Sprawdź, ile razy JOIN powiela zamówienie

Teraz celowo zrobimy błąd. Do zamówień dołączymy pozycje, a następnie zsumujemy kwoty zamówień. Zamówienie 101 ma dwie pozycje, więc jego kwota pojawi się dwa razy. Wszystkie wybrane zamówienia mają tu przynajmniej jedną pozycję; dzięki temu możemy wyraźnie zobaczyć samo zwielokrotnienie, bez dodatkowego problemu znikających rekordów.

-- example: wrong_join
SELECT COUNT(*) AS joined_rows,
       SUM(o.amount_cents) AS repeated_amount,
       SUM(DISTINCT o.amount_cents) AS distinct_amount
FROM orders AS o
JOIN order_items AS i ON i.order_id = o.order_id
WHERE o.status = 'paid'
  AND o.order_date >= '2026-10-01'
  AND o.order_date < '2026-11-01';

Wynik to siedem wierszy połączenia i suma 34000. Prawidłowa suma znanych kwot sześciu zamówień wynosi 24000. Różnicę powoduje powtórne doliczenie 10000 dla zamówienia 101. Baza wykonuje dokładnie podane instrukcje. To my poprosiliśmy ją o sumowanie na poziomie pozycji, używając kwoty przypisanej do całego zamówienia.

Dodanie DISTINCT do sumy daje tutaj 14000 i też psuje wynik. Zamówienia 101 oraz 109 mają taką samą kwotę, ale są różnymi zamówieniami. Usunięcie powtarzających się wartości kwoty usuwa również prawidłowy zakup. Jeżeli potrzebuję danych o pozycjach, najpierw doprowadzam je do poziomu zamówienia albo sprawdzam samo istnienie pasującej pozycji. W tym raporcie tabela pozycji jest zbędna.

Jedno duże pudełko połączone z dwiema pozycjami i lupa przy kartach danych.

Przy każdym połączeniu zapisuję oczekiwaną relację: jeden do jednego, wiele do jednego lub wiele do wielu. Potem porównuję liczbę wierszy i unikalnych kluczy przed oraz po operacji. Nie każda zmiana liczby wierszy jest błędem. Muszę jednak umieć wskazać, dlaczego nastąpiła i czy dalsze obliczenia nadal mają właściwy poziom szczegółowości.

Zbuduj wynik w czytelnych etapach

Właściwy raport korzysta tylko z orders i customers. CTE eligible nadaje nazwę wybranemu zakresowi. Kolejny etap dopisuje kraj, a końcowy SELECT grupuje wynik. Takie etapy ułatwiają mi osobne sprawdzenie filtrów i połączenia. Samo użycie CTE nie daje obietnicy szybszego wykonania; to przede wszystkim sposób organizacji tej kwerendy. Składnię opisuje dokumentacja WITH w SQLite.

-- example: summary
WITH eligible AS (
  SELECT order_id, customer_id, amount_cents
  FROM orders
  WHERE status = 'paid'
    AND order_date >= '2026-10-01'
    AND order_date < '2026-11-01'
), labelled AS (
  SELECT e.order_id, e.amount_cents,
         COALESCE(c.country, 'unknown') AS country
  FROM eligible AS e
  LEFT JOIN customers AS c
    ON c.customer_id = e.customer_id
)
SELECT country,
       COUNT(*) AS order_count,
       SUM(amount_cents) AS known_amount,
       COUNT(*) - COUNT(amount_cents) AS missing_amounts
FROM labelled
GROUP BY country
ORDER BY country;

LEFT JOIN zachowuje zamówienia bez dopasowanego klienta. COALESCE nadaje im etykietę unknown. Gdybym po połączeniu wymagał w WHERE kraju PL, zamówienia bez kraju wypadłyby z wyniku. Warunki dotyczące prawej tabeli wymagają zatem uwagi; ich położenie zmienia sens analizy. Zasady połączeń i filtrowania opisuje dokumentacja SELECT.

Kraj Zamówienia Znana kwota
DE 1 0
PL 3 20000
unknown 2 4000

Kolumna missing_amounts w wyniku zapytania wynosi 1 dla PL oraz 0 dla DE i unknown.

Klient z Francji istnieje w słowniku, ale jego zamówienie ma status pending. Francja nie pojawia się więc w raporcie opartym na wybranych zamówieniach. Jeżeli odbiorca chce wszystkie kraje, także bez sprzedaży, potrzebujemy osobnej listy krajów i innej konstrukcji wyniku. Dopisanie zera dopiero na wykresie ukryłoby tę decyzję zamiast ją wyjaśnić.

Oddziel brak kwoty od prawdziwego zera

Zamówienie 103 ma kwotę zero, natomiast 102 ma NULL. W pierwszym przypadku wartość znamy. W drugim jej nie znamy. Dlatego raport pokazuje zarówno sumę znanych kwot, jak i licznik braków. Odbiorca może ocenić, czy suma obejmuje wszystkie zamówienia, zanim wykorzysta ją do porównania krajów.

COUNT(*) liczy wiersze, COUNT(amount_cents) znane kwoty, a SUM pomija NULL. Gdy nie ma żadnej znanej kwoty, SUM zwraca NULL. Te zachowania sprawdzisz w dokumentacji agregacji SQLite. W naszym przykładzie niemieckie zero pozostaje zerem. Polski wynik 20000 jest sumą dwóch znanych kwot, obok której nadal widać jedno nieuzupełnione zamówienie.

Nie podmieniam automatycznie każdej brakującej kwoty na zero. Taka zamiana wymaga uzasadnienia biznesowego. Może usunąć sygnał, że import płatności się opóźnił albo źródło nie przekazało wartości. Podobnie unknown grupuje dwa różne problemy klienta: pusty identyfikator i identyfikator bez dopasowania. W raporcie mogą mieć wspólną etykietę, ale przy naprawianiu danych rozdzielam ich przyczyny.

Zrób uzgodnienie wyniku przed wysłaniem

Kontrola końcowa zaczyna się od prostego bilansu. W grupach mamy sześć zamówień, tyle samo co po filtrze. Znane kwoty sumują się do 24000, brakująca kwota występuje raz. W kategorii unknown są dokładnie dwa zamówienia. Jeżeli liczby się rozchodzą, wracam do ostatniego etapu, w którym były zgodne, i sprawdzam konkretne identyfikatory.

W testach tego przykładu uruchamiamy identyczne zapytania z obu wersji językowych w SQLite. Sprawdzamy podstawowy wynik, pusty zakres, same brakujące kwoty, rzeczywiste zero, niepasujący klucz i odrzucenie duplikatu klucza głównego. Test błędnego JOIN-a zachowuje jego błędny wynik jako ostrzeżenie: jeśli zmienimy dane ćwiczenia, wyjaśnienie musi nadal pasować do obliczeń.

Sama zgodność sum nie wystarczy do zatwierdzenia raportu. Dwa błędy mogą się wzajemnie znieść. Dlatego oglądam też wybrane rekordy graniczne: pierwszy i ostatni dzień okresu, anulowane zamówienie, brak kwoty oraz brak klienta. Zestaw takich przypadków powinien wynikać z definicji pytania. Losowe sprawdzenie jednego ładnego wiersza daje znacznie mniej informacji.

Przy większym zbiorze warto zachować małą kontrolną próbkę z oczekiwanym wynikiem oraz zestaw liczników dla całego zakresu. Próbka tłumaczy logikę, liczniki sygnalizują zmianę skali problemu. O tym, jak rozdzielam naprawę danych od ich wygodnego ukrywania, przeczytasz także w tekście o czyszczeniu danych.

Czytaj cudzy SQL od źródeł do wyniku

Kiedy dostaję długie zapytanie, najpierw szukam tabel wejściowych, filtrów i kluczy połączenia. Przy każdym CTE dopisuję sobie jedno zdanie: co reprezentuje jego wiersz? Dopiero później czytam końcowe miary. Ta kolejność pomaga zauważyć, że kwota zamówienia została wcześniej rozmnożona, zanim trafiła do pozornie niewinnego SUM.

Funkcje okna przydają się wtedy, gdy potrzebujesz obliczenia w grupie przy zachowaniu poszczególnych wierszy, na przykład kolejności zakupów klienta. Wymagają jednak świadomego podziału i porządku. Przy jednakowych datach potrzebny może być dodatkowy klucz rozstrzygający. Grupowanie wszystkich nierozpoznanych klientów razem byłoby osobną decyzją. Opis działania znajdziesz w dokumentacji funkcji okna.

Nie dokładam okna tylko po to, żeby zapytanie wyglądało na bardziej zaawansowane. Nasze pytanie wymaga wyniku na poziomie kraju, więc zwykła agregacja jest wystarczająca. Gdy nowe wymaganie dotyczy ostatniego zamówienia każdego klienta, wracam do definicji oraz brakujących identyfikatorów i dopiero wtedy wybieram odpowiednią funkcję.

Oddziel liczbę zamówień od liczby sztuk

W tabeli pozycji mamy także quantity. Zamówienie 101 zawiera dwie pozycje, ale łącznie trzy sztuki produktów. Gdy ktoś poprosi o liczbę sprzedanych produktów, musimy ustalić, czy chodzi o sztuki, pozycje czy różne kody produktu. Każda z tych odpowiedzi może być sensowna. Każda wymaga innego obliczenia i innej nazwy kolumny w raporcie. Samo słowo liczba nie ustala mianownika ani jednostki obserwacji.

Nie rozdzielam również całej kwoty zamówienia po równo na jego pozycje bez dodatkowej definicji. Nie wiemy, ile kosztował produkt A, a ile produkt B, czy rabat dotyczył jednej pozycji i czy kwota obejmuje dostawę. Na tych danych da się policzyć zamówienia według kraju. Nie da się wiarygodnie stworzyć sprzedaży wartościowej według produktu. Rozpoznanie takiej granicy jest częścią pracy analitycznej.

Przy przekazywaniu wyniku pomocne bywa krótkie zdanie pod tabelą: pokazujemy zamówienia ze statusem paid według daty zamówienia, a suma obejmuje wyłącznie znane kwoty. Do tego dodaję jednostkę wartości i datę pobrania. Dzięki temu odbiorca nie musi odtwarzać znaczenia z nazwy pliku ani zgadywać, czy pusta komórka oznacza zero. Dobra nazwa miary oszczędza późniejsze pytania o rozbieżności.

Przygotuj zapytanie do przeglądu

Przed oddaniem pracy proszę drugą osobę o prześledzenie jednego zwykłego zamówienia i jednego wyjątku. Zaczynamy od danych wejściowych, przechodzimy przez warunki, a na końcu wskazujemy właściwy wiersz wyniku. Jeżeli nie potrafimy uzgodnić, gdzie powinno trafić zamówienie bez klienta, wracamy do definicji. Zmiana formatowania nie rozwiąże takiej rozbieżności. Potrzebna jest decyzja dotycząca znaczenia danych.

Warto też przewidzieć, co stanie się po zmianie źródła. Nowy status może wymagać aktualizacji zakresu. Nowy kraj powinien przejść przez grupowanie bez ręcznego dopisywania kolumny. Dwa rekordy tego samego klienta powinny zatrzymać kontrolę unikalności, zanim zwielokrotnią zamówienia. W ćwiczeniu chroni nas klucz główny; w surowym eksporcie trzeba sprawdzić to samodzielnie. Takie pytania zamieniają jednorazową kwerendę w rozwiązanie, które można rozsądnie utrzymywać.

Optymalizuj dopiero sprawdzoną odpowiedź

Przy wolnym zapytaniu sprawdzam plan wykonania, rozmiar źródeł i liczbę wierszy po połączeniach. W SQLite służy do tego EXPLAIN QUERY PLAN, opisane w oficjalnej dokumentacji. Indeks może pomóc, ale jego przydatność zależy od warunków i rozkładu danych. Dziesięć przykładowych zamówień nie jest wiarygodnym testem wydajności firmowej hurtowni.

Porównanie wersji powinno obejmować rezultat i koszt wykonania w podobnych warunkach. Szybsza kwerenda, która zgubiła niepasujących klientów, rozwiązuje inne zadanie. Partycjonowanie, składnia dat i narzędzia diagnostyczne różnią się między silnikami. Przykłady tutaj wykonano w SQLite; przed przeniesieniem do innego systemu sprawdź jego dokumentację oraz własne testy graniczne.

Zostaw po sobie wynik, który można powtórzyć

Do gotowego raportu dołączam definicję zakresu, wersję zapytania, moment pobrania danych i krótką notatkę o brakach. Jeżeli kraj klienta może się zmienić, zaznaczam, czy używam bieżącej wartości, czy historii. Dzięki temu kolejna osoba wie, dlaczego ponowne uruchomienie za miesiąc może dać inny rezultat dla tego samego okresu.

Na własne ćwiczenie zmień zakres na listopad, a potem przewidź wynik bez uruchamiania kodu. Powinno zostać jedno zamówienie 108, kraj PL i kwota 7000. Następnie wybierz miesiąc bez zamówień i ustal, czy odbiorca oczekuje pustej tabeli, czy jawnych zer dla listy krajów. To dwa różne kontrakty raportu, które warto umieć opisać zwykłym językiem.

SQL rozwija się najlepiej razem z umiejętnością sprawdzania danych. Po tym przykładzie możesz przejść do budowania histogramu z CTE i CASE WHEN. Zachowaj ten sam nawyk: ustal jednostkę obserwacji, policz oczekiwany mały wynik i dopiero później pracuj na większym zbiorze.

Materiał wideo i podcast

W materiale omawiam elementy SQL przydatne analitykowi. Powyższe ćwiczenie rozwija temat o kompletny zestaw danych i kontrole wyniku; możesz wrócić do niego po obejrzeniu filmu.

Najważniejsze w pigułce

Co z SQL powinien umieć analityk danych?

Filtrowanie, łączenie tabel i agregacje warto ćwiczyć razem z rozpoznawaniem ziarna danych, braków i kluczy. Ważna jest umiejętność sprawdzenia wyniku i wyjaśnienia definicji raportu.

Dlaczego JOIN zawyża sumę?

Połączenie zamówienia z kilkoma pozycjami powtarza jego kwotę w każdym pasującym wierszu. Przed sumowaniem trzeba ustalić właściwy poziom danych; SUM(DISTINCT kwota) nie usuwa tego problemu poprawnie.

Czy NULL można zastąpić zerem?

Tylko wtedy, gdy uzasadnia to definicja biznesowa. Brak kwoty oznacza coś innego niż znana kwota zero. W przykładzie zachowujemy NULL i osobno pokazujemy liczbę braków.

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.