Large-Scale Aggregation with PySpark
How to perform group-by aggregations on large datasets with PySpark, covering window functions, UDFs, broadcast joins, and performance tuning.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
PySpark is the Python API for Apache Spark, a distributed data processing engine. Group-by aggregations are one of the most common operations in data pipelines — summing revenue by customer, counting events by day, averaging metrics by region. On large datasets (100GB+), the way you write group-by operations affects performance dramatically. This approach handles basic aggregations, window functions, UDAFs, broadcast joins, and partition tuning.
When to Use
- Datasets larger than 100GB that don’t fit on a single machine
- Aggregations across billions of rows (clickstream, IoT, transaction logs)
- When you need distributed processing across a cluster
- Pipelines that read from/write to distributed storage (S3, HDFS, GCS)
- When pandas/Polars run out of memory
When NOT to Use
- Datasets under 10GB — pandas or Polars are faster due to no serialization overhead
- Interactive analysis on small data — pandas is more ergonomic
- Real-time processing — use Structured Streaming or Flink
- Simple transformations without aggregation — a SQL query on the warehouse is simpler
Solution
Basic group-by aggregation
from pyspark.sql import SparkSession
from pyspark.sql import functions as F
spark = SparkSession.builder \
.appName("aggregations") \
.config("spark.sql.adaptive.enabled", "true") \
.config("spark.sql.adaptive.coalescePartitions.enabled", "true") \
.getOrCreate()
df = spark.read.parquet("s3://data-lake/orders/")
# Basic group-by
result = (
df
.groupBy("customer_id")
.agg(
F.sum("amount").alias("total_spent"),
F.count("order_id").alias("order_count"),
F.avg("amount").alias("avg_order_value"),
F.max("order_date").alias("last_order_date"),
F.min("order_date").alias("first_order_date"),
)
.orderBy(F.desc("total_spent"))
)
result.show(20)
Group by multiple columns
result = (
df
.groupBy("customer_id", F.date_format("order_date", "yyyy-MM").alias("month"))
.agg(
F.sum("amount").alias("monthly_spent"),
F.countDistinct("order_id").alias("unique_orders"),
)
.orderBy("customer_id", "month")
)
Window functions
from pyspark.sql import Window
# Define window specification
window_spec = Window.partitionBy("customer_id").orderBy(F.desc("order_date"))
# Row number — latest order gets 1
df_with_rank = df.withColumn(
"order_rank",
F.row_number().over(window_spec)
)
# Running total per customer ordered by date
running_total_window = (
Window
.partitionBy("customer_id")
.orderBy("order_date")
.rowsBetween(Window.unboundedPreceding, Window.currentRow)
)
df_with_running = df.withColumn(
"running_total",
F.sum("amount").over(running_total_window)
)
# Lag — previous order amount
df_with_lag = df.withColumn(
"prev_amount",
F.lag("amount", 1).over(window_spec)
)
# Percentile within group
df_with_pct = df.withColumn(
"amount_percentile",
F.percent_rank().over(Window.partitionBy("category").orderBy("amount"))
)
Multiple aggregations with different groupings
from pyspark.sql import DataFrame
def aggregate_multiple_ways(df: DataFrame) -> DataFrame:
"""Perform multiple aggregations in a single pass."""
return (
df
.groupBy("customer_id")
.agg(
F.sum("amount").alias("total_spent"),
F.sum(F.when(F.col("status") == "completed", F.col("amount")).otherwise(0)).alias("completed_amount"),
F.sum(F.when(F.col("status") == "cancelled", F.col("amount")).otherwise(0)).alias("cancelled_amount"),
F.count(F.when(F.col("amount") > 100, 1)).alias("large_orders"),
F.collect_set("category").alias("categories"),
F.expr("percentile(amount, 0.95)").alias("p95_amount"),
)
)
Broadcast join for small dimension tables
# Small dimension table — broadcast to all executors
customers = spark.read.parquet("s3://data-lake/customers/")
orders = spark.read.parquet("s3://data-lake/orders/")
# Broadcast join — avoids shuffle
joined = orders.join(
F.broadcast(customers),
on="customer_id",
how="left"
)
# Without broadcast — triggers a shuffle (slow for large tables)
# joined = orders.join(customers, on="customer_id", how="left")
Aggregation with pivot
# Pivot: rows=customer, columns=month, values=sum(amount)
pivoted = (
df
.groupBy("customer_id")
.pivot("month") # or .pivot("month", ["2025-01", "2025-02", "2025-03"])
.agg(F.sum("amount"))
)
User Defined Aggregate Function (UDAF)
from pyspark.sql.types import DoubleType
from pyspark.sql.functions import udf
from pyspark.sql.functions import struct
# Pandas UDF for custom aggregation (vectorized — faster than regular UDF)
@F.pandas_udf(DoubleType())
def custom_metric(amounts: pd.Series, quantities: pd.Series) -> float:
"""Weighted average price."""
total_qty = quantities.sum()
if total_qty == 0:
return 0.0
return (amounts * quantities).sum() / total_qty
result = (
df
.groupBy("customer_id")
.agg(
custom_metric(F.col("amount"), F.col("quantity")).alias("weighted_avg_price")
)
)
Partition tuning
# Set shuffle partitions (default is 200 — often too many for small data)
spark.conf.set("spark.sql.shuffle.partitions", "50")
# Repartition before group-by to avoid skew
df_repartitioned = df.repartition(100, "customer_id")
result = (
df_repartitioned
.groupBy("customer_id")
.agg(F.sum("amount").alias("total"))
)
# Coalesce after aggregation to reduce small files
result = result.coalesce(10)
result.write.parquet("s3://data-lake/aggregated/")
Handling data skew
# Salting technique for skewed keys
from pyspark.sql.functions import concat, lit, rand, floor, explode, array
# Add a salt key to split large groups
df_salted = df.withColumn(
"salt",
floor(rand() * 10).cast("int")
)
# Group by with salt — splits large groups across partitions
partial = (
df_salted
.groupBy("customer_id", "salt")
.agg(F.sum("amount").alias("partial_sum"))
)
# Second aggregation without salt to combine
final = (
partial
.groupBy("customer_id")
.agg(F.sum("partial_sum").alias("total_sum"))
)
Saving results
# Write as Parquet partitioned by date
result.write \
.partitionBy("year", "month") \
.mode("overwrite") \
.parquet("s3://data-lake/aggregated/orders_by_month/")
# Write as CSV
result.write \
.mode("overwrite") \
.option("header", "true") \
.csv("s3://data-lake/aggregated/orders_csv/")
# Write to Hive table
result.write \
.mode("overwrite") \
.saveAsTable("analytics.orders_summary")
Variants
Using Spark SQL
# Register DataFrame as a temp view
df.createOrReplaceTempView("orders")
result = spark.sql("""
SELECT
customer_id,
SUM(amount) AS total_spent,
COUNT(DISTINCT order_id) AS unique_orders,
PERCENTILE(amount, 0.95) AS p95_amount
FROM orders
WHERE status = 'completed'
GROUP BY customer_id
ORDER BY total_spent DESC
""")
Streaming aggregation with Structured Streaming
streaming_df = spark \
.readStream \
.format("kafka") \
.option("kafka.bootstrap.servers", "localhost:9092") \
.option("subscribe", "orders") \
.load()
aggregated = (
streaming_df
.selectExpr("CAST(value AS STRING) AS json")
.selectExpr("json_tuple(json, 'customer_id', 'amount') AS (customer_id, amount)")
.groupBy("customer_id")
.agg(F.sum("amount").alias("total"))
)
query = aggregated \
.writeStream \
.outputMode("complete") \
.format("console") \
.start()
Caching for iterative workloads
# Cache a DataFrame used multiple times
df_cached = df.filter(F.col("status") == "completed").cache()
# First action — materializes cache
result1 = df_cached.groupBy("customer_id").agg(F.sum("amount").alias("total"))
# Second action — uses cache
result2 = df_cached.groupBy("category").agg(F.avg("amount").alias("avg"))
# Unpersist when done
df_cached.unpersist()
Best Practices
-
For a deeper guide, see Parallel DataFrame Operations with Dask.
-
Set
spark.sql.shuffle.partitionsbased on data size — 200 is default, use fewer for small data -
Use
broadcast()for dimension tables under 10MB — avoids expensive shuffle -
Use Pandas UDFs instead of regular UDFs — vectorized, 10-100x faster
-
Filter early — push filters before joins and aggregations to reduce data volume
-
Use
coalesce()instead ofrepartition()when reducing partitions — avoids full shuffle -
Enable Adaptive Query Execution (
spark.sql.adaptive.enabled=true) — Spark optimizes at runtime -
Use
partitionBywhen writing — enables predicate pushdown for downstream reads -
Avoid
collect()on large DataFrames — brings all data to the driver
Common Mistakes
- Not setting shuffle partitions: default 200 creates tiny tasks for small aggregations. Set to 20-50 for small data.
- Using regular UDFs instead of Pandas UDFs: regular UDFs serialize each row individually. Pandas UDFs process in batches.
- Not broadcasting small tables: a 5MB dimension table shuffled across 200 partitions is wasteful. Use
broadcast(). - Calling
collect()on large results: brings all data to the driver and crashes it. Useshow(),take(), or write to storage. - Not caching reused DataFrames: if you use a DataFrame 3+ times, cache it. Otherwise Spark recomputes the lineage each time.
FAQ
How many shuffle partitions should I set?
Rule of thumb: aim for 100-200MB per partition. For 10GB of shuffled data, use 50-100 partitions. For 1TB, use 5000-10000. Enable AQE and let Spark coalesce automatically.
What is the difference between repartition() and coalesce()?
repartition() does a full shuffle to redistribute data. coalesce() merges existing partitions without shuffle. Use coalesce() when reducing partitions and repartition() when increasing or when data is skewed.
How do I handle data skew in group-by?
Use the salting technique: add a random salt (0-9) to the key, aggregate in two stages. Or enable spark.sql.adaptive.skewJoin.enabled=true for join skew.
Should I use DataFrame API or Spark SQL?
Both compile to the same Catalyst optimizer plan. Use whichever is more readable for your team. DataFrame API is better for dynamic/programmatic queries, SQL for static ones.
How do I monitor Spark performance?
Use the Spark UI (port 4040 by default). Check the Stages tab for shuffle read/write sizes, task duration, and skew. Use explain() on DataFrames to see the physical plan.
Related Resources
High-Performance DataFrame Operations with Polars
How to use Polars for fast DataFrame operations with lazy evaluation, expression API, streaming, and interop with pandas for large datasets.
RecipeSchedule and Monitor DAGs with Apache Airflow
How to define, schedule, and monitor Directed Acyclic Graphs in Apache Airflow with operators, sensors, XCom, and task dependencies.
RecipeParallel DataFrame Operations with Dask
How to use Dask for parallel DataFrame operations on datasets larger than memory, covering lazy evaluation, partitions, custom computations, and distributed scheduling.
RecipeRecursive CTEs for Hierarchical Data Queries
How to query hierarchical data with recursive Common Table Expressions in SQL, covering tree traversal, org charts, category trees, and cycle detection.