Funkcje okienkowe w SQL Server: te 6 trików z ROW_NUMBER i LAG robi różnicę w raportach
Poznaj 6 praktycznych trików z funkcjami okienkowymi w SQL Server. Zobacz, jak używać OVER, ROW_NUMBER, RANK, DENSE_RANK, LAG i LEAD do raportów, deduplikacji i analizy zmian w czasie.
Czym są funkcje okienkowe w SQL Server i dlaczego często zastępują skomplikowane podzapytania?
Funkcje okienkowe w SQL Server to funkcje obliczeniowe wykonywane na zbiorze wierszy powiązanych z bieżącym wierszem, bez redukowania wyniku do jednego rekordu na grupę. Działają z użyciem klauzuli OVER, w której określa się sposób podziału danych na grupy (PARTITION BY) oraz kolejność przetwarzania (ORDER BY). Dzięki temu można np. numerować wiersze, porównywać wartości z poprzednim rekordem albo liczyć sumy narastające, zachowując jednocześnie wszystkie wiersze w wyniku.
To właśnie odróżnia je od klasycznego GROUP BY i wielu podzapytań. W tradycyjnym podejściu, jeśli trzeba było dla każdego wiersza wyliczyć wartość zależną od innych wierszy z tej samej grupy, często kończyło się to zagnieżdżonymi podzapytaniami, samodołączeniami albo dodatkowymi tabelami pośrednimi. Funkcje okienkowe pozwalają wykonać takie obliczenia w jednym zapytaniu, czytelniej i zwykle bardziej naturalnie z punktu widzenia analizy danych.
Powód, dla którego często zastępują skomplikowane podzapytania, jest praktyczny: umożliwiają dostęp do kontekstu sąsiednich lub powiązanych wierszy bez potrzeby ręcznego łączenia tabel z samymi sobą. Zamiast pisać osobne zapytanie do znalezienia „poprzedniego rekordu”, „pierwszego rekordu w grupie” czy „pozycji rekordu w rankingu”, można użyć funkcji takich jak ROW_NUMBER(), RANK(), LAG(), LEAD(), SUM() lub AVG() z OVER. To upraszcza logikę i zmniejsza ryzyko błędów wynikających z nadmiarowych złączeń.
W praktyce funkcje okienkowe są szczególnie przydatne wtedy, gdy wynik ma pozostać na poziomie szczegółowych rekordów, ale jednocześnie trzeba dodać informację analityczną wyliczoną „w tle” na podstawie całej grupy lub określonej sekwencji danych. Dlatego w raportach i analizach SQL Server są często lepszym rozwiązaniem niż rozbudowane podzapytania: kod jest krótszy, bardziej zrozumiały i lepiej oddaje intencję obliczeń.
Jak działa klauzula OVER z PARTITION BY i ORDER BY w praktyce?
Klauzula OVER określa, na jakim zbiorze wierszy ma działać funkcja okienkowa i w jakiej kolejności ma je analizować. W praktyce są tu dwa najważniejsze elementy: PARTITION BY dzieli wynik na niezależne grupy, a ORDER BY ustawia kolejność wierszy wewnątrz każdej z tych grup. Funkcja jest liczona dla każdego wiersza osobno, ale z uwzględnieniem innych wierszy z tego samego „okna”.
PARTITION BY działa podobnie do logicznego grupowania, ale nie zwija danych jak GROUP BY. Dzięki temu można np. policzyć numer wiersza, sumę narastającą albo porównać bieżący wiersz z poprzednim, zachowując wszystkie rekordy w wyniku. Jeśli podasz PARTITION BY Dzial, to każdy dział będzie liczony oddzielnie. Jeśli go pominiesz, całe zapytanie zostanie potraktowane jako jedna partycja.
ORDER BY w OVER określa kolejność obliczeń w obrębie partycji. Ma to kluczowe znaczenie dla funkcji takich jak ROW_NUMBER(), LAG(), LEAD() czy sumy narastające. Przykładowo ROW_NUMBER() OVER (PARTITION BY Dzial ORDER BY DataSprzedazy) nada numerację od 1 osobno w każdym dziale, zgodnie z datą sprzedaży. Z kolei LAG(Kwota) OVER (PARTITION BY Dzial ORDER BY DataSprzedazy) zwróci wartość Kwota z poprzedniego wiersza w tym samym dziale, według tej właśnie kolejności.
W praktyce trzeba pamiętać, że bez jednoznacznego sortowania wynik może być niedeterministyczny. Jeśli w ORDER BY użyjesz kolumny z powtarzającymi się wartościami, SQL Server może różnie ustalać kolejność remisów, chyba że dodasz dodatkowy klucz, np. identyfikator. To szczególnie ważne przy numerowaniu i porównywaniu z poprzednim lub następnym wierszem.
Warto też odróżnić ORDER BY wewnątrz OVER od końcowego ORDER BY całego zapytania. To pierwsze steruje logiką funkcji okienkowej, a to drugie tylko kolejnością wyświetlenia wyniku. Możesz więc policzyć ranking według jednej kolejności, a pokazać dane w innej.
Praktycznie oznacza to tyle: PARTITION BY odpowiada na pytanie „w jakich grupach liczyć?”, a ORDER BY na pytanie „w jakiej kolejności liczyć wiersze w każdej grupie?”. To właśnie ta kombinacja pozwala budować raporty, w których obliczenia są lokalne dla działu, klienta, miesiąca czy dokumentu, ale nadal widzisz pełny zestaw rekordów.
Kiedy użyć ROW_NUMBER, a kiedy RANK lub DENSE_RANK?
Wybór zależy od tego, jak chcesz traktować remisy w danych, czyli wiersze o tej samej wartości w klauzuli ORDER BY funkcji okienkowej.
| Funkcja | Jak działa przy remisach | Kiedy używać |
|---|---|---|
ROW_NUMBER() | Każdy wiersz dostaje unikalny numer: 1, 2, 3, 4... | Gdy potrzebujesz jednoznacznej kolejności, np. wybrać dokładnie jeden „pierwszy” rekord w grupie, usunąć duplikaty albo paginować wyniki. |
RANK() | Wiersze z remisem dostają ten sam ranking, a kolejny numer jest pomijany, np. 1, 2, 2, 4. | Gdy chcesz zachować klasyczny ranking, gdzie po remisie kolejne miejsce jest przesunięte. |
DENSE_RANK() | Wiersze z remisem dostają ten sam ranking, ale bez luk, np. 1, 2, 2, 3. | Gdy chcesz grupować po pozycjach rankingowych bez przerw w numeracji. |
ROW_NUMBER() wybierasz wtedy, gdy remis nie ma znaczenia biznesowego albo gdy świadomie chcesz wymusić pojedynczy wynik. Trzeba jednak pamiętać, że jeśli wartości sortowania nie są unikalne, kolejność między remisującymi wierszami może być nieokreślona, więc warto dodać dodatkowe kolumny do ORDER BY.
RANK() i DENSE_RANK() wybierasz wtedy, gdy remisy mają znaczenie i powinny być widoczne w wyniku. Różnica między nimi sprowadza się do tego, czy po remisie ma powstać luka w numeracji. Jeśli raport ma odzwierciedlać rzeczywiste miejsca w rankingu, częściej pasuje RANK(). Jeśli ważniejsze są kolejne poziomy pozycji bez przerw, lepsze będzie DENSE_RANK().
Jak użyć ROW_NUMBER do usuwania duplikatów lub wyboru „najnowszego” rekordu?
ROW_NUMBER() nadaje kolejne numery w ramach zdefiniowanej grupy, więc pozwala wskazać jeden rekord „do zostawienia”, a pozostałe potraktować jako duplikaty. Kluczowe są dwa elementy: PARTITION BY, które określa, po jakich kolumnach rozpoznajesz duplikat, oraz ORDER BY, które decyduje, który wiersz ma być uznany za właściwy, na przykład najnowszy.
Jeżeli chcesz wybrać tylko najnowszy rekord dla każdego klienta, produktu albo dokumentu, numerujesz wiersze w obrębie tej samej grupy malejąco po dacie lub innym znaczniku czasu. Wiersz z numerem 1 będzie wtedy najnowszy:
WITH x AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY CustomerID
ORDER BY ModifiedDate DESC, ID DESC
) AS rn
FROM dbo.Orders
)
SELECT *
FROM x
WHERE rn = 1;
Ten sam wzorzec stosuje się do usuwania duplikatów. Najpierw oznaczasz rekordy numerami, a potem usuwasz te z numerem większym niż 1. Zostaje tylko jeden wiersz w każdej grupie duplikatów:
WITH x AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY Email
ORDER BY CreatedAt DESC, ID DESC
) AS rn
FROM dbo.Users
)
DELETE FROM x
WHERE rn > 1;
Najważniejsze jest poprawne zdefiniowanie logiki. PARTITION BY musi obejmować kolumny, które rzeczywiście oznaczają duplikat, a ORDER BY musi jednoznacznie wskazywać, który rekord ma wygrać. Jeśli sama data może się powtarzać, warto dodać drugie kryterium, na przykład ID DESC, żeby wynik był deterministyczny. Bez tego SQL Server może wybrać różne wiersze przy kolejnych wykonaniach.
W praktyce ROW_NUMBER() jest najwygodniejsze wtedy, gdy chcesz zachować dokładnie jeden rekord z każdej grupy: najnowszy, najstarszy albo „najwyższy priorytetem”. Do samego wykrycia duplikatów wystarczy filtrowanie po rn > 1, a do zachowania jednego rekordu — po rn = 1.
Do czego służą LAG i LEAD i jak policzyć zmianę okres do okresu?
LAG i LEAD to funkcje okienkowe służące do odczytu wartości z innego wiersza w ramach tego samego zbioru, bez potrzeby wykonywania samozłączenia. LAG zwraca wartość z wcześniejszego wiersza, a LEAD z późniejszego, zgodnie z kolejnością zdefiniowaną w OVER (ORDER BY ...). W praktyce są używane wtedy, gdy trzeba porównać bieżący rekord z poprzednim lub następnym, na przykład dla kolejnych miesięcy, dni albo wersji danych.
Do policzenia zmiany okres do okresu najczęściej używa się LAG, ponieważ porównanie odbywa się względem poprzedniego okresu. Dla wartości sprzedaży można pobrać wartość z poprzedniego miesiąca i odjąć ją od bieżącej. Przykładowo: SalesAmount - LAG(SalesAmount) OVER (ORDER BY SalesMonth) zwróci zmianę bezwzględną. Jeśli potrzebna jest zmiana procentowa, wzór ma postać: (SalesAmount - LAG(SalesAmount) OVER (ORDER BY SalesMonth)) * 100.0 / LAG(SalesAmount) OVER (ORDER BY SalesMonth).
Kluczowe jest poprawne określenie kolejności w ORDER BY, bo to ona definiuje, co znaczy „poprzedni” i „następny” wiersz. Jeśli dane są liczone osobno dla różnych grup, na przykład dla każdego produktu lub klienta, należy dodać PARTITION BY, aby porównania nie mieszały się między grupami. Wtedy zapis wygląda na przykład tak: LAG(SalesAmount) OVER (PARTITION BY ProductID ORDER BY SalesMonth).
Trzeba też uwzględnić, że dla pierwszego wiersza w danym oknie LAG zwróci NULL, bo nie ma wcześniejszego rekordu, a dla ostatniego LEAD zwróci NULL. To normalne zachowanie i zwykle obsługuje się je przez CASE, COALESCE albo trzeci argument funkcji, czyli wartość domyślną. Dzięki temu można uniknąć błędów przy dalszych obliczeniach, zwłaszcza przy dzieleniu w zmianie procentowej.
LEAD działa analogicznie, ale przydaje się wtedy, gdy chcesz odnieść bieżący okres do następnego, na przykład sprawdzić różnicę między dzisiejszym stanem a jutrzejszym lub wykryć zmianę przed kolejnym zdarzeniem. Do klasycznej analizy okres do okresu najczęściej wystarcza jednak LAG, bo porównanie prawie zawsze dotyczy wartości historycznej.
Jakie są typowe błędy przy funkcjach okienkowych i jak ich uniknąć?
Najczęstszy błąd to zakładanie, że wynik funkcji okienkowej będzie deterministyczny, mimo że w ORDER BY wewnątrz OVER(...) nie ma pełnego porządku. Jeśli kilka wierszy ma tę samą wartość sortowania, ROW_NUMBER(), LAG() czy LEAD() mogą zwracać różne wyniki między uruchomieniami. Aby tego uniknąć, trzeba dodać kolumnę jednoznacznie rozstrzygającą kolejność, na przykład klucz główny lub znacznik czasu o wystarczającej precyzji.
Drugi typowy problem to błędne lub pominięte PARTITION BY. Bez podziału funkcja liczy wynik dla całego zbioru, co często prowadzi do porównań między różnymi klientami, dokumentami lub grupami danych. Z kolei zbyt szerokie partycjonowanie może rozbić dane na zbyt małe fragmenty i dać pozornie poprawny, ale biznesowo błędny rezultat. Przed użyciem funkcji warto jasno określić: co jest grupą analizy i w jakiej kolejności wiersze mają być porównywane.
Często spotykany jest też błąd filtrowania po wyniku funkcji okienkowej w tej samej warstwie zapytania. W SQL Server funkcje okienkowe są obliczane po WHERE, więc nie można ich tam użyć bezpośrednio. Poprawny wzorzec to najpierw policzyć wynik w CTE, podzapytaniu lub tabeli pochodnej, a dopiero potem filtrować, na przykład po ROW_NUMBER() = 1.
Inna pułapka dotyczy domyślnej ramki okna przy funkcjach agregujących. Dla takich funkcji jak SUM() czy AVG() samo ORDER BY w klauzuli OVER nie zawsze oznacza to, czego użytkownik oczekuje, zwłaszcza przy powtarzających się wartościach sortowania. Jeśli potrzebna jest kumulacja wiersz po wierszu, warto jawnie określić ramkę, np. ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, zamiast polegać na ustawieniach domyślnych.
Błędy pojawiają się również przy obsłudze wartości NULL i brakujących poprzednich wierszy. LAG() dla pierwszego wiersza w partycji zwróci NULL, chyba że podany zostanie trzeci argument domyślny. Jeśli tego nie uwzględnisz, porównania i obliczenia różnic mogą dawać niepełne lub mylące wyniki. Dobrą praktyką jest świadome ustawienie wartości domyślnej albo późniejsza obsługa NULL w logice zapytania.
Na koniec błąd wydajnościowy: funkcje okienkowe są wygodne, ale kosztowne przy dużych zbiorach, szczególnie gdy wymagają sortowania na kolumnach bez odpowiednich indeksów. Żeby tego uniknąć, należy ograniczać liczbę przetwarzanych wierszy przed etapem okienkowym, używać właściwych indeksów wspierających PARTITION BY i ORDER BY oraz sprawdzać plan wykonania. Najwięcej problemów bierze się nie z samej składni, tylko z nieprecyzyjnego określenia grupy, kolejności i oczekiwanego zakresu obliczeń.
Najczęściej zadawane pytania i odpowiedzi odnośnie Funkcje okienkowe w SQL Server: te 6 trików z ROW_NUMBER i LAG robi różnicę w raportach
Funkcje okienkowe są lepsze wtedy, gdy chcesz zachować wszystkie wiersze i jednocześnie dodać do nich obliczenia analityczne. GROUP BY zwraca zwykle jeden rekord na grupę, a OVER pozwala liczyć ranking, sumę narastającą lub porównanie z poprzednim rekordem bez utraty szczegółowości. To szczególnie przydatne w raportach, gdzie każdy wiersz ma pozostać widoczny.
Tak, ROW_NUMBER można użyć bez PARTITION BY, jeśli numeracja ma obejmować cały wynik jako jedną grupę. W takiej sytuacji SQL Server traktuje wszystkie zwrócone wiersze jako jedną partycję i nadaje im kolejne numery zgodnie z ORDER BY. To wygodne przy paginacji, globalnym rankingu albo wyborze jednego rekordu z całego zestawu.
ORDER BY powinno jednoznacznie rozstrzygać kolejność wszystkich remisujących wierszy. Jeśli sortujesz tylko po kolumnie z powtarzającymi się wartościami, wynik może być niedeterministyczny. W praktyce najlepiej dodać drugi klucz, który domknie kolejność, na przykład identyfikator. Najczęstszy bezpieczny wzorzec to:
- kolumna biznesowa, np. data lub kwota,
- dodatkowy unikalny klucz, np. ID.
Tak, LAG i LEAD często pozwalają zastąpić self-join przy porównywaniu sąsiednich rekordów. Zamiast ręcznie łączyć tabelę z samą sobą, możesz pobrać wartość z poprzedniego albo następnego wiersza bezpośrednio w jednym zapytaniu. Dzięki temu kod jest zwykle prostszy, czytelniejszy i lepiej pokazuje, że chodzi o analizę sekwencji danych.
Zmianę procentową z LAG trzeba liczyć z uwzględnieniem braku poprzedniego wiersza i ryzyka dzielenia przez zero. Najwygodniej najpierw wyciągnąć poprzednią wartość do CTE lub podzapytania, a dopiero potem budować wzór. W praktyce sprawdź dwie rzeczy:
- czy pierwszy wiersz ma wartość poprzednią równą NULL,
- czy poprzednia wartość nie jest zerem przed dzieleniem.
Nie można tego zrobić bezpośrednio, ponieważ funkcje okienkowe są obliczane po etapie WHERE. Oznacza to, że SQL Server nie zna jeszcze wartości ROW_NUMBER w momencie filtrowania warunkiem WHERE. Poprawny wzorzec polega na policzeniu numeracji w CTE, podzapytaniu albo tabeli pochodnej, a dopiero potem odfiltrowaniu wierszy, na przykład po rn = 1.
RANK lub DENSE_RANK wybierasz wtedy, gdy remisy mają znaczenie i powinny być widoczne w wyniku. ROW_NUMBER zawsze nadaje unikalny numer każdemu wierszowi, nawet przy takich samych wartościach sortowania. Jeśli raport ma pokazywać wspólne miejsce dla remisów, lepiej użyć funkcji rankingowych. RANK zostawia luki po remisach, a DENSE_RANK numeruje kolejne pozycje bez przerw.
Najważniejsze są sortowanie, liczba przetwarzanych wierszy i dopasowanie indeksów do PARTITION BY oraz ORDER BY. Funkcje okienkowe są wygodne, ale przy dużych zbiorach mogą być kosztowne. Dobrym podejściem jest najpierw ograniczenie danych do potrzebnego zakresu, a potem uruchomienie logiki okienkowej. Warto też sprawdzać plan wykonania, szczególnie gdy raport działa wolniej niż oczekiwano.