Free Interactive Course · Spark + Scala

Window Functions

The single most powerful feature in the DataFrame API — compute across a set of rows related to the current row without collapsing them. Ranking, running totals, and reaching to the previous or next row. Every example here runs live.

The dataset

This module uses the orders DataFrame from Foundations — we rank and compare rows within categories and per user:

peopleid, name, country, age, signup_date
ordersid, user_id, product, category, amount, qty, status, ordered_at
7.1

Window.partitionBy & row_number

A window is a set of rows related to the current row. Unlike groupBy, a window function keeps every row — it just adds a column computed over the window. You define the window with Window.partitionBy(...).orderBy(...), then apply a function over it. row_number is the classic: number the rows 1, 2, 3… within each partition.

Signature
import org.apache.spark.sql.expressions.Window
val w = Window.partitionBy($"category").orderBy($"amount".desc)
def row_number(): Column         // 1,2,3 … no ties, always distinct
someFn.over(w)                   // apply a function over the window
Scala
import org.apache.spark.sql.expressions.Window
import org.apache.spark.sql.functions.row_number

val w = Window.partitionBy($"category").orderBy($"amount".desc)

orders.select(
  $"product", $"category", $"amount",
  row_number().over(w).as("rn")
).show(false)
Run the equivalent Spark SQL — edit it and press Run
SQL
⌘/Ctrl + Enter
The killer use case: "top-N per group". Add row_number partitioned by the group and ordered by your metric, then filter($"rn" <= 3). That's the idiomatic Spark way to get the 3 biggest orders per category — no self-join needed. Always give the window an orderBy for ranking functions; without one the numbering is non-deterministic.
7.2

rank, dense_rank & percent_rank

Three ranking functions that differ only in how they handle ties. row_number breaks ties arbitrarily; rank gives ties the same number then skips (1,2,2,4); dense_rank gives ties the same number but doesn't skip (1,2,2,3). percent_rank expresses the rank as a 0–1 fraction.

Signature
def rank(): Column          // 1,2,2,4  — gaps after ties
def dense_rank(): Column    // 1,2,2,3  — no gaps
def percent_rank(): Column  // (rank-1)/(n-1), between 0.0 and 1.0
Scala
import org.apache.spark.sql.functions.{rank, dense_rank}

val w = Window.orderBy($"qty".desc)

orders.select(
  $"product", $"qty",
  rank().over(w).as("rank"),
  dense_rank().over(w).as("dense_rank")
).show(false)
Run the equivalent Spark SQL — edit it and press Run
SQL
⌘/Ctrl + Enter
Watch the two rows with qty = 2 and the two with qty = 1: rank jumps (…3, 3, 5, 5) while dense_rank stays compact (…3, 3, 4, 4). Choose dense_rank when you want "the top 3 distinct values" and rank when position/percentile matters. For deterministic single-winner picks, use row_number and add a tie-breaker column to the orderBy.
7.3

lag & lead — reach to another row

lag pulls a value from a previous row in the window; lead from a following row. This is how you compute deltas — "how much bigger was this order than the customer's last one?" — without a self-join.

Signature
def lag(e: Column, offset: Int = 1): Column
def lag(e: Column, offset: Int, defaultValue: Any): Column
def lead(e: Column, offset: Int = 1): Column
Scala
import org.apache.spark.sql.functions.{lag, lead}

val w = Window.partitionBy($"user_id").orderBy($"ordered_at")

orders.select(
  $"user_id", $"ordered_at", $"amount",
  lag($"amount").over(w).as("prev_amt"),
  lead($"amount").over(w).as("next_amt")
).show(false)
Run the equivalent Spark SQL — edit it and press Run
SQL
⌘/Ctrl + Enter
At the edges of a partition there's no previous/next row, so lag/lead return null. Supply a third argument for a default — lag($"amount", 1, 0.0) — so a customer's first order compares against 0 instead of null. The orderBy defines what "previous" means, so always order by time (or your sequence key) explicitly.
7.4

Running totals & moving averages

Apply a plain aggregate (sum, avg) over an ordered window and it becomes cumulative: each row sees all rows up to and including itself. Add an explicit frame with rowsBetween to control exactly which rows are included — the whole basis of running totals and moving averages.

Signature
Window.orderBy($"ordered_at").rowsBetween(Window.unboundedPreceding, Window.currentRow)
// rowsBetween(-2, 0)  → current row + the 2 before it (a 3-row moving window)
sum($"amount").over(w)   avg($"amount").over(w)
Scala
import org.apache.spark.sql.functions.sum

val w = Window.orderBy($"ordered_at")
  .rowsBetween(Window.unboundedPreceding, Window.currentRow)

orders.select(
  $"ordered_at", $"amount",
  sum($"amount").over(w).as("running_total")
).show(false)
Run the equivalent Spark SQL — edit it and press Run
SQL
⌘/Ctrl + Enter
The default frame differs by whether the window has an orderBy: with ordering it's RANGE UNBOUNDED PRECEDING → CURRENT ROW (cumulative); without ordering it's the whole partition. That surprises people — an unordered sum().over(partitionBy) gives the group total on every row (a neat way to compute a percent-of-total). Prefer rowsBetween over rangeBetween for moving windows unless you specifically want value-based (not row-count) ranges.
7.5

first_value, last_value & nth_value

Grab a specific row's value from within the window: the first_value, the last_value, or the nth_value. Great for "what was the top product in this category?" stamped onto every row.

Signature
def first_value(e: Column): Column      def last_value(e: Column): Column
def nth_value(e: Column, offset: Int): Column
// last_value usually needs an explicit full frame — see the note
Scala
import org.apache.spark.sql.functions.first_value

val w = Window.partitionBy($"category").orderBy($"amount".desc)

orders.select(
  $"product", $"category", $"amount",
  first_value($"product").over(w).as("top_product")
).show(false)
Run the equivalent Spark SQL — edit it and press Run
SQL
⌘/Ctrl + Enter
The classic last_value gotcha: with the default cumulative frame, last_value is just… the current row (the frame only extends to "current row"). To get the true last value of the whole partition, widen the frame: Window.partitionBy(...).orderBy(...).rowsBetween(Window.unboundedPreceding, Window.unboundedFollowing). first_value doesn't have this problem because the frame always starts at the beginning.
7.6

ntile, cume_dist & percentiles

ntile(n) splits the ordered window into n roughly-equal buckets and labels each row 1…n — the way you build quartiles, deciles, or A/B cohorts. cume_dist gives the cumulative distribution (fraction of rows ≤ this one).

Signature
def ntile(n: Int): Column       // bucket number 1..n
def cume_dist(): Column         // cumulative distribution, 0 < x <= 1
Scala
import org.apache.spark.sql.functions.ntile

val w = Window.orderBy($"amount".desc)

orders.select(
  $"product", $"amount",
  ntile(2).over(w).as("half")
).show(false)
Run the equivalent Spark SQL — edit it and press Run
SQL
⌘/Ctrl + Enter
With 6 rows and ntile(2), Spark puts the top 3 amounts in bucket 1 and the rest in bucket 2. When rows don't divide evenly, the earlier buckets get the extra row. ntile needs a total ordering to be meaningful, so always pair it with an orderBy. For exact percentile values (not buckets) reach for percentile_approx in an agg instead.
D
DevAnalytics · Kettle & Co.now
Leaderboard time 🏆 — rank every order by amount, biggest = 1. Give me product, amount, and the rank as rnk.
🎯 Your mission From orders, return product, amount, and a row_number() ranking called rnk ordered by amount descending across all orders.
SQL
⌘/Ctrl + Enter

Frequently asked questions

What is a window function in Spark?
A window function computes a value across a set of rows related to the current row — defined by Window.partitionBy(...).orderBy(...) — without collapsing those rows the way groupBy does. Every input row stays in the output; the function just adds a computed column such as a rank, running total, or the previous row's value.
How do I get the top N rows per group in Spark?
Add a row_number() over a window partitioned by the group and ordered by your metric, then filter: df.withColumn("rn", row_number().over(Window.partitionBy("category").orderBy($"amount".desc))).filter($"rn" <= 3). This is the idiomatic top-N-per-group pattern and avoids a self-join.
What is the difference between rank, dense_rank and row_number in Spark?
row_number assigns a unique 1,2,3… even to tied rows. rank gives tied rows the same number but then skips (1,2,2,4). dense_rank gives tied rows the same number without skipping (1,2,2,3). Use row_number to pick one winner, dense_rank for "top 3 distinct values".
Why does last_value return the wrong value in a Spark window?
Because the default window frame only extends from the start of the partition to the current row, so last_value returns the current row. Widen the frame to the whole partition with .rowsBetween(Window.unboundedPreceding, Window.unboundedFollowing) to get the real last value.

Ready for the full data stack?

These Spark functions are one piece. When you want pipelines, Kafka, lakehouse table formats, and a full end-to-end project, the Data Engineering course is free too.

Explore Data Engineering →

Comments

0

Join the conversation. Sign in to leave a comment — questions and feedback welcome.