Agregaciones a Gran Escala con PySpark
Cómo realizar agregaciones group-by en datasets grandes con PySpark, cubriendo window functions, UDFs, broadcast joins y tuning de performance.
Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.
Overview
PySpark es la API de Python para Apache Spark, un engine de procesamiento de datos distribuido. Las agregaciones group-by son una de las operaciones más comunes en pipelines de datos — sumar revenue por customer, contar eventos por día, promediar métricas por región. En datasets grandes (100GB+), la forma en que escribes las operaciones group-by afecta la performance dramáticamente. El siguiente enfoque cubre agregaciones básicas, window functions, UDAFs, broadcast joins y tuning de particiones.
When to Use
- Datasets más grandes que 100GB que no caben en una sola máquina
- Agregaciones a través de billones de rows (clickstream, IoT, transaction logs)
- Cuando necesitas procesamiento distribuido a través de un cluster
- Pipelines que leen de/escriben a storage distribuido (S3, HDFS, GCS)
- Cuando pandas/Polars se quedan sin memoria
When NOT to Use
- Datasets bajo 10GB — pandas o Polars son más rápidos por no tener overhead de serialización
- Análisis interactivo en data pequeña — pandas es más ergonómico
- Procesamiento real-time — usa Structured Streaming o Flink
- Transformaciones simples sin agregación — un SQL query en el warehouse es más simple
Solution
Agregación group-by básica
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/")
# Group-by básico
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 múltiples columnas
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
# Definir window specification
window_spec = Window.partitionBy("customer_id").orderBy(F.desc("order_date"))
# Row number — el order más reciente gets 1
df_with_rank = df.withColumn(
"order_rank",
F.row_number().over(window_spec)
)
# Running total per customer ordered por 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 — amount del order anterior
df_with_lag = df.withColumn(
"prev_amount",
F.lag("amount", 1).over(window_spec)
)
# Percentile dentro del group
df_with_pct = df.withColumn(
"amount_percentile",
F.percent_rank().over(Window.partitionBy("category").orderBy("amount"))
)
Múltiples agregaciones con diferentes groupings
from pyspark.sql import DataFrame
def aggregate_multiple_ways(df: DataFrame) -> DataFrame:
"""Realizar múltiples agregaciones en un solo 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 para tablas de dimensiones pequeñas
# Tabla de dimensiones pequeña — broadcast a todos los executors
customers = spark.read.parquet("s3://data-lake/customers/")
orders = spark.read.parquet("s3://data-lake/orders/")
# Broadcast join — evita shuffle
joined = orders.join(
F.broadcast(customers),
on="customer_id",
how="left"
)
# Sin broadcast — triggerea un shuffle (lento para tablas grandes)
# joined = orders.join(customers, on="customer_id", how="left")
Agregación con 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 para agregación custom (vectorized — más rápido que 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")
)
)
Tuning de particiones
# Setear shuffle partitions (default es 200 — a menudo demasiado para data pequeña)
spark.conf.set("spark.sql.shuffle.partitions", "50")
# Repartition antes de group-by para evitar skew
df_repartitioned = df.repartition(100, "customer_id")
result = (
df_repartitioned
.groupBy("customer_id")
.agg(F.sum("amount").alias("total"))
)
# Coalesce después de agregación para reducir small files
result = result.coalesce(10)
result.write.parquet("s3://data-lake/aggregated/")
Manejar data skew
# Técnica de salting para keys skewed
from pyspark.sql.functions import concat, lit, rand, floor, explode, array
# Agregar salt key para splittear grupos grandes
df_salted = df.withColumn(
"salt",
floor(rand() * 10).cast("int")
)
# Group by con salt — splittea grupos grandes a través de particiones
partial = (
df_salted
.groupBy("customer_id", "salt")
.agg(F.sum("amount").alias("partial_sum"))
)
# Segunda agregación sin salt para combinar
final = (
partial
.groupBy("customer_id")
.agg(F.sum("partial_sum").alias("total_sum"))
)
Guardar resultados
# Escribir como Parquet partitioned por date
result.write \
.partitionBy("year", "month") \
.mode("overwrite") \
.parquet("s3://data-lake/aggregated/orders_by_month/")
# Escribir como CSV
result.write \
.mode("overwrite") \
.option("header", "true") \
.csv("s3://data-lake/aggregated/orders_csv/")
# Escribir a Hive table
result.write \
.mode("overwrite") \
.saveAsTable("analytics.orders_summary")
Variants
Usar Spark SQL
# Registrar DataFrame como 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
""")
Agregación streaming con 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 para workloads iterativos
# Cachear un DataFrame usado múltiples veces
df_cached = df.filter(F.col("status") == "completed").cache()
# Primera action — materializa el cache
result1 = df_cached.groupBy("customer_id").agg(F.sum("amount").alias("total"))
# Segunda action — usa el cache
result2 = df_cached.groupBy("category").agg(F.avg("amount").alias("avg"))
# Unpersist cuando termines
df_cached.unpersist()
Best Practices
-
For a deeper guide, see Parallel DataFrame Operations with Dask.
-
Setea
spark.sql.shuffle.partitionsbasado en el tamaño de data — 200 es default, usa menos para data pequeña -
Usa
broadcast()para tablas de dimensiones bajo 10MB — evita shuffle expensive -
Usa Pandas UDFs en lugar de regular UDFs — vectorized, 10-100x más rápido
-
Filtra temprano — pushea filters antes de joins y agregaciones para reducir el volumen de data
-
Usa
coalesce()en lugar derepartition()cuando reduces particiones — evita full shuffle -
Habilita Adaptive Query Execution (
spark.sql.adaptive.enabled=true) — Spark optimiza en runtime -
Usa
partitionByal escribir — habilita predicate pushdown para reads downstream -
Evita
collect()en DataFrames grandes — trae toda la data al driver
Common Mistakes
- No setear shuffle partitions: default 200 crea tasks tiny para agregaciones pequeñas. Setea a 20-50 para data pequeña.
- Usar regular UDFs en lugar de Pandas UDFs: regular UDFs serializan cada row individualmente. Pandas UDFs procesan en batches.
- No broadcastear tablas pequeñas: una tabla de dimensión de 5MB shufflada a través de 200 particiones es wasteful. Usa
broadcast(). - Llamar
collect()en resultados grandes: trae toda la data al driver y lo crashea. Usashow(),take()o escribe a storage. - No cachear DataFrames reusados: si usas un DataFrame 3+ veces, cachéalo. Si no, Spark recomputa el lineage cada vez.
FAQ
¿Cuántas shuffle partitions debería setear?
Regla general: apunta a 100-200MB por partición. Para 10GB de data shufflada, usa 50-100 particiones. Para 1TB, usa 5000-10000. Habilita AQE y deja que Spark coalesce automáticamente.
¿Cuál es la diferencia entre repartition() y coalesce()?
repartition() hace un full shuffle para redistribuir data. coalesce() mergea particiones existentes sin shuffle. Usa coalesce() cuando reduces particiones y repartition() cuando aumentas o cuando la data está skewed.
¿Cómo manejo data skew en group-by?
Usa la técnica de salting: agrega un salt random (0-9) al key, agrega en dos stages. O habilita spark.sql.adaptive.skewJoin.enabled=true para join skew.
¿Debería usar DataFrame API o Spark SQL?
Ambos compilan al mismo plan del Catalyst optimizer. Usa el que sea más legible para tu equipo. DataFrame API es mejor para queries dinámicos/programáticos, SQL para estáticos.
¿Cómo monitoreo la performance de Spark?
Usa la Spark UI (port 4040 por default). Chequea el tab de Stages para tamaños de shuffle read/write, duración de tasks y skew. Usa explain() en DataFrames para ver el physical plan.
Recursos Relacionados
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.