Funkcje okna w PostgreSQL - rankingi, sumy i LAG

Radosław Krajewski 20 czerwca 2026
Schemat przedstawia kategorie funkcji okna w PostgreSQL: agregujące, wartości i rankingowe, z przykładami jak AVG(), RANK() i LAG().

Spis treści

Raport ma pokazać sprzedaż każdego klienta, miejsce produktu w rankingu albo zmianę wyniku względem poprzedniego miesiąca, ale nie chcesz tracić szczegółowych wierszy. Właśnie tutaj przydają się funkcje okna w PostgreSQL, opisywane także jako postgres window function. Pokażę składnię, najważniejsze funkcje, praktyczne zapytania, pułapki związane z ramą okna oraz sposób filtrowania wyników.

Funkcje okna pozwalają analizować wiersze bez ich grupowania

  • OVER uruchamia funkcję w trybie okna i określa, które wiersze mają być analizowane.
  • PARTITION BY dzieli dane na niezależne grupy, a ORDER BY ustala kolejność obliczeń.
  • row_number(), rank() i dense_rank() rozwiązują różne problemy z rankingami i remisami.
  • lag() i lead() pozwalają porównywać bieżący wiersz z poprzednim lub następnym.
  • Do filtrowania wyniku funkcji okna potrzebujesz podzapytania albo CTE, ponieważ nie można użyć jej bezpośrednio w WHERE.

Plan zapytania PostgreSQL dla `select * from pg_indexes;` z Nested Loop jako najwolniejszym węzłem, bez użycia funkcji okna.

Na czym polega działanie funkcji okna

Zwykłe GROUP BY zamienia wiele wierszy w jeden wiersz dla każdej grupy. Funkcja okna działa inaczej: wykonuje obliczenie na powiązanym zbiorze rekordów, ale zachowuje każdy wiersz wyniku. Dzięki temu możesz pokazać sprzedaż konkretnego zamówienia i jednocześnie średnią sprzedaż jego klienta.

Najprostszy przykład wygląda tak:

SELECT
    customer_id,
    order_id,
    amount,
    AVG(amount) OVER (PARTITION BY customer_id) AS customer_average
FROM orders;

Dla każdego zamówienia PostgreSQL obliczy średnią z zamówień tego samego klienta. Kolumna amount nadal opisuje pojedyncze zamówienie, a customer_average daje mu szerszy kontekst. To jedna z tych konstrukcji SQL, które początkowo wyglądają jak magia, ale po zrozumieniu zasady szybko stają się codziennym narzędziem.

Trzy elementy klauzuli OVER

Klauzula OVER może zawierać trzy elementy. Nie zawsze potrzebujesz wszystkich, ale każdy odpowiada za inną część analizy.

Element Znaczenie Przykład
PARTITION BY Dzieli dane na niezależne grupy PARTITION BY customer_id
ORDER BY Ustala kolejność w obrębie grupy ORDER BY sold_at
Rama okna Określa zakres wierszy używany przez obliczenie ROWS BETWEEN 6 PRECEDING AND CURRENT ROW

Bez PARTITION BY cała tabela jest traktowana jako jedna grupa. Bez ORDER BY funkcja nadal może policzyć na przykład średnią dla grupy, ale nie ma sensownej kolejności dla numerowania, wartości poprzedniej czy sumy narastającej.

Składnia, od której najlepiej zacząć

Ogólny wzór jest prosty:

funkcja(argument) OVER (
    PARTITION BY kolumna_grupująca
    ORDER BY kolumna_sortująca
);

Przykładowo, numer zamówienia klienta w kolejności od najnowszego może wyglądać tak:

SELECT
    customer_id,
    order_id,
    sold_at,
    ROW_NUMBER() OVER (
        PARTITION BY customer_id
        ORDER BY sold_at DESC, order_id DESC
    ) AS order_number
FROM orders;

Drugie sortowanie po order_id nie jest ozdobnikiem. Gdy dwie daty są identyczne, dodatkowa kolumna zapewnia deterministyczny wynik. Bez niej PostgreSQL może ustawić remisujące wiersze w kolejności, na której nie powinieneś opierać logiki aplikacji.

ORDER BY w oknie a ORDER BY całego zapytania

To częste źródło nieporozumień. ORDER BY wewnątrz OVER steruje obliczeniem funkcji, natomiast końcowe ORDER BY steruje kolejnością zwróconych rekordów.

SELECT
    product_id,
    sold_at,
    amount,
    SUM(amount) OVER (
        PARTITION BY product_id
        ORDER BY sold_at
    ) AS running_total
FROM sales
ORDER BY sold_at DESC;

W tym przykładzie suma narastająca jest liczona chronologicznie, ale wynik może zostać pokazany od najnowszej daty. Te dwa porządki są niezależne i mogą być różne.

Rama okna ma znaczenie

Przy funkcjach takich jak SUM, AVG czy LAST_VALUE sama partycja często nie wystarcza. Rama okna mówi, czy obliczenie obejmuje wszystkie wcześniejsze rekordy, kilka rekordów obok bieżącego wiersza, czy tylko określony zakres wartości.

SELECT
    sold_at,
    amount,
    SUM(amount) OVER (
        ORDER BY sold_at
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS running_total
FROM sales
ORDER BY sold_at;

ROWS odnosi się do fizycznych wierszy. RANGE działa na wartościach sortowania i może obejmować także rekordy z taką samą wartością ORDER BY. Przy powtarzających się datach albo kwotach różnica bywa widoczna, dlatego do klasycznej sumy narastającej najczęściej wybieram ROWS z jednoznacznym sortowaniem.

Rankingi i wybieranie najlepszych rekordów

PostgreSQL ma kilka funkcji rankingowych, które wyglądają podobnie, ale zachowują się inaczej przy remisach. W praktycznych raportach ta różnica ma duże znaczenie.

Funkcja Zachowanie przy remisie Typowe zastosowanie
row_number() Każdy wiersz dostaje inny numer Wybór dokładnie jednego rekordu lub numerowanie
rank() Remisy mają ten sam numer, a po nich powstaje luka Ranking sportowy lub wynikowy
dense_rank() Remisy mają ten sam numer, ale bez luk Poziomy, klasy i grupy wyników
ntile(n) Dzieli uporządkowane dane na n grup Kwartyle, segmentacja klientów

Załóżmy, że chcesz znaleźć najlepiej zarabiające produkty w każdej kategorii:

SELECT
    category_id,
    product_id,
    revenue,
    RANK() OVER (
        PARTITION BY category_id
        ORDER BY revenue DESC
    ) AS category_rank
FROM product_sales;

RANK() jest dobrym wyborem, gdy dwa produkty z takim samym przychodem powinny zajmować wspólne miejsce. Jeśli potrzebujesz zawsze dokładnie jednego zwycięzcy, użyj ROW_NUMBER() i dodaj stabilny warunek rozstrzygający, na przykład product_id.

Top N w każdej grupie

Nie da się poprawnie napisać takiego warunku bezpośrednio w WHERE, ponieważ funkcje okna są obliczane później niż filtrowanie. Rozwiązaniem jest podzapytanie:

SELECT
    category_id,
    product_id,
    revenue
FROM (
    SELECT
        category_id,
        product_id,
        revenue,
        ROW_NUMBER() OVER (
            PARTITION BY category_id
            ORDER BY revenue DESC, product_id
        ) AS position
    FROM product_sales
) ranked
WHERE position <= 3
ORDER BY category_id, position;

To zapytanie zwraca trzy produkty z każdej kategorii. Jeśli chcesz uwzględnić wszystkie produkty remisujące na trzecim miejscu, zamień ROW_NUMBER() na RANK(). Wynik może wtedy zawierać więcej niż trzy rekordy w jednej kategorii, co jest prawidłowym skutkiem dopuszczenia remisów.

Sumy narastające i porównywanie sąsiednich wierszy

Funkcje okna dobrze sprawdzają się w analizach czasowych. Możesz policzyć sumę narastającą, zmianę względem poprzedniego miesiąca albo średnią z ostatnich siedmiu dni bez wielokrotnego łączenia tabeli z samą sobą.

Suma narastająca

SELECT
    sold_at,
    amount,
    SUM(amount) OVER (
        ORDER BY sold_at, sale_id
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS cumulative_amount
FROM sales
ORDER BY sold_at, sale_id;

Dodanie sale_id do sortowania rozwiązuje problem kilku transakcji z tą samą datą. W praktyce właśnie takie szczegóły odróżniają raport, który można bezpiecznie wykorzystać w aplikacji, od demonstracyjnego przykładu działającego tylko na małej tabeli.

Poprzednia i następna wartość

LAG() pobiera wartość z wcześniejszego wiersza, a LEAD() z późniejszego. Obie funkcje respektują kolejność zdefiniowaną w OVER.

SELECT
    month,
    revenue,
    LAG(revenue) OVER (ORDER BY month) AS previous_revenue,
    revenue - LAG(revenue) OVER (ORDER BY month) AS change
FROM monthly_revenue
ORDER BY month;

Pierwszy wiersz nie ma poprzednika, więc LAG() zwróci NULL. Możesz podać wartość domyślną, ale zwykle wolę zostawić NULL i obsłużyć brak porównania jawnie, zamiast udawać, że zmiana wynosi zero.

Średnia krocząca

SELECT
    measured_at,
    value,
    AVG(value) OVER (
        ORDER BY measured_at
        ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
    ) AS seven_readings_average
FROM measurements
ORDER BY measured_at;

To jest średnia z maksymalnie siedmiu kolejnych pomiarów, a niekoniecznie z siedmiu dni kalendarzowych. Gdy w danych brakuje dni, ROWS liczy rekordy, nie daty. Dla analizy kalendarzowej najpierw trzeba przygotować serię dat i uzupełnić brakujące obserwacje.

Najczęstsze błędy i ograniczenia

Najbardziej zdradliwy błąd polega na użyciu funkcji okna w WHERE, GROUP BY albo HAVING. PostgreSQL nie pozwala na takie wyrażenie, ponieważ funkcja okna działa po tych etapach przetwarzania. Użyj podzapytania lub CTE, a dopiero w zewnętrznym zapytaniu filtruj obliczoną kolumnę.

WITH ranked AS (
    SELECT
        product_id,
        revenue,
        RANK() OVER (ORDER BY revenue DESC) AS position
    FROM product_sales
)
SELECT product_id, revenue
FROM ranked
WHERE position <= 10;

Drugi błąd to pomijanie remisów. ROW_NUMBER() zawsze numeruje wiersze osobno, nawet gdy wartości są identyczne. Jeżeli raport ma znaczenie biznesowe, najpierw ustal, czy remis ma być wspólnym miejscem, czy trzeba wymusić kolejność dodatkową kolumną.

Pułapka z LAST_VALUE

LAST_VALUE() często zwraca wynik inny od oczekiwanego, ponieważ domyślna rama kończy się na bieżącym wierszu. W efekcie funkcja może zwrócić wartość bieżącego rekordu zamiast ostatniej wartości całej partycji.

SELECT
    customer_id,
    sold_at,
    amount,
    LAST_VALUE(amount) OVER (
        PARTITION BY customer_id
        ORDER BY sold_at, sale_id
        ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
    ) AS last_customer_amount
FROM sales;

Jawne określenie ramy sprawia, że PostgreSQL analizuje całą partycję. Przy FIRST_VALUE() problem zwykle nie jest tak widoczny, ale również dobrze rozumieć ramę zamiast polegać na domyślnym zachowaniu.

Przeczytaj również: Pętla do-while w C# - składnia, przykłady i pułapki

Wydajność dużych zapytań

Funkcje okna często wymagają sortowania danych. Przy milionach rekordów może to być kosztowny etap, szczególnie gdy jedno zapytanie używa kilku różnych definicji okna. Sprawdzam wtedy plan przez EXPLAIN (ANALYZE, BUFFERS), ograniczam dane możliwie wcześnie i upewniam się, że sortowanie nie odbywa się na zbędnych kolumnach.

Indeks może pomóc w filtrowaniu i dostarczeniu danych w użytecznej kolejności, ale nie gwarantuje automatycznie taniego wykonania funkcji okna. Koszt zależy od filtrów, liczby partycji, kolejności sortowania, rozmiaru tabeli i ilości pamięci dostępnej dla operacji sortowania.

Jak dobierać funkcję do konkretnego problemu

Najlepiej nie zaczynać od nazwy funkcji, tylko od pytania, które ma odpowiedzieć raport. Gdy potrzebuję numeru rekordu, wybieram ROW_NUMBER(). Gdy analizuję miejsce z uwzględnieniem remisów, wybieram RANK() albo DENSE_RANK(). Do różnic w czasie używam LAG() i LEAD(), a do trendów agregatów z klauzulą OVER.

Potrzeba Najlepszy wybór Na co uważać
Numerowanie rekordów w grupie ROW_NUMBER() Dodaj jednoznaczne sortowanie
Ranking z remisami i lukami RANK() Po remisie kolejne miejsce może być pominięte
Ranking bez luk DENSE_RANK() Wszystkie wartości w grupie muszą mieć właściwe znaczenie biznesowe
Zmiana względem poprzedniego rekordu LAG() Pierwszy wiersz zwróci NULL
Suma lub średnia narastająca SUM() albo AVG() z OVER Zdefiniuj właściwą ramę okna
Podział na segmenty NTILE() Grupy nie zawsze będą miały identyczną liczbę wierszy

Możesz użyć kilku funkcji okna w jednym zapytaniu. Jeśli wszystkie mają tę samą definicję partycji i sortowania, PostgreSQL pozwala nazwać okno w klauzuli WINDOW, co poprawia czytelność:

SELECT
    customer_id,
    amount,
    SUM(amount) OVER customer_window AS total_amount,
    AVG(amount) OVER customer_window AS average_amount
FROM sales
WINDOW customer_window AS (
    PARTITION BY customer_id
);

W kodzie aplikacji, także w projektach .NET korzystających z PostgreSQL, takie zapytania najlepiej trzymać blisko modelu raportowego i testować na danych z remisami, brakującymi datami oraz pustymi wartościami. To właśnie te przypadki najczęściej ujawniają, że pozornie poprawna funkcja okna wymaga doprecyzowania.

Funkcje okna warto projektować razem z logiką raportu

Najważniejsza zasada jest prosta. Najpierw określ grupę, kolejność i regułę remisów, a dopiero potem wybierz funkcję. Większość trudnych błędów nie wynika z samej składni, tylko z niejasnej odpowiedzi na pytanie, które wiersze powinny należeć do analizowanego okna.

Jeżeli wynik ma trafić do produkcyjnego raportu, sprawdź go na małym, ręcznie policzonym zbiorze, a później uruchom EXPLAIN ANALYZE na danych o realnej wielkości. Taka chwila kontroli zwykle oszczędza więcej czasu niż późniejsze poprawianie rankingu, który źle traktuje remisy albo sumy narastającej zależnej od przypadkowej kolejności.

FAQ - Najczęstsze pytania

GROUP BY redukuje wiele wierszy do jednego wiersza dla każdej grupy. Funkcje okna wykonują obliczenia na powiązanych rekordach, ale zachowują każdy wiersz wyniku, dzięki czemu można pokazać pojedyncze zamówienie oraz średnią klienta.

ROW_NUMBER() nadaje każdemu wierszowi osobny numer, więc sprawdza się przy wyborze dokładnie jednego rekordu. RANK() przyznaje ten sam numer przy remisie i pozostawia luki, a DENSE_RANK() również uwzględnia remisy, ale nie tworzy luk.

Najpierw oblicz pozycję za pomocą ROW_NUMBER() z PARTITION BY category_id i ORDER BY revenue DESC, a następnie odfiltruj wynik w podzapytaniu lub CTE warunkiem position <= 3. Jeśli chcesz uwzględnić wszystkie produkty remisujące na trzecim miejscu, użyj RANK(), ponieważ wynik może wtedy zawierać więcej niż trzy rekordy.

ROWS odnosi się do fizycznych wierszy, natomiast RANGE działa na wartościach sortowania i może obejmować rekordy z taką samą wartością ORDER BY. Przy powtarzających się datach do klasycznej sumy narastającej najlepiej użyć ROWS oraz jednoznacznego sortowania, na przykład z dodatkowym sale_id.

Oceń artykuł

Ocena: 0.00 Liczba głosów: 0

Tagi

funkcje okna
rankingi
rama okna
suma narastająca
cte
Autor Radosław Krajewski
Radosław Krajewski
Nazywam się Radosław Krajewski i od 6 lat zgłębiam tajniki programowania .NET, chmury Azure oraz sztucznej inteligencji. Moja przygoda z tymi technologiami zaczęła się od fascynacji tym, jak złożone problemy można rozwiązywać za pomocą kodu i innowacyjnych narzędzi. Staram się przekazywać tę wiedzę w sposób zrozumiały, dzieląc się swoimi doświadczeniami i spostrzeżeniami na kursdotnet.pl. W moich artykułach skupiam się na praktycznych aspektach, porównuję różne rozwiązania i analizuję najnowsze trendy, aby dostarczyć Wam rzetelne i aktualne informacje, które pomogą Wam rozwijać się w tej dynamicznie zmieniającej się dziedzinie.

Udostępnij artykuł

Napisz komentarz