A running total sounds simple: add this row to everything that came before it. The difficult part is defining what “before” means. In DB2 for z/OS, an OLAP or window specification makes that definition explicit. PARTITION BY decides where the calculation resets, ORDER BY establishes the sequence, and a ROWS or RANGE frame decides which neighbouring rows participate.
This tutorial builds practical patterns with SUM, AVG, and COUNT, then uses LAG and LEAD to compare adjacent rows. It also explains ties, nulls, moving windows, deterministic ordering, and the sort and work-file costs that matter on production z/OS systems.
A conventional GROUP BY changes the grain of a result. If twenty transactions belong to one account, grouping by account normally turns those twenty rows into one row. A window aggregate does not collapse them. Db2 evaluates the function over a related set of rows and places the answer beside each current detail row.
12345678SELECT ACCOUNT_ID, TXN_TS, TXN_ID, AMOUNT, SUM(AMOUNT) OVER ( PARTITION BY ACCOUNT_ID ) AS ACCOUNT_TOTAL FROM ACCOUNT_TXN;
Every transaction remains visible, but each carries its account total. With no window ORDER BY, the aggregate sees the whole partition. That is not yet a running total: every row in one account receives the same answer. Adding window ORDER BY introduces a meaningful current position, and adding a frame states how far backward or forward the aggregate can see.
| Pattern | Typical expression | Purpose |
|---|---|---|
| Partition total | SUM(amount) OVER (PARTITION BY account_id) | Repeat one account total on every account row |
| Running total | SUM(amount) OVER (... ORDER BY ... ROWS UNBOUNDED PRECEDING) | Accumulate from partition start through current row |
| Moving average | AVG(amount) OVER (... ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) | Average the current row and two preceding rows |
| Position in partition | COUNT(*) OVER (... ROWS UNBOUNDED PRECEDING) | Count rows reached so far |
| Previous or next value | LAG(expr) / LEAD(expr) OVER (PARTITION BY ... ORDER BY ...) | Compare neighbouring rows without a self-join |
PARTITION BY divides the rows that survive FROM, JOIN, and WHERE into independent partitions. A running account balance normally partitions by ACCOUNT_ID. A departmental payroll total partitions by WORKDEPT. If you omit PARTITION BY, the entire qualifying result is one partition, so the calculation never resets.
Partitioning is logical; it does not require the table itself to use table-controlled partitioning or a partitioned table space. The expressions in the OLAP clause simply define groups for this calculation. A composite business key can require several expressions, such as PARTITION BY COMPANY_ID, ACCOUNT_ID.
ORDER BY inside OVER defines calculation order. For transactions, a timestamp alone may not be unique, so use a stable tie-breaker such as TXN_ID:
1ORDER BY TXN_TS, TXN_ID
This window ORDER BY does not promise final result order. Db2 is still free to return rows in any sequence unless the full SELECT has an outer ORDER BY. Production reports commonly repeat the same keys at query level.
The frame moves as Db2 evaluates each current row. UNBOUNDED PRECEDING means the first row of this partition. CURRENT ROW is the current physical row for ROWS, but for RANGE it represents the current ORDER BY value and its peers. A numeric bound such as 2 PRECEDING means two rows under ROWS; under RANGE it means a value offset and has stricter ORDER BY type and key-count rules.
| Frame | Rows included | Common use |
|---|---|---|
| ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW | Every ordered row from partition start through this physical row | Deterministic running total |
| ROWS BETWEEN 2 PRECEDING AND CURRENT ROW | Current row plus at most two preceding rows | Three-row trailing average |
| ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING | Previous, current, and next row where available | Centered smoothing |
| RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW | Partition start through all peers of current ORDER BY value | Value-grouped cumulative result |
| ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING | The complete partition | Whole-partition aggregate on every detail row |
The safest beginner pattern states all three decisions and makes ordering unique:
123456789101112SELECT ACCOUNT_ID, TXN_TS, TXN_ID, AMOUNT, SUM(AMOUNT) OVER ( PARTITION BY ACCOUNT_ID ORDER BY TXN_TS, TXN_ID ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS RUNNING_AMOUNT FROM ACCOUNT_TXN WHERE TXN_STATUS = 'POSTED' ORDER BY ACCOUNT_ID, TXN_TS, TXN_ID;
WHERE is applied before the window calculation, so only posted transactions contribute. When ACCOUNT_ID changes, the total restarts. Within one account, the timestamp and transaction id determine exactly which row enters next. The explicit ROWS frame avoids accidentally grouping timestamp peers.
SUM ignores null expression values. If a null AMOUNT should mean zero, write SUM(COALESCE(AMOUNT, 0)) rather than assuming null is stored as zero. Also consider the result data type. Very large integer or decimal accumulations can overflow the result type; cast to a suitable DECIMAL before SUM when the business maximum demands it.
CASE inside SUM creates a conditional running total. This is useful when one ordered stream contains credits and debits:
12345678910111213141516171819202122SELECT ACCOUNT_ID, TXN_TS, TXN_ID, TXN_TYPE, AMOUNT, SUM(CASE WHEN TXN_TYPE = 'C' THEN AMOUNT ELSE DECIMAL(0, 15, 2) END) OVER ( PARTITION BY ACCOUNT_ID ORDER BY TXN_TS, TXN_ID ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS RUNNING_CREDITS, SUM(CASE WHEN TXN_TYPE = 'D' THEN AMOUNT ELSE DECIMAL(0, 15, 2) END) OVER ( PARTITION BY ACCOUNT_ID ORDER BY TXN_TS, TXN_ID ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS RUNNING_DEBITS FROM ACCOUNT_TXN;
Use ELSE zero when every row should contribute a numeric value. Without ELSE, CASE returns null for unmatched rows; SUM ignores those nulls, which can be acceptable but can produce null before the first matching event. Matching decimal types also avoids unintended type promotion.
A three-row trailing average uses the current row and at most two previous rows. At the beginning of a partition the frame is smaller; Db2 does not invent missing rows.
123456789101112131415SELECT SENSOR_ID, SAMPLE_TS, READING, AVG(READING) OVER ( PARTITION BY SENSOR_ID ORDER BY SAMPLE_TS ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) AS AVG_LAST_3, COUNT(READING) OVER ( PARTITION BY SENSOR_ID ORDER BY SAMPLE_TS ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) AS VALUES_IN_AVG FROM SENSOR_SAMPLE ORDER BY SENSOR_ID, SAMPLE_TS;
COUNT(READING) reveals how many non-null readings AVG actually used. COUNT(*) would count rows even when READING is null. If each sensor can produce multiple rows at the same SAMPLE_TS, add a sample sequence to ORDER BY. For a centered average, use ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING, but remember that this includes a future row from the reporting sequence.
A row-based “last three” is not the same as “last three days.” ROWS counts observations. A RANGE offset is value-based, but offset RANGE frames have restrictions, including a single suitable sort-key expression. For calendar reporting, many teams first build one row per day in a common table expression, then apply a ROWS frame to that regular series.
COUNT(*) with a cumulative frame gives the row position within a partition. It differs from ROW_NUMBER mainly because COUNT is an aggregate and accepts a frame, while ROW_NUMBER is a numbering specification. COUNT(expression) counts only non-null expression values.
12345678910111213SELECT ORDER_ID, EVENT_TS, EVENT_ID, STATUS, COUNT(*) OVER ( PARTITION BY ORDER_ID ORDER BY EVENT_TS, EVENT_ID ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS EVENT_NUMBER, COUNT(COMPLETED_TS) OVER ( PARTITION BY ORDER_ID ) AS COMPLETION_EVENTS FROM ORDER_EVENT;
EVENT_NUMBER progresses one row at a time. COMPLETION_EVENTS has no window ORDER BY, so it repeats the total count of non-null completion timestamps for the whole order partition.
Suppose two transactions have the same POSTING_DATE. With ORDER BY POSTING_DATE and a RANGE cumulative frame, both are peers. The running amount shown for each includes both transactions. The result advances by date groups, not individual rows.
1234567891011121314SELECT POSTING_DATE, TXN_ID, AMOUNT, SUM(AMOUNT) OVER ( ORDER BY POSTING_DATE RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS TOTAL_BY_DATE_GROUP, SUM(AMOUNT) OVER ( ORDER BY POSTING_DATE, TXN_ID ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS TOTAL_BY_TRANSACTION FROM ACCOUNT_TXN WHERE ACCOUNT_ID = 1001 ORDER BY POSTING_DATE, TXN_ID;
Neither result is universally correct. RANGE expresses a business rule like “balance through this posting date,” where transactions on the same date belong together. ROWS expresses “balance after this transaction,” which requires a deterministic transaction sequence.
For aggregate windows that specify ORDER BY but omit an explicit frame, Db2 uses the cumulative peer-aware behavior equivalent to RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. This default is concise, but explicit frames are easier to review and less likely to surprise maintainers.
LAG returns an expression from an earlier row in the window sequence. LEAD returns one from a later row. The default offset is one. They stay within the current partition, so the first LAG and final LEAD normally return null. An optional default can replace that out-of-partition null.
1234567891011121314SELECT ACCOUNT_ID, TXN_TS, TXN_ID, AMOUNT, LAG(AMOUNT) OVER ( PARTITION BY ACCOUNT_ID ORDER BY TXN_TS, TXN_ID ) AS PREVIOUS_AMOUNT, LEAD(AMOUNT) OVER ( PARTITION BY ACCOUNT_ID ORDER BY TXN_TS, TXN_ID ) AS NEXT_AMOUNT FROM ACCOUNT_TXN ORDER BY ACCOUNT_ID, TXN_TS, TXN_ID;
This replaces a common self-join whose only purpose is to locate the previous or next row. It does not calculate a difference automatically. Put the window result in a nested table expression or common table expression, then calculate and filter outside when that is clearer:
123456789101112131415161718WITH BALANCE_HISTORY AS ( SELECT ACCOUNT_ID, AS_OF_DATE, BALANCE, LAG(BALANCE, 1, DECIMAL(0, 15, 2)) OVER ( PARTITION BY ACCOUNT_ID ORDER BY AS_OF_DATE ) AS PRIOR_BALANCE FROM DAILY_BALANCE ) SELECT ACCOUNT_ID, AS_OF_DATE, BALANCE, PRIOR_BALANCE, BALANCE - PRIOR_BALANCE AS DAILY_CHANGE FROM BALANCE_HISTORY WHERE BALANCE <> PRIOR_BALANCE ORDER BY ACCOUNT_ID, AS_OF_DATE;
The third LAG argument is used only when the requested preceding row is outside the partition; it does not replace a null BALANCE stored in an existing preceding row. Current Db2 for z/OS levels also support null-treatment options for LAG and LEAD, but package APPLCOMPAT and subsystem function level govern newer syntax. Verify deployment compatibility before using optional features beyond the basic form.
A tie-breaker can change meaning. ORDER BY POSTING_DATE, TXN_ID no longer has the same peer groups as ORDER BY POSTING_DATE alone. That is desirable for ROWS-based transaction sequencing, but not if the business rule says every transaction on one date is simultaneous. Decide the rule before adjusting SQL to make output look tidy.
Window values are produced after the input rows have been filtered. You cannot normally refer to a newly assigned window alias in the WHERE clause of that same query block. Compute the window in an inner query or CTE, then filter in an outer SELECT.
1234567891011121314151617WITH RUNNING AS ( SELECT ACCOUNT_ID, TXN_TS, TXN_ID, AMOUNT, SUM(AMOUNT) OVER ( PARTITION BY ACCOUNT_ID ORDER BY TXN_TS, TXN_ID ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS RUNNING_AMOUNT FROM ACCOUNT_TXN WHERE TXN_STATUS = 'POSTED' ) SELECT ACCOUNT_ID, TXN_TS, TXN_ID, AMOUNT, RUNNING_AMOUNT FROM RUNNING WHERE RUNNING_AMOUNT > 100000 ORDER BY ACCOUNT_ID, TXN_TS, TXN_ID;
Moving TXN_STATUS to the outer query would change the calculation because rejected rows would first participate in SUM and only then disappear. Predicate placement is part of the business definition, not just a tuning detail.
Windows often require Db2 to organize rows by partition and order keys. A large unfiltered partition can need a substantial sort and work-file space. Several window expressions can share processing when their PARTITION BY, ORDER BY, and framing needs are compatible; unrelated orderings can require additional work.
Do not replace a clear window expression with correlated subqueries merely to avoid the word “sort.” A correlated running-total subquery can repeatedly scan earlier rows and scale poorly. Compare real access paths and elapsed and CPU measurements instead of assuming one syntax is always faster.
Conditional SUM and COUNT combine naturally with windows. String aggregation is a different pattern: on Db2 for z/OS, use LISTAGG with WITHIN GROUP to build an ordered string for a group rather than assuming that every aggregate accepts the same OVER syntax. GROUP BY, LISTAGG ordering, and window ordering solve related but distinct problems.
You can also place a grouped result in a CTE and then apply a window to those grouped rows. For example, first produce one row per month with SUM(SALES_AMOUNT), then calculate a running year-to-date total across the monthly rows. This two-stage design makes the grain explicit and can dramatically reduce the number of rows entering the window.
Imagine each account has its own line of numbered cups. PARTITION BY puts cups for different accounts into separate lines. ORDER BY tells you which cup is first, second, and third. A running SUM pours all the water from the first cup through the cup you are holding into a measuring jug. ROWS says “count actual cups.” RANGE says “all cups with the same number sticker belong together.” AVG tells you the average water in the nearby cups, COUNT tells you how many cups were used, LAG peeks at the cup just behind you, and LEAD peeks at the cup just ahead. Every cup stays in the line; the calculation only adds a useful note beside it.
Use SUM(amount) OVER (PARTITION BY grouping-columns ORDER BY sequence-columns ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW). PARTITION BY is optional. Include a unique tie-breaker in ORDER BY when each row must have a stable, row-by-row total.
ROWS counts ordered rows. RANGE uses ORDER BY values and treats rows with equal sort-key values as peers. RANGE CURRENT ROW therefore includes all peers, while ROWS CURRENT ROW identifies the current physical row.
An aggregate window with ORDER BY and no explicit frame uses a peer-aware cumulative frame, equivalent to RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Every row with the same ORDER BY value enters together. Specify ROWS and add a unique tie-breaker if you need one-row-at-a-time accumulation.
LAG looks backward and LEAD looks forward within the current partition. If the requested offset is outside that partition, Db2 returns null unless the optional default-value argument is supplied.
They can require sorting and work-file processing, especially for large partitions or several incompatible window orderings. Filter early, select only needed columns, consider indexes beginning with selective and partitioning keys followed by ordering keys, and inspect the access path with EXPLAIN.
1. What makes a window SUM different from a GROUP BY SUM?
2. Which frame is usually clearest for a row-by-row running total?
3. What happens when RANGE CURRENT ROW encounters tied ORDER BY values?
4. What does LAG(amount) normally return?
5. Does ORDER BY inside OVER guarantee final display order?