Skip to content
Great engineers know 4 min read · Aggregation 4 practice problems ↓

max_by / min_by

Return the value from the row where another column is largest, or smallest, in each group. One aggregate instead of a window, a row number and a filter.

You will learn

  • How max_by picks a row inside each group
  • Why it is cheaper than the window workaround
  • How to return several columns from the winning row
  • What happens with ties and nulls

Read first

Comfortable with these? Read on.

TL;DR max_by(x, ord) returns x from the row with the largest ord in each group; min_by uses the smallest. Available in Spark SQL since 3.0 and in the PySpark API since 3.3.

What it does

max_by(page, ts) finds the row with the largest ts in each group and returns that row's page. min_by does the same for the smallest. It is often called argmax.

Step by step

pageviews

session_idpagets
s1/home10:00
s1/pricing10:02
s1/checkout10:05
s2/search11:00
s2/product/4211:01

max_by(page, ts)

session_idexit
s1/checkout
s2/product/42
  1. 1
    Spark splits the rows into groups by session_id.
  2. 2
    For each group it keeps one running pair: the largest ts seen so far and the page from that row.
  3. 3
    Each new row either replaces the pair, if its ts is larger, or is ignored.
  4. 4
    When the group ends, Spark returns the page half of the pair. No sorting happens at any point.

Run the example

PySparkSpark SQL
from pyspark.sql import functions as F
result = pageviews.groupBy("session_id").agg(
    F.min_by("page", "ts").alias("entry"),
    F.max_by("page", "ts").alias("exit"),
)
SELECT session_id,
       min_by(page, ts) AS entry,
       max_by(page, ts) AS exit
FROM pageviews
GROUP BY session_id

Switch to PySpark to edit and run this example in your browser.

Without it

The usual workaround ranks rows in a window and keeps the first one: three steps and a sort for a single value.

PySparkSpark SQL
from pyspark.sql import functions as F
from pyspark.sql.window import Window
w = Window.partitionBy("session_id").orderBy(F.desc("ts"))
(pageviews.withColumn("pos", F.row_number().over(w))
    .filter("pos = 1").select("session_id", "page"))
SELECT session_id, page
FROM (
  SELECT *, ROW_NUMBER() OVER (
    PARTITION BY session_id ORDER BY ts DESC) AS rn
  FROM pageviews)
WHERE rn = 1

Performance

max_byrow_number window
Before the shufflePartial aggregation: one row per group per partitionNothing: every row is shuffled
SortingNoneSorts every row by group and time
Physical planHashAggregate → Exchange → HashAggregateExchange → Sort → Window → Filter

With many rows per group, max_by moves far less data across the network. The window version is still right when you need the top N rows, or the whole winning row with many columns and a defined tie rule.

Several columns from the winning row

Calling max_by once per column is fine when the ordering column is unique. With ties, each call can pick a different row. Pack the columns into one struct so they always come from the same row:

PySparkSpark SQL
pageviews.groupBy("session_id").agg(
    F.max_by(F.struct("page", "referrer"), "ts").alias("last")
).select("session_id", "last.page", "last.referrer")
SELECT session_id, last.page, last.referrer
FROM (
  SELECT session_id, max_by(struct(page, referrer), ts) AS last
  FROM pageviews GROUP BY session_id)

An older trick does the same without max_by: max(struct(ts, page)). Structs compare field by field, so the maximum struct is the one with the latest ts, and you read .page from it.

Common mistakes

Ignoring ties

If two rows share the largest ts, either value can come back, and it can differ between runs. Make the ordering column unique or add a tiebreaker.

Expecting null ordering values to count

Rows with a null ts are skipped. A group with only null ts returns null.

Using F.max_by on PySpark before 3.3

Use F.expr("max_by(page, ts)"); the SQL function exists since 3.0.

Key takeaways

  • max_by(x, ord) returns x from the row with the largest ord in each group.
  • It aggregates before the shuffle and never sorts, so it beats the window version for "top 1".
  • Ties are not deterministic, and nulls in ord are skipped.
  • Use a struct to take several columns from the same row.

Check yourself

3 questions

1. Two rows in session s9 share the latest ts. What does max_by(page, ts) return for s9?

Show the answer

One of the two pages, and which one is not guaranteed. max_by keeps whichever tied row it meets first, and the order depends on partitioning. Add a tiebreaker to make it deterministic.

2. Why is max_by usually cheaper than row_number() over a window for "latest row per group"?

Show the answer

It aggregates before the shuffle and never sorts. Each partition reduces its rows to one pair per group before the exchange, and no sort is needed. The window shuffles and sorts every row.

3. How do you get page and referrer guaranteed from the same winning row?

Show the answer

max_by(struct(page, referrer), ts). One max_by over a struct picks a single row and returns all its fields together.

Practice it

Interview problems that use max_by / min_by: write the PySpark, run it, and get graded on hidden tests.

Solve: Entry and Exit Pages (MAX_BY / MIN_BY) →

Go deeper

Primary sources: functions.max_by · Built-in functions (SQL)