PySpark4 min read7 sections

PySpark collect_list vs collect_set, and explode vs explode_outer

Two functions turn many rows into one array, and two turn an array back into many rows. Each pair differs in one small way that changes your row counts, and interviewers ask about both.

Solve “Explode Array Column”

Free. Runs in your browser, no install.

Updated Written for: Freshers and beginners who know basic PySpark DataFrames and want to use arrays without losing rows by accident.

What you will learn

  • collect_list gathers every value of a group into an array and keeps duplicates. collect_set keeps each value once.
  • Both skip NULL values and neither promises an order, so sort the array if the order matters.
  • explode turns each array element into a row, but a row whose array is empty or NULL disappears.
  • explode_outer keeps those rows and gives them a NULL element, so a customer is never lost silently.
  • A list grows with the group, so collecting a huge group into one array can run a task out of memory.
On this page
  1. What you will learn
  2. 1. The data
  3. 2. collect_list and collect_set
  4. 3. explode and explode_outer
  5. 4. Going around the loop
  6. 5. Mistakes to avoid
  7. 6. Check yourself

Arrays turn up in PySpark all the time: a list of tags on a post, the items in a basket, the pages in a visit. Two functions pack rows into an array, and two functions unpack an array into rows. They look alike, and each pair differs in one detail that decides whether your numbers are right.

What you will learn

  • What collect_list and collect_set return, and which one keeps duplicates.
  • What happens to NULL values and to the order of the items.
  • What explode does with a NULL or empty array, and why explode_outer exists.
  • How to check your row counts after either one.
  • How to answer the interview question that asks for the difference.

The data

A shop has 6 order rows. Asha bought a pen twice and a book once. Ravi bought a book, and one of his rows has no item recorded. Meena bought a pen.

PythonPySpark
from pyspark.sql import SparkSession, functions as F

spark = SparkSession.builder.master("local[1]").getOrCreate()

orders = spark.createDataFrame(
    [(1, "Asha", "pen"), (2, "Asha", "pen"), (3, "Asha", "book"),
     (4, "Ravi", "book"), (5, "Ravi", None), (6, "Meena", "pen")],
    "order_id int, customer string, item string",
)
orders.show()
+--------+--------+----+
|order_id|customer|item|
+--------+--------+----+
|       1|    Asha| pen|
|       2|    Asha| pen|
|       3|    Asha|book|
|       4|    Ravi|book|
|       5|    Ravi|NULL|
|       6|   Meena| pen|
+--------+--------+----+

The goal is 1 row per customer, with all their items in one array column.

collect_list and collect_set

Both are aggregate functions, so you use them after groupBy, the same way you use sum or count. The difference is only what they do with repeated values.

PythonPySpark
(orders.groupBy("customer")
    .agg(F.sort_array(F.collect_list("item")).alias("items_list"),
         F.sort_array(F.collect_set("item")).alias("items_set"))
    .orderBy("customer").show(truncate=False))
+--------+----------------+-----------+
|customer|items_list      |items_set  |
+--------+----------------+-----------+
|Asha    |[book, pen, pen]|[book, pen]|
|Meena   |[pen]           |[pen]      |
|Ravi    |[book]          |[book]     |
+--------+----------------+-----------+
Rows for Asha
pen, pen, book

3 rows in the orders table.

collect_list
[book, pen, pen]

Every value, duplicates kept. Size 3.

collect_set
[book, pen]

Each value once. Size 2.

Figure 1. Asha has 3 order rows: pen, pen and book. collect_list returns all 3 values. collect_set returns each distinct value once.

Asha's list has 3 items and her set has two, because the pen is counted once in the set. Ravi has only one item in both, and his NULL did not appear in either array.

Counting what you collected

Because NULL is skipped, the size of a list can be smaller than the number of rows in the group.

PythonPySpark
(orders.groupBy("customer")
    .agg(F.count("*").alias("order_rows"),
         F.count("item").alias("items_not_null"),
         F.size(F.collect_list("item")).alias("list_size"),
         F.size(F.collect_set("item")).alias("set_size"))
    .orderBy("customer").show())
+--------+----------+--------------+---------+--------+
|customer|order_rows|items_not_null|list_size|set_size|
+--------+----------+--------------+---------+--------+
|    Asha|         3|             3|        3|       2|
|   Meena|         1|             1|        1|       1|
|    Ravi|         2|             1|        1|       1|
+--------+----------+--------------+---------+--------+

Ravi has 2 order rows but a list of size 1. The list size matches count("item"), which also skips NULL. If you wanted the number of orders, count the rows, not the array.

The same functions work in Spark SQL. This query returns the same sets:

PythonPySpark
orders.createOrReplaceTempView("orders")
spark.sql("SELECT customer, sort_array(collect_set(item)) AS items FROM orders GROUP BY customer ORDER BY customer").show(truncate=False)
+--------+-----------+
|customer|items      |
+--------+-----------+
|Asha    |[book, pen]|
|Meena   |[pen]      |
|Ravi    |[book]     |
+--------+-----------+

explode and explode_outer

Now the other direction. Keep the customer baskets as a table with one array per customer, and add 2 customers who have nothing: Zoya's array is NULL, and Kiran's array exists but is empty.

PythonPySpark
baskets = (orders.groupBy("customer")
    .agg(F.sort_array(F.collect_list("item")).alias("items")))
extra = spark.createDataFrame([("Zoya", None), ("Kiran", [])], "customer string, items array<string>")
all_baskets = baskets.union(extra)
all_baskets.orderBy("customer").show(truncate=False)
+--------+----------------+
|customer|items           |
+--------+----------------+
|Asha    |[book, pen, pen]|
|Kiran   |[]              |
|Meena   |[pen]           |
|Ravi    |[book]          |
|Zoya    |NULL            |
+--------+----------------+

explode gives one output row per array element.

PythonPySpark
all_baskets.select("customer", F.explode("items").alias("item")).orderBy("customer", "item").show()
+--------+----+
|customer|item|
+--------+----+
|    Asha|book|
|    Asha| pen|
|    Asha| pen|
|   Meena| pen|
|    Ravi|book|
+--------+----+

Kiran and Zoya are gone. An empty array has no elements to turn into rows, and a NULL array has none either, so the row disappears and Spark gives no warning. explode_outer keeps them.

PythonPySpark
all_baskets.select("customer", F.explode_outer("items").alias("item")).orderBy("customer", "item").show()
+--------+----+
|customer|item|
+--------+----+
|    Asha|book|
|    Asha| pen|
|    Asha| pen|
|   Kiran|NULL|
|   Meena| pen|
|    Ravi|book|
|    Zoya|NULL|
+--------+----+
explode
5 rows
  • Asha: book, pen, pen (3 rows)
  • Meena: pen (1 row)
  • Ravi: book (1 row)
  • Kiran and Zoya: no rows at all
explode_outer
7 rows
  • The same 5 rows
  • Kiran: 1 row, item NULL
  • Zoya: 1 row, item NULL
  • Nobody is lost
Figure 2. 5 customers, where Kiran has an empty array and Zoya has a NULL array. explode returns rows for Asha, Meena and Ravi only. explode_outer also returns Kiran and Zoya, each with a NULL item.

The row counts show the difference: explode returns 5 rows and explode_outer returns 7. If you need to know which items sit at which position, posexplode and posexplode_outer add a position column, which starts at 0.

PythonPySpark
(all_baskets.where("customer in ('Asha', 'Zoya')")
    .select("customer", F.posexplode_outer("items").alias("pos", "item"))
    .orderBy("customer", "pos").show())
+--------+----+----+
|customer| pos|item|
+--------+----+----+
|    Asha|   0|book|
|    Asha|   1| pen|
|    Asha|   2| pen|
|    Zoya|NULL|NULL|
+--------+----+----+

Going around the loop

If you collect rows and explode them again, do you get the same rows back? It depends on which function you used.

PythonPySpark
(orders.groupBy("customer").agg(F.sort_array(F.collect_set("item")).alias("items"))
    .select("customer", F.explode("items").alias("item"))
    .orderBy("customer", "item").show())
+--------+----+
|customer|item|
+--------+----+
|    Asha|book|
|    Asha| pen|
|   Meena| pen|
|    Ravi|book|
+--------+----+

Asha's second pen is gone, because collect_set removed it before explode ran. collect_list followed by explode returns the original items. So use collect_set only when you really want distinct values.

Mistakes to avoid

  • Trusting the order. Sort the array, or collect a struct and sort that, if the order carries meaning.
  • Collecting a very large group. One array lives inside 1 row on one machine. If a customer has millions of events, collect_list can run a task out of memory. Aggregate to counts or top items instead.
  • Expecting NULL to be kept. Neither collect_list nor collect_set keeps NULL, so the array can be shorter than the group.
  • Losing rows without noticing. After a plain explode, compare the number of distinct keys before and after.

Check yourself

  • Asha has the items pen, pen and book. What does collect_set return for her, and how many items does collect_list return?
  • A customer has an empty array. Which of explode and explode_outer returns a row for them?
  • You explode a column and then sum an order amount. Why can the total be too high?

Practise it: open Explode Array Column and Items per user as an array in the editor.

Keep practising

Take what you just read into the editor.

Browse all 36 guides