High-Cardinality GroupBy Memory Explosion¶
Performance Impact
OutOfMemoryError from unbounded state - Not from the number of groups, but from what you accumulate inside each one.
The Problem¶
The usual telling of this gotcha goes: grouping by ten million user IDs creates ten million groups in memory, so it crashes.
That is wrong, and believing it leads people to mangle their queries for no reason.
user_stats = df.groupBy("user_id").agg( # 10M distinct users
F.count("*").alias("event_count"),
F.sum("amount").alias("total_spent"),
)
Spark handles this. HashAggregateExec computes partial aggregates on each partition before shuffling, so only one small, fixed-size buffer per key travels the network — a counter and a running sum, a few bytes each. When the hash map outgrows memory, Spark spills it and switches to a sort-based aggregate. Ten million groups is unremarkable. A hundred million is unremarkable.
The memory bomb is not the group count. It is what you put in the buffer.
Where The Memory Actually Goes¶
user_events = df.groupBy("user_id").agg(
F.collect_list("event_payload").alias("events") # ← grows with group size
)
count and sum have buffers of constant size. collect_list and collect_set have buffers that grow with the number of rows in the group. One user with 50 million events must materialise all 50 million payloads in a single executor's heap, because a list cannot be partially aggregated.
This is the OOM people actually hit.
stats = user_stats.collect() # driver holds all 10M rows
pdf = user_stats.toPandas() # same, plus a full copy in Python
The cluster handled ten million groups without complaint. The driver is one JVM with a default 1 GB result limit. The aggregation was never the problem.
rdd.groupByKey().mapValues(sum) # ships every value across the network
rdd.reduceByKey(lambda a, b: a + b) # ✅ combines locally first
The RDD API's groupByKey genuinely does what the myth describes: all values for a key must fit in memory on one executor. The DataFrame groupBy does not behave this way. If you learned this rule on RDDs, it does not transfer.
df.groupBy("date").pivot("user_id").sum("amount") # 10M columns
pivot turns rows into columns, and the schema must be known and held. Ten million columns is not a wide table; it is a crash. pivot is for tens of values, not millions.
A single key holding 90% of the rows makes one task do 90% of the work — and hold 90% of the state. See Data Skew; the diagnosis and the fixes are different from anything on this page.
Solutions¶
# Instead of collect_list of everything, keep only what you need
from pyspark.sql import Window
w = Window.partitionBy("user_id").orderBy(F.col("ts").desc())
recent = (
df.withColumn("rn", F.row_number().over(w))
.filter(F.col("rn") <= 10) # at most 10 rows per user
.groupBy("user_id")
.agg(F.collect_list("event_payload").alias("last_10_events"))
)
The group count is unchanged. The per-group state is now bounded by ten.
user_stats.write.mode("overwrite").parquet("s3://bucket/user_stats/")
# Bring back only what a human can read
top_spenders = (
user_stats.orderBy(F.col("total_spent").desc()).limit(1000).collect()
)
Aggregating ten million groups is a cluster operation. Looking at them is not. Keep the result distributed and pull back a limit().
summary = df.agg(
F.approx_count_distinct("user_id", 0.02).alias("unique_users"),
F.expr("percentile_approx(amount, 0.5)").alias("median_amount"),
)
distinct().count() over ten million users shuffles every distinct value. approx_count_distinct uses a HyperLogLog sketch of fixed size and answers in one pass with a 2% error bound.
If the number lands on a dashboard, the sketch is the right answer. If it lands in a regulatory filing, it is not.
Do not bin your keys to make the group count smaller
A widely copied "fix" is to bucket the grouping key:
This does not optimise the query. It answers a different question. "Total spend per arbitrary block of a thousand adjacent user IDs" is not a quantity anyone wants, it assumes user_id is numeric and densely packed, and it silently produces plausible-looking nonsense.
Reduce cardinality only when the coarser grouping is the one you actually meant — by day instead of by second, by region instead of by postcode. Never to appease the optimizer.
Key Takeaways¶
GroupBy rules
- Group count is rarely the problem. DataFrame
groupBypre-aggregates and spills. - Buffer size is the problem.
count/sumare constant;collect_list/collect_setare not. collect()andtoPandas()move the result into one JVM. Write it instead.- RDD
groupByKeyis the dangerous one — usereduceByKey. The rule does not apply to DataFrames. pivotneeds low cardinality, always.- Never bin the grouping key to reduce groups. You are changing the question, not the plan.
Measuring Impact¶
# collect_list over 10M users, one whale user: executor OOM at 40 min
# bounded to last 10 events per user: completes in 6 min
# distinct().count() on user_id: 14 min, full shuffle
# approx_count_distinct(user_id, 0.02): 50 s, one pass, ±2%
The lesson generalises past groupBy: when Spark runs out of memory, ask what has unbounded size — a list, a driver-side collection, a column count. It is almost never the number of keys.
That closes the gotchas. Every one of them is a variation on the same two questions: how much data moves, and how much state does one task have to hold. The Spark UI answers both, and reading it is the skill that turns this list from trivia into diagnosis.