Window functions

PySpark · Practical · 15 min

Deduplication, sessionization and 'latest record per key' are all window-function problems in Spark.

A window function computes a value for each row from a group of related rows, without collapsing them. Define the window with Window from pyspark.sql.window:

w = Window.partitionBy("user").orderBy("ts")

then apply a function .over(w):

  • Ranking: F.row_number(), F.rank(), F.dense_rank(), F.percent_rank(), F.ntile(4)
  • Offsets: F.lag("amount", 1), F.lead("amount")
  • Aggregates: F.sum("amount").over(w) gives a running total, because an ordered window defaults to a frame from the partition start to the current row
  • Without orderBy, an aggregate over Window.partitionBy("user") covers the whole partition, handy for shares of a total

Frames can be set explicitly: .rowsBetween(-2, Window.currentRow) for a 3-row moving window, or .rangeBetween(...) for value-based ranges like the last 7 days in seconds.

The classic latest record per key pattern: add F.row_number().over(Window.partitionBy("id").orderBy(F.desc("updated_at"))) and keep rows where it equals 1.

A window without partitionBy moves all data into a single partition, which is very slow on big data. Spark warns about this.

Example

from pyspark.sql import SparkSession, functions as F
spark = SparkSession.builder.master("local[2]").getOrCreate()

from pyspark.sql.window import Window

df = spark.createDataFrame(
    [("u1", 1, 10), ("u1", 2, 30), ("u1", 3, 20), ("u2", 1, 5), ("u2", 2, 15)],
    "user STRING, ts INT, amount INT",
)
by_time = Window.partitionBy("user").orderBy("ts")
whole = Window.partitionBy("user")

out = df.select(
    "user", "ts", "amount",
    F.sum("amount").over(by_time).alias("running"),
    F.lag("amount").over(by_time).alias("prev"),
    F.round(F.col("amount") / F.sum("amount").over(whole), 2).alias("share"),
)
print([tuple(r) for r in out.orderBy("user", "ts").collect()])
# [('u1', 1, 10, 10, None, 0.17), ('u1', 2, 30, 40, 10, 0.5), ('u1', 3, 20, 60, 30, 0.33),
#  ('u2', 1, 5, 5, None, 0.25), ('u2', 2, 15, 20, 5, 0.75)]

latest = Window.partitionBy("user").orderBy(F.desc("ts"))
last = df.withColumn("rn", F.row_number().over(latest)).filter("rn = 1").drop("rn")
print(sorted(tuple(r) for r in last.collect()))   # [('u1', 3, 20), ('u2', 2, 15)]

Remember

  • Window.partitionBy(...).orderBy(...) + func.over(w)
  • Ordered windows give running aggregates; unordered ones give group totals
  • row_number() == 1 per key = latest-record deduplication

Watch out for

  • Defining a window without partitionBy on large data, forcing everything into one partition.

Check yourself

  1. F.sum('x').over(Window.partitionBy('k').orderBy('t')) computes:

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: spark.apache.org/docs/latest/sql-ref-syntax-qry-select-window.html