KajoData / SQL
ROWS UNBOUNDED PRECEDING w MySQL: suma narastająca bez niespodzianek
Przechodzę od sześciu operacji do sumy narastającej. Pokażę, dlaczego dwa zdarzenia tego samego dnia wymagają decyzji o kolejności i poziomie raportu.

Suma narastająca wygląda niewinnie: dodaj do obecnej wartości wszystko, co było wcześniej. Tylko co oznacza „wcześniej”, gdy dwie operacje mają tę samą datę? I od którego początku liczymy: całej tabeli, klienta, zamówienia czy miesiąca?
Ja rozbijam takie zadanie na trzy decyzje: podział danych, kolejność oraz granice sumowania. Dopiero z nich składam funkcję okienkową. W tym tekście używam MySQL 8.4 i małych danych syntetycznych. Nie zakładam, że nazwa ramki sama określi biznesowe znaczenie raportu.
Partycja, kolejność i ramka
Partycja to grupa wierszy, w której obliczenie działa osobno. PARTITION BY account_id rozdziela konta. ORDER BY wewnątrz OVER ustala ich kolejność na potrzeby obliczenia. Ramka wyznacza, które pozycje z tej partycji mają wejść do wyniku dla konkretnego wiersza.
Funkcja okienkowa pozwala zachować poszczególne operacje i dopisać obok ich sumę. Zwykłe grupowanie daje inną postać odpowiedzi: po jednym wierszu na grupę. Podstawową różnicę pokazuje dokumentacja funkcji okienkowych MySQL.
Wyobrażam sobie uporządkowane kartki dla jednego konta. Dla pierwszej kartki sumuję ją samą, dla drugiej pierwszą i drugą, dla trzeciej pierwszą, drugą i trzecią. To użyteczny obraz sumy narastającej. Nadal muszę jednak ustalić, jak układam kartki i gdzie zaczyna się nowy stos.
Co dokładnie oznacza UNBOUNDED PRECEDING
Zapis ROWS UNBOUNDED PRECEDING jest skrótem ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Początek to pierwsza pozycja partycji, a koniec to bieżąca pozycja, włącznie. Pełna składnia jest opisana w specyfikacji ramek MySQL 8.4.
W poradniku preferuję pełny zapis, bo od razu widać oba końce. W kodzie zespołowym skrót też jest poprawny, jeśli wszyscy rozumieją jego znaczenie. Słowo UNBOUNDED nie oznacza całej tabeli. Granica nadal należy do konkretnej partycji, a ta zależy od definicji OVER.
Nie tłumaczę ROWS jako kolejności zapisu rekordów na dysku. Chodzi o pozycje w kolejności okna. Bez odpowiedniego sortowania etykieta „narastająco” nie ma wystarczającego uzasadnienia. Baza nie odgadnie, czy za ważniejszą uważasz datę zdarzenia, datę księgowania czy kolejność importu.
Sześć operacji i pierwszy kompletny przykład
Przyjmuję operacje na dwóch umownych kontach. Kwoty to syntetyczne jednostki, a ujemna wartość oznacza korektę. event_id jednoznacznie rozstrzyga kolejność operacji z tego samego dnia.
| account_id | event_id | event_day | amount |
|---|---|---|---|
| A | 1 | 2026-01-01 | 100 |
| A | 2 | 2026-01-02 | 50 |
| A | 3 | 2026-01-02 | 30 |
| A | 4 | 2026-01-04 | -20 |
| B | 5 | 2026-01-01 | 40 |
| B | 6 | 2026-01-03 | 60 |
WITH events AS (
SELECT 'A' AS account_id, 1 AS event_id,
CAST('2026-01-01' AS DATE) AS event_day, 100 AS amount
UNION ALL SELECT 'A', 2, CAST('2026-01-02' AS DATE), 50
UNION ALL SELECT 'A', 3, CAST('2026-01-02' AS DATE), 30
UNION ALL SELECT 'A', 4, CAST('2026-01-04' AS DATE), -20
UNION ALL SELECT 'B', 5, CAST('2026-01-01' AS DATE), 40
UNION ALL SELECT 'B', 6, CAST('2026-01-03' AS DATE), 60
)
SELECT account_id, event_id, event_day, amount,
SUM(amount) OVER (
PARTITION BY account_id
ORDER BY event_day, event_id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_amount
FROM events
ORDER BY account_id, event_day, event_id;
Dla konta A kolejne wyniki to 100, 150, 180, 160. Dla konta B to 40, 100. Drugie konto zaczyna własne obliczenie, ponieważ ma oddzielną partycję. Ujemna korekta zmniejsza sumę, więc słowo „narastająca” nie obiecuje, że wynik zawsze rośnie.
Blok WITH jest CTE, dostępnym w obrębie tego polecenia. W dalszych fragmentach events oznacza ten sam zbiór. Do samodzielnego wykonania fragmentu dołącz ten blok ponownie. Nie tworzę tu trwałej tabeli ani procesu księgowego.
Remis daty: dlaczego sama data nie wystarcza
Na koncie A drugiego stycznia występują dwie operacje. Jeżeli posortujesz tylko po dacie i zastosujesz ROWS, jedna będzie wcześniej, ale samo zapytanie nie wybierze jednoznacznie która. Kwota 50 może wejść przed 30 albo odwrotnie. Suma na koniec tego dnia wynosi 180 w obu wariantach.
Różni się wynik pośredni przypisany konkretnej operacji. Przy naszej jawnej kolejności operacja numer dwa ma 150, a numer trzy 180. Po odwróceniu dwóch równorzędnych pozycji operacja numer trzy mogłaby otrzymać 130. Nie chcę, żeby raport salda pojedynczej operacji zależał od technicznego przypadku.
Dlatego dodałem event_id do sortowania. To rozstrzygnięcie jest częścią syntetycznego przykładu. W prawdziwym systemie ustal, czy identyfikator rzeczywiście odzwierciedla pożądaną kolejność. Losowy identyfikator może stabilizować wynik, lecz nie potwierdza chronologii zdarzeń, które dotarły z opóźnieniem.
RANGE i domyślna ramka
W MySQL 8.4 pominięcie ramki przy obecnym ORDER BY daje domyślnie RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. RANGE obejmuje również wiersze równorzędne według sortowania. Dla samego event_day obie operacje drugiego stycznia są takimi remisami.
SELECT event_id, event_day, amount,
SUM(amount) OVER (
PARTITION BY account_id
ORDER BY event_day
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS total_through_day
FROM events
WHERE account_id = 'A'
ORDER BY event_day, event_id;
Wyniki przypisane operacjom A to 100, 180, 180, 160. Obie pozycje z drugiego stycznia pokazują sumę obejmującą cały ten dzień. To sensowna odpowiedź, jeśli właśnie tak zdefiniowałeś raport. Różnica względem ROWS jest widoczna bez dużego zbioru i bez żadnego losowania danych.
Dopisanie event_id do tego okna zmieniłoby definicję remisu. Nie porównuj zatem ROWS i RANGE po jednoczesnej zmianie kolumn sortowania, a potem nie przypisuj całej różnicy samej ramce. Podczas diagnozy zmieniam jeden element i zapisuję oczekiwane znaczenie każdej kolumny wyniku.

ORDER BY w oknie i na końcu zapytania
Sortowanie wewnątrz OVER służy obliczeniu. Końcowe ORDER BY służy prezentacji wyniku. W przykładzie podałem oba, bo chcę liczyć według daty i identyfikatora oraz w tej samej kolejności pokazać wynik. To dwie jawne decyzje, które akurat są zgodne.
Mogę wyświetlić operacje malejąco, zachowując sumę liczoną rosnąco. Wtedy najnowszy wiersz konta A pokaże 160 na górze. Nie oznacza to, że suma była liczona od końca. Odczytujący raport musi wiedzieć, co opisuje kolumna salda przy każdej operacji.
Jeżeli zmienię kierunek sortowania wewnątrz OVER, zmienię zbiór wcześniejszych pozycji. To wpływa na obliczenie. Dlatego przy poprawianiu wyglądu raportu nie przenoszę bezmyślnie końcowego sortowania do definicji okna. Układ tabeli i kierunek kumulowania mogą realizować różne potrzeby czytelnika.
Ostatnie wiersze a ostatnie dni
Ramka ROWS BETWEEN 1 PRECEDING AND CURRENT ROW obejmuje obecną i najwyżej jedną poprzednią pozycję. Dla A daje sumy 100, 150, 80, 10. Pierwsza pozycja ma tylko siebie. Trzecia obejmuje kwoty 50 i 30, a czwarta 30 i -20.
To dwa ostatnie zdarzenia, a nie dwa dni. Między drugim a czwartym stycznia jest luka, a drugiego stycznia są dwie pozycje. Jeśli raport ma mówić o dniach kalendarzowych, najpierw potrzebujesz właściwego poziomu danych i decyzji dotyczącej dni bez operacji.
Dla dziennej sumy narastającej mogę najpierw zgrupować operacje po koncie i dacie. Konto A ma wtedy kwoty dzienne 100, 80 i -20, a wyniki narastające 100, 180 i 160. Nie pomyliłem dwóch różnych operacji z dwoma różnymi dniami.
Siedmiodniowa średnia wymaga jeszcze decyzji o pustych dniach. Średnia po dniach z aktywnością może różnić się od średniej po wszystkich dniach. Wiersz kalendarza bez zdarzenia i zdarzenie z nieznaną kwotą też nie muszą znaczyć tego samego. Najpierw ustalam mianownik, później piszę ramkę.
Filtr może zmienić punkt startu
Funkcje okienkowe MySQL działają na wierszach pozostałych po WHERE, GROUP BY i HAVING. Jeżeli w tym samym poziomie zapytania odfiltrujesz początek historii, nie wejdzie on do obliczenia. Końcowe ograniczenie widocznych rekordów trzeba odróżnić od ograniczenia źródła sumy.
Dla konta A filtr event_day >= '2026-01-04' przed oknem zostawi tylko korektę -20. Suma dla tego dnia wyniesie -20. Jeśli najpierw policzysz całą historię w CTE, a dopiero potem pokażesz czwarty stycznia, zobaczysz 160. Oba wyniki są logiczne dla innych pytań.
WITH calculated AS (
SELECT account_id, event_id, event_day, amount,
SUM(amount) OVER (
PARTITION BY account_id
ORDER BY event_day, event_id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_amount
FROM events
)
SELECT *
FROM calculated
WHERE event_day >= '2026-01-04'
ORDER BY account_id, event_day, event_id;
Ten fragment zakłada dostępność opisanego zbioru events; jego CTE możesz dodać przed calculated w tym samym WITH. Nie próbuj odwoływać się do aliasu wyniku okna w WHERE tego samego SELECT. Oddzielny poziom pozwala jasno określić, co liczymy, a co jedynie wyświetlamy.
Suma operacji nie zawsze jest saldem
W przykładzie zaczynamy od zera. Prawdziwe saldo może wymagać stanu początkowego sprzed dostępnej historii. Gdy w bazie masz tylko listopadowe ruchy, ich suma nie ujawni stanu na koniec października. Nawet idealnie zapisana ramka nie odtworzy brakującego wejścia.
Ustaliłbym osobno kwotę początkową, zakres ruchów i regułę dla korekt. Czy korekta zmienia historyczny dzień, czy jest nową operacją w dniu księgowania? Te warianty dają inne ścieżki narastania. Nazwa „saldo” powinna odpowiadać konkretnej, uzgodnionej definicji, zwłaszcza gdy raport jest porównywany ze źródłem.
Ważne są również jednostki. Nie sumuj w jednym koncie różnych walut ani liczby sztuk z wartością sprzedaży. PARTITION BY może pomóc rozdzielić serie, ale najpierw trzeba wskazać wszystkie wymiary, które czynią sumę sensowną. Jedna kolumna techniczna nie zawsze reprezentuje jedną spójną serię.
Zastanów się też, jak raport zachowa się po spóźnionym imporcie. Jeśli jutro dodasz operację z wcześniejszą datą, ponownie policzona historia może zmienić późniejsze stany. To oczekiwany skutek nowego wejścia, choć wcześniej wysłany plik pokazywał inne liczby. Przy porównywaniu eksportów zapisuję moment odczytu danych i zakres dostępnej historii. Samo stabilne sortowanie nie gwarantuje identycznych wyników dla dwóch różnych stanów źródła. Rozbieżność trzeba wyjaśnić przez brakującą operację albo regułę korekt, zanim uznasz ją za błąd samej funkcji okna.
Braki, całe partycje i LAST_VALUE
SUM w MySQL pomija NULL, a przy braku wartości do zsumowania daje NULL. Jeśli kwota jest nieznana, automatyczne podstawienie zera może sprawić, że suma będzie wyglądała na kompletną. Do raportu jakości dodałbym liczbę braków, zamiast ukrywać je wygodnym wynikiem.
Do sumy całej partycji przy każdym wierszu wystarczy SUM(amount) OVER (PARTITION BY account_id). Jeśli jawnie określasz pełną ramkę, jej koniec to UNBOUNDED FOLLOWING. Taki wynik odpowiada na pytanie o całość konta w wybranym źródle, bez stopniowego narastania.
Uważaj na LAST_VALUE: odnosi się do ostatniej pozycji ramki. Przy ramce kończącej się na bieżącym wierszu nie musi wskazywać ostatniej operacji konta. Z kolei MySQL ignoruje ramkę między innymi dla ROW_NUMBER i LAG. Nazwy funkcji nie gwarantują wspólnego sposobu użycia granic.
Kiedy obliczenie ma się zerować
Początek partycji jest częścią definicji miary. W przykładzie konto A ma jeden wspólny przebieg. Jeśli raport ma pokazywać ruchy narastająco w każdym miesiącu osobno, potrzebujesz granicy miesiąca w podziale danych. Samo wyświetlenie nazwy miesiąca obok wyniku niczego nie zeruje. To kolumna opisowa, dopóki nie uczestniczy w odpowiedniej regule obliczenia.
Nie używałbym samego numeru miesiąca jako jedynego rozróżnienia okresów. Styczeń jednego roku i styczeń następnego roku to różne okresy. Najpierw przygotowałbym jednoznaczny klucz miesiąca obejmujący rok, a następnie rozdzielił serie według konta i tego klucza. Unikam dzięki temu niejawnego połączenia zdarzeń odległych o rok.
Warto odróżnić sprzedaż od początku miesiąca od aktualnego stanu konta. Pierwsza miara naturalnie zaczyna liczenie od nowego okresu. Druga może potrzebować przeniesionego stanu. Gdy te dwie wartości stoją obok siebie na dashboardzie, nadaj im różne nazwy i opisz zakres. Czytelnik nie powinien odgadywać, dlaczego jedna suma zaczyna się od zera, a druga od wcześniejszego salda.
Wyobraź sobie dodatkową operację w lutym. W przebiegu całej historii powiększa ona styczniowy wynik. W przebiegu miesięcznym staje się pierwszym wkładem nowej partycji. Te dwie prognozy potrafisz opisać przed pisaniem SQL. Jeśli nie wiadomo, która jest oczekiwana, problem wymaga doprecyzowania definicji raportu, a nie szukania innej funkcji okna.
Pozycje zamówienia i właściwa jednostka wiersza
Ten sam mechanizm nadaje się do sumowania ilości kolejnych pozycji zamówienia. Wtedy naturalnym kandydatem partycji jest identyfikator zamówienia, a miarą liczba sztuk na pozycji. Pozostaje pytanie o kolejność. Numer produktu nie musi jednoznacznie identyfikować pozycji: ten sam produkt może pojawić się dwa razy, choćby z różnymi rabatami lub terminami dostawy.
W takim raporcie najpierw wskazałbym identyfikator pozycji albo jawny numer porządkowy. Następnie zdecydowałbym, czy czytelnik potrzebuje przebiegu według kolejności zamawiania, magazynowego kompletowania czy prezentacji na dokumencie. Każdy wariant może być poprawny, ale oznacza inne „do tej pozycji”. Sortowanie alfabetyczne produktu nie daje automatycznie historii realizacji zamówienia.
Samo zachowanie liczby wierszy przez funkcję okienkową ułatwia pomylenie miar. Obok trzech pozycji możesz mieć trzy narastające ilości. Jeśli później ktoś zsumuje tę nową kolumnę, wielokrotnie policzy wcześniejsze wkłady. Dla kwot A suma wartości 100, 150, 180 i 160 nie jest łączną wartością operacji. Końcowy stan i suma kolejnych stanów odpowiadają na inne pytania.
Dlatego w modelu lub eksporcie opisuję kolumnę jako wartość na danym etapie. Przy późniejszym agregowaniu wracam do kwot zdarzeń albo wybieram właściwy stan końcowy dla każdej serii. Nie wybieram bezmyślnie największego salda: po ujemnej korekcie maksimum może dotyczyć wcześniejszego momentu, a stan na koniec jest mniejszy.
Jak czytam trudniejsze zapytanie
Przy cudzym kodzie zaczynam od wejścia do funkcji. Wypisuję znaczenie wiersza po złączeniach i grupowaniu. Następnie odczytuję partycję, kierunek i dodatkowe kryteria kolejności. Dopiero potem czytam granice ramki. Dzięki temu nie spędzam czasu na porównywaniu wariantów ROWS, gdy problemem jest wcześniej powielona pozycja zamówienia.
Kolejny krok to ręczne rozpisanie jednej krótkiej serii. Dla każdej pozycji zaznaczam wejście i wyjście z ramki oraz wartości używane w sumie. Przy małych danych nie potrzebuję setek wierszy, żeby zauważyć, że filtr usunął wcześniejszy wkład albo remisy nie mają ustalonej kolejności. Długi wydruk potrafi utrudnić zobaczenie prostej zależności.
W większym zbiorze rozdzielam poprawność definicji od kosztu wykonania. Czytelna ramka nie gwarantuje szybkiego raportu, a szybki raport nie potwierdza właściwego znaczenia wyniku. Najpierw porównuję oczekiwane przypadki na zrozumiałym zakresie, potem analizuję sposób przetwarzania rzeczywistych danych. Nie podaję uniwersalnej obietnicy przyspieszenia po dopisaniu jednego indeksu bez wiedzy o całym zapytaniu.
Ćwiczenie i sposób sprawdzania raportu
Dodaj na koncie A operację numer siedem, piątego stycznia, z kwotą 10. Najpierw przewidź ostatnią sumę narastającą: 170. Potem policz sumę dwóch ostatnich zdarzeń: -10. Zapisz, które wiersze weszły do każdego wyniku. Jeżeli wyjaśnienie opiera się tylko na nazwie funkcji, wróć do danych.
Przed oddaniem raportu obejrzyj pierwszą pozycję każdej partycji, dwie pozycje z tą samą datą i jedną ujemną korektę. Sprawdź także filtr początku okresu. Te małe przypadki odsłaniają różne błędy: brak podziału, niejednoznaczną kolejność, niewłaściwą ramkę i odciętą historię.
Jeżeli obliczenie działa po JOIN, sprawdź jeszcze, czy złączenie nie powieliło operacji. Ramka sumuje otrzymane wiersze, więc powielone wejście daje powielony wkład. Wróć wtedy do LEFT JOIN i liczby wierszy oraz kolejności klauzul SQL. Dopiero na poprawnym wejściu warto oceniać wynik narastający.
Najważniejsze w pigułce
Co oznacza ROWS UNBOUNDED PRECEDING?
Ramka obejmuje pozycje od początku bieżącej partycji do bieżącego wiersza. Pełny zapis kończy się AND CURRENT ROW.
Czy domyślna ramka z ORDER BY to ROWS?
W MySQL 8.4 domyślna ramka z ORDER BY to RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Obejmuje też remisy według wyrażeń sortowania.
Czy ROWS 6 PRECEDING oznacza ostatnie siedem dni?
Obejmuje bieżący i najwyżej sześć wcześniejszych wierszy. Dni odpowiadają wierszom dopiero po przygotowaniu odpowiedniej, kompletnej serii dziennej.
Przy pracy nad artykułem użyto w zautomatyzowany sposób sztucznej inteligencji. Jeśli też chcesz tak umieć, sprawdź ten projekt.
