Analytical SQL Window Functions: OVER, PARTITION BY & Running Metrics
Created: 2026-11-08 · Last updated: 2026-11-08 · Nejat Hakan
Executive Summary: In traditional SQL, aggregating data with
GROUP BYcollapses multiple individual rows into a single summary tuple per group, irreversibly discarding the distinct identities and non-aggregated attributes of individual records. Window Functions (OVER (...)) fundamentally alter this limitation: they compute running totals, rankings, moving averages, and period-over-period deltas across a sliding partition of rows while preserving the identity of every single row. This guide explores the execution lifecycle of window functions, partition frames, navigation functions, and production data-deduplication patterns.
-
Preserve Row Identity
Compute partition-wide analytics (e.g., total department salary) alongside individual employee details in a single scan.
-
Rankings & Bucketing
Differentiate
ROW_NUMBER(),RANK(),DENSE_RANK(), andNTILE()to solve Top-N category queries deterministically. -
Temporal Navigation
Look backwards and forwards across time-series data using
LAG()andLEAD()without expensive, unindexed self-joins. -
Sliding Window Frames
Define moving calculation boundaries:
ROWS BETWEEN UNBOUNDED PRECEDINGfor ledgers and moving N-day rolling averages.
1. The Core Paradigm: GROUP BY vs. OVER (...)
The distinction between standard grouping and windowing lies in whether rows are collapsed or enriched:
%%{init: {'flowchart': {'wrappingWidth': 600}}}%%
flowchart TB
SRC["Raw Dataset (100 Rows)<br/>(Orders per Customer)"]
GRP_OP["GROUP BY customer_id<br/>Collapses Rows into Groups"]
WIN_OP["OVER (PARTITION BY customer_id)<br/>Evaluates Sliding Window per Group"]
GRP_RES["Aggregated Output (10 Rows)<br/>Individual Order IDs Lost!"]
WIN_RES["Enriched Output (100 Rows)<br/>Every Order ID Retained +<br/>Running Metrics Appended!"]
SRC --> GRP_OP
SRC --> WIN_OP
GRP_OP --> GRP_RES
WIN_OP --> WIN_RES
The SQL Query Execution Lifecycle
Window functions occupy a specific, late phase in the SQL execution pipeline:
1. FROM & JOIN (Build data working set)
2. WHERE (Filter individual rows)
3. GROUP BY (Collapse rows into groups)
4. HAVING (Filter collapsed groups)
5. Window Functions (OVER) (Evaluated across the filtered/grouped result set)
6. SELECT (Project output columns)
7. DISTINCT (Deduplicate output tuples)
8. ORDER BY (Sort final output)
9. LIMIT / OFFSET (Slice result stream)
Why Window Functions Cannot Exist in WHERE Clauses
Because WHERE filters rows in Step 2 before window functions are calculated in Step 5, you cannot write WHERE ROW_NUMBER() OVER (...) = 1. To filter on a window function result, wrap it inside a Common Table Expression (CTE) or subquery!
2. Anatomy of the OVER Clause
A window specification consists of three configurable components:
FUNCTION() OVER (
PARTITION BY partition_column -- 1. Slices data into logical buckets
ORDER BY sort_column -- 2. Sorts rows inside each bucket
ROWS BETWEEN ... AND ... -- 3. Sets physical calculation frame boundaries
)
%%{init: {'flowchart': {'wrappingWidth': 600}}}%%
flowchart TB
PART["Partition: customer_id = 42<br/>(Logical Boundary)"]
R1["Row 1: Ordered 2026-01-01 | $50 | Running Total: $50"]
R2["Row 2: Ordered 2026-01-15 | $30 | Running Total: $80"]
R3["Row 3: Ordered 2026-02-01 | $40 | Running Total: $120<br/>(Current Row: Evaluates Previous + Current)"]
PART --> R1
PART --> R2
PART --> R3
Frame Specification Semantics (ROWS vs. RANGE)
- Cumulative Running Total:
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWSums from the very first row of the partition up to the current row. - Centered Moving Average (3-point window):
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWINGCalculates the average of the immediate previous row, the current row, and the immediate next row. - Rolling Historical 7-Day Window:
ROWS BETWEEN 6 PRECEDING AND CURRENT ROWCalculates a 7-day smoothing average over daily metric records.
3. The Window Function Toolkit
A. Ranking Functions
| Function | Handling of Ties | Output Sequence for values (100, 100, 50) |
Primary Engineering Use Case |
|---|---|---|---|
ROW_NUMBER() |
Arbitrary unique integer | 1, 2, 3 |
Deduplication (picking latest event per device). |
RANK() |
Equal rank, skips next | 1, 1, 3 |
Competitions and leaderboards with point ties. |
DENSE_RANK() |
Equal rank, no gaps | 1, 1, 2 |
Top-N categories (e.g., Top 3 distinct product prices). |
NTILE(n) |
Divides rows into n buckets | E.g., NTILE(4) for quartiles |
Performance percentiles and A/B test cohort slicing. |
B. Navigation Functions: Zero Self-Joins
Before window functions, calculating month-over-month growth required joining a table back against itself on month = prev_month + 1, causing quadratic (O(N2)) query slowdowns. LAG and LEAD eliminate self-joins entirely:
LAG(column, offset, default): Retrieves values from N rows behind the current row within the partition.LEAD(column, offset, default): Retrieves values from N rows ahead of the current row within the partition.
4. Hands-on Production Lab: Telemetry & Financial Analytics
Execute this complete analytical workshop in psql to calculate running balances, period growth, and server log deduplication:
-- File: /opt/db-ops/window_functions_lab.sql
BEGIN;
-- 1. Financial Ledger with Running Balance & Deltas
CREATE TABLE account_ledger (
entry_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
account_id INT NOT NULL,
booked_at TIMESTAMPTZ NOT NULL,
amount_cents BIGINT NOT NULL -- Positive = Credit, Negative = Debit
);
INSERT INTO account_ledger (account_id, booked_at, amount_cents) VALUES
(101, '2026-11-01 09:00:00Z', 100000),
(101, '2026-11-02 14:30:00Z', -25000),
(101, '2026-11-04 11:15:00Z', 40000),
(102, '2026-11-01 10:00:00Z', 50000),
(102, '2026-11-03 16:00:00Z', -15000);
-- Query: Running Balance & Prior Transaction Comparison
SELECT
account_id,
booked_at,
amount_cents / 100.0 AS transaction_eur,
-- Previous transaction amount for this account
LAG(amount_cents / 100.0, 1, 0.00) OVER (
PARTITION BY account_id ORDER BY booked_at
) AS prev_transaction_eur,
-- Running real-time balance
SUM(amount_cents / 100.0) OVER (
PARTITION BY account_id
ORDER BY booked_at
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_balance_eur
FROM account_ledger
ORDER BY account_id, booked_at;
-- 2. Deduplication Pattern: Get the Single Most Recent Heartbeat per Server
CREATE TABLE server_heartbeats (
heartbeat_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
server_node TEXT NOT NULL,
cpu_load NUMERIC(5,2) NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL
);
INSERT INTO server_heartbeats (server_node, cpu_load, recorded_at) VALUES
('node-alpha', 12.5, '2026-11-08 07:00:00Z'),
('node-alpha', 45.2, '2026-11-08 07:05:00Z'),
('node-beta', 88.1, '2026-11-08 07:01:00Z'),
('node-beta', 22.4, '2026-11-08 07:06:00Z');
-- Isolate the latest telemetry status per server
WITH ranked_heartbeats AS (
SELECT
server_node,
cpu_load,
recorded_at,
ROW_NUMBER() OVER (
PARTITION BY server_node
ORDER BY recorded_at DESC
) AS rank_order
FROM server_heartbeats
)
SELECT server_node, cpu_load, recorded_at
FROM ranked_heartbeats
WHERE rank_order = 1;
COMMIT;