Spotting Shuffles in Your Code

You can count a program's shuffles without running it. Read the chain from the first DataFrame to the action, and at each call ask the question from the first section: can a task answer this for its own rows alone? When the answer is no, there is a shuffle at that line. Narrow calls add none, however many you stack, and a wide call adds exactly 1.

The wide calls, as they appear in code

Walk a chain line by line

Before you run the program below, count its shuffles. There are 7 calls between the table and the action, and 2 of them are wide. Run it with collect() so the sort is a true global sort. Then delete the orderBy line and run again: the same rows come back, 1 shuffle fewer, in whatever order the readers returned them.

LineNarrow or wideWhyShuffles so far
`filter` on statusNarrowTested per row0
`filter` on regionNarrowTested per row0
`withColumn`NarrowComputed per row0
`groupBy("region").agg`WideAll rows of a region must meet1
`filter` on totalNarrowTested per aggregated row1
`orderBy("total")`WideGlobal order needs every row placed2
`collect()`ActionRuns the chain with its 2 shuffles2

The chain has 2 shuffles with 3 stretches of narrow work around them. The first shuffle moves the filtered raw rows. The second moves the aggregated rows, a single row per region, so it carries almost nothing. Where a shuffle sits in the chain matters as much as how many there are, because the rows alive at that line are the rows that cross.

Calls that stay narrow despite appearances

Calls that shuffle without saying so

distinct() reads like a tidy-up and runs as a groupBy on every column: to know a row is unique, Spark has to see all its copies in one task. dropDuplicates(["user_id"]) does the same with a chosen key. And orderBy followed by collect() or a file write sorts across the whole table, so every row is sent to the partition that owns its range of values. Each of these costs the same shuffle as a groupBy on its key, whatever its name suggests.

orderBy followed by show() or limit(n) runs as a different plan: each partition keeps its top n rows and the driver merges those few, with no range shuffle. The range shuffle is paid when the full sorted result is needed.

How many shuffles does the program below run?

(page_views
   .filter(F.col("device") == "mobile")
   .select("user_id", "page_url")
   .distinct()
   .withColumn("is_home", F.col("page_url") == "/")
   .filter(F.col("is_home"))
   .collect())
Do
  • Count the wide calls before you run: each one is a shuffle
  • Put every raw-column filter before the first groupBy
  • Name the key of each shuffle: region, user_id, all columns
  • Treat distinct and dropDuplicates as a groupBy in disguise
Avoid
  • Assume a short chain is cheap: a single orderBy can move the whole table
  • Add a second groupBy when one agg can compute both numbers
  • Trust the method name; ask what the task needs to answer
  • Call collect() on a shuffled result larger than the driver can hold

About this lesson

This section is part of the Shuffles: Beginner lesson on DataDriven, the home of data engineering. Lessons cover SQL, Python, Spark, data modeling and pipeline architecture, and each section pairs its explanation with worked examples and practice you run as you go.