5.3 Window Functions and Complex Joins in Spark SQL
Key Takeaways
Window functions preserve input row detail while evaluating a frame, rank or offset.
ROW_NUMBER breaks ties arbitrarily unless ORDER BY includes a complete tie breaker; RANK leaves rank gaps and DENSE_RANK does not.
For aggregate/value windows, an ordered default frame is cumulative RANGE including peers; LAST_VALUE does not necessarily mean the final partition value.
ROWS describes row offsets, while RANGE describes ordering-value offsets and peers.
In standard relational queries, aggregation operations grouped by keys (using GROUP BY) collapse all contributing rows into a single summary record per group. While suitable for high-level metric rollups, grouping destroys row-level detail. Window functions overcome this limitation by computing aggregations, moving averages, relative ranks, or offsets over a designated group of rows (called a window frame) while preserving the unique identity of each individual row in the output.
Alongside window functions, advanced data pipelines rely on complex join strategies—such as Semi Joins, Anti Joins, and Cross Joins—to filter, reconcile, and combine distributed datasets efficiently across cluster nodes.
1. Window Specification Architecture: Syntax and Execution Mechanics
A window function calculates a return value for every row in a relation based on a defined subset of rows related to the current row. In Spark SQL, window operations are declared using the OVER clause.
The Anatomy of the OVER Clause
A window specification comprises three distinct clauses:
function_name(expression) OVER (
[PARTITION BY partition_col1, partition_col2, ...]
[ORDER BY sort_col1 [ASC|DESC] [NULLS FIRST|LAST], ...]
[ROWS|RANGE BETWEEN frame_start AND frame_end]
)
PARTITION BY: Divides input rows into logical partitions across cluster nodes. The window function evaluates independently within each partition. If omitted, the entire dataset is treated as a single partition (which can cause severe executor memory bottlenecks).ORDER BY: Determines the physical ordering of rows within each partition. Mandatory for ranking and offset functions, and establishes cumulative ordering for aggregate functions.- Frame Specification (
ROWS/RANGE): Defines the subset of rows within the partition that bounds the calculation relative to the current row.
Reusing Window Definitions: The WINDOW Clause
When multiple analytical functions share the same partitioning and ordering rules, writing duplicate OVER (...) clauses creates verbose, error-prone code. Spark SQL supports the ANSI SQL WINDOW clause to define reusable window aliases at the end of the query:
SELECT
department_id,
employee_id,
salary,
AVG(salary) OVER w AS dept_avg_salary,
SUM(salary) OVER w AS dept_running_total,
MAX(salary) OVER w AS dept_max_salary
FROM corporate.employees
WINDOW w AS (
PARTITION BY department_id
ORDER BY salary DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
);
Programmatic PySpark Window API
In PySpark, window operations are formulated using pyspark.sql.window.Window:
from pyspark.sql.window import Window
import pyspark.sql.functions as F
# Define the window specification
window_spec = (
Window.partitionBy("department_id")
.orderBy(F.col("salary").desc())
.rowsBetween(Window.unboundedPreceding, Window.currentRow)
)
# Apply the window specification
df_ranked = df.withColumn("dept_running_total", F.sum("salary").over(window_spec))
Physical Execution Mechanics: Shuffling and Sorting
A window physical plan can include distribution, sorting and a window operator; these are operators, not necessarily three separate scheduler stages:
- Exchange (Shuffle): If
PARTITION BYis specified, Spark applies hash-partitioning on the partition columns to ensure all rows sharing the same partition key land on the same executor JVM. - In-Memory Sort (
SortExec): Within each partition, rows are sorted by theORDER BYcolumns. - Window Operator (
WindowExec): A specialized physical operator sweeps through the sorted partition, maintaining a sliding frame buffer to evaluate aggregates or rank calculations.
2. Ranking Functions: ROW_NUMBER, RANK, DENSE_RANK, and NTILE
Ranking functions assign an ordinal integer to each row within a partition based on the ORDER BY specification. How these functions handle duplicate (tied) values is one of the most frequently tested concepts on the Databricks certification exam.
Comparative Ranking Mechanics
ROW_NUMBER(): Generates a strict, unique sequential integer (1, 2, 3, ...) for every row in the partition. If two rows have identical sort values,ROW_NUMBER()assigns distinct numbers non-deterministically unless additional tie-breaking columns are added toORDER BY.RANK(): Assigns identical rank numbers to rows with identical sort values. However, it leaves gaps in the sequence equal to the number of tied rows. The next non-tied row receives a rank equal to: .DENSE_RANK(): Assigns identical rank numbers to rows with identical sort values, but never leaves gaps. The next non-tied row receives the immediate next sequential integer.NTILE(n): Divides rows within each partition as equally as possible into numbered buckets (quantiles), ranging from 1 to . If the row count is not evenly divisible by , earlier buckets receive one extra row.PERCENT_RANK(): Computes the relative rank of a row as a percentage from 0.0 to 1.0, calculated as: .CUME_DIST(): Calculates the cumulative distribution of a value, defined as: .
Comparative Ranking Table on Duplicate Values
Consider a department containing eight employees with the following salaries: [100k, 90k, 90k, 80k, 70k, 70k, 70k, 50k]:
| Employee ID | Salary | ROW_NUMBER() | RANK() | DENSE_RANK() | NTILE(3) | PERCENT_RANK() | CUME_DIST() |
|---|---|---|---|---|---|---|---|
| E1 | $100k | 1 | 1 | 1 | 1 | 0.00 | 0.125 (1/8) |
| E2 | $90k | 2 | 2 | 2 | 1 | 0.14 (1/7) | 0.375 (3/8) |
| E3 | $90k | 3 | 2 | 2 | 1 | 0.14 (1/7) | 0.375 (3/8) |
| E4 | $80k | 4 | 4 | 3 | 2 | 0.43 (3/7) | 0.500 (4/8) |
| E5 | $70k | 5 | 5 | 4 | 2 | 0.57 (4/7) | 0.875 (7/8) |
| E6 | $70k | 6 | 5 | 4 | 2 | 0.57 (4/7) | 0.875 (7/8) |
| E7 | $70k | 7 | 5 | 4 | 3 | 0.57 (4/7) | 0.875 (7/8) |
| E8 | $50k | 8 | 8 | 5 | 3 | 1.00 (7/7) | 1.000 (8/8) |
Notice that for RANK(), when E2 and E3 tie at rank 2, E4 jumps immediately to rank 4 (skipping 3). For DENSE_RANK(), E4 receives rank 3 without any gap.
3. Analytic and Offset Functions
Offset functions allow queries to inspect values from adjacent rows without performing self-joins.
Offset Functions: LAG and LEAD
LAG(col, [offset=1], [default=null]): Returns the value ofcolfrom rows before the current row within the partition.LEAD(col, [offset=1], [default=null]): Returns the value ofcolfrom rows after the current row within the partition.
SELECT
customer_id,
order_date,
order_amount,
-- Amount spent on the immediate previous order
LAG(order_amount, 1, 0.0) OVER (PARTITION BY customer_id ORDER BY order_date) AS prev_order_amount,
-- Days elapsed since the previous order
datediff(order_date, LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date)) AS days_since_prev_order
FROM sales.orders;
Value Functions: FIRST_VALUE and LAST_VALUE
FIRST_VALUE(col [, ignoreNulls]): Returns the value ofcolfrom the first row of the window frame.LAST_VALUE(col [, ignoreNulls]): Returns the value ofcolfrom the last row of the window frame.
The Classic LAST_VALUE() Exam Trap
A critical trap occurs when developers invoke LAST_VALUE() using the default window frame. By default, when ORDER BY is present, the frame is:
Under this cumulative frame, the "last row" evaluated by the function is the last peer with the current ordering value, which is the current row when ordering values are unique. Consequently, LAST_VALUE() is evaluated over the current cumulative frame as the query progresses down the partition, rather than the final value of the entire partition.
-- BUG: Returns the current row's order_amount, NOT the final order amount!
SELECT customer_id, order_date, order_amount,
LAST_VALUE(order_amount) OVER (PARTITION BY customer_id ORDER BY order_date, order_id) AS wrong_last
FROM sales.orders;
-- CORRECT: Explicitly extend the frame to the end of the partition
SELECT customer_id, order_date, order_amount,
LAST_VALUE(order_amount) OVER (
PARTITION BY customer_id
ORDER BY order_date, order_id
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS true_last
FROM sales.orders;
4. Window Frame Specifications: ROWS vs RANGE
The frame clause defines the physical or logical slice of rows within a partition evaluated by the window function.
Frame Boundary Syntax
[ROWS | RANGE] BETWEEN frame_start AND frame_end
Valid boundary keywords include:
UNBOUNDED PRECEDING: Frame begins at the very first row of the partition.n PRECEDING: Frame starts physical rows before the current row (ROWS) or value units before (RANGE).CURRENT ROW: The active row being evaluated.n FOLLOWING: Frame extends physical rows ahead (ROWS) or value units ahead (RANGE).UNBOUNDED FOLLOWING: Frame extends to the very last row of the partition.
ROWS vs RANGE: The Fundamental Difference
ROWS(Physical Offsets): Operates purely on row index positions. For example,ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWINGalways evaluates exactly up to 3 rows (the previous row, the current row, and the next row), regardless of whether column values are identical.RANGE(Logical Value Offsets): Operates on the actual values of theORDER BYexpression. In Spark SQL:- Tied Sort Values: If multiple rows share the exact same value in the
ORDER BYcolumn,RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWconsiders all tied rows as peers and evaluates the aggregate across all of them together up to the end of the tied group. - Numeric and Date Intervals:
RANGEcan evaluate logical intervals, such asRANGE BETWEEN INTERVAL 7 DAYS PRECEDING AND CURRENT ROW, aggregating all transactions that occurred in the 7 days prior to the current transaction date regardless of how many rows exist.
- Tied Sort Values: If multiple rows share the exact same value in the
Default Window Frame Rules
The following defaults apply to ordinary aggregate/value windows. Ranking and offset functions can require their own frames or reject explicit custom frames; do not impose this table on every function.
| Specification | Default Window Frame |
|---|---|
Window contains ORDER BY (no explicit frame) | RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW |
Window contains NO ORDER BY | ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING |
A student test score table contains values: [100, 95, 95, 90, 85]. An analytical query calculates ROW_NUMBER(), RANK(), and DENSE_RANK() ordered by score DESC within a single partition. What are the respective ranks assigned to the fourth row (score 90)?
ROW_NUMBER = 4, RANK = 4, DENSE_RANK = 3
ROW_NUMBER = 3, RANK = 3, DENSE_RANK = 2
ROW_NUMBER = 4, RANK = 3, DENSE_RANK = 4
ROW_NUMBER = 3, RANK = 4, DENSE_RANK = 3
A data analyst executes the following query in Spark SQL to find the final transaction amount for each customer:
SELECT customer_id, txn_date, txn_amount, LAST_VALUE(txn_amount) OVER (PARTITION BY customer_id ORDER BY txn_date) AS final_amount FROM transactions;
Instead of returning the latest transaction amount for each partition, the query unexpectedly returns each row's own txn_amount in final_amount. Why does this occur?
LAST_VALUE requires an explicit DESC sort order in the ORDER BY clause to evaluate in reverse.
When ORDER BY is specified without an explicit frame clause, Spark defaults to RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, so LAST_VALUE evaluates through the current ordering peer group rather than the whole partition.
LAST_VALUE is an invalid window function in Spark SQL and must be replaced with FIRST_VALUE using descending order.
Window functions in Spark SQL cannot evaluate numeric columns without casting them to strings.
Sections you finish are checked off in the contents.