Post

Apache Spark Transformations Every Data Engineer Should Know — A Practical Guide

In this article, I want to walk through the Apache Spark transformations that I reach for every day in real pipelines. There are dozens of transformations in the Spark API, but in practice, a handful of them cover most of what you need. The goal here is not to list every transformation — the docs do that better — but to show you the ones that matter, with concrete examples, and point out where people (myself included) tend to trip up.

If you have been writing Spark for a while, you probably know most of these. But you might still pick up a thing or two about how they behave under the hood — and that is often the difference between a job that finishes in five minutes and one that runs for an hour.

Narrow vs Wide Transformations — Why It Matters

Before jumping into specific transformations, let us get one concept out of the way: narrow versus wide dependencies. It sounds academic, but it directly affects shuffle and performance.

Narrow transformations are those where each partition of the parent RDD (or DataFrame) contributes to exactly one partition of the child. Think map, filter, flatMap. No data moves across the network.

Wide transformations require data to be shuffled across partitions. Think groupByKey, reduceByKey, join. Spark has to redistribute data so that records with the same key end up on the same executor. This is expensive.

Here is a quick comparison:

TransformationTypeCauses Shuffle?When to Use
mapNarrowNoRow-level transformation, one input → one output
filterNarrowNoDropping rows that do not meet a condition
flatMapNarrowNoOne input → zero or more outputs (e.g. splitting arrays)
reduceByKeyWideYes (but combines locally first)Aggregating by key when the combine operation is associative
groupByKeyWideYes (full shuffle)When you genuinely need the whole list of values per key
joinWideYesCombining two datasets on a common key
distinctWideYesRemoving duplicate rows
coalesceNarrowNo (but can cause skew)Reducing partition count without full shuffle
repartitionWideYes (full shuffle)Increasing partitions or fixing skew

The key takeaway: not all wide transformations are created equal. reduceByKey does a map-side combine before the shuffle, so it moves less data than groupByKey. That small difference matters at scale.

map, filter, and flatMap — The Bread and Butter

These three are the narrow transformations you will write the most. They are straightforward, but there are a couple of things worth pointing out.

map

map applies a function to every row and returns exactly one output row per input row. It is the simplest transformation.

1
2
3
4
5
6
7
from pyspark.sql import SparkSession
spark = SparkSession.builder.appName("transformations").getOrCreate()

# Using RDD
rdd = spark.sparkContext.parallelize([1, 2, 3, 4, 5])
squared = rdd.map(lambda x: x * x)
print(squared.collect())  # [1, 4, 9, 16, 25]

In the DataFrame API, you would usually use withColumn with a UDF or built-in function instead of mapping directly. But the idea is the same.

filter

filter keeps rows where a condition is true. Nothing surprising here, but remember: filtering early in your pipeline is one of the simplest ways to improve performance. The less data Spark has to shuffle, the faster your job runs.

1
2
3
4
5
6
7
# DataFrame
df = spark.read.parquet("s3://bucket/events")
active_events = df.filter(df.status == "active")

# RDD
rdd = spark.sparkContext.parallelize([1, 2, 3, 4, 5])
evens = rdd.filter(lambda x: x % 2 == 0)

A common pattern I use is filtering as soon as possible after reading — before any join or aggregation. If you only need data from the last 7 days, push that filter into your read if the format supports it (Parquet and Delta do, with partition pruning).

flatMap

flatMap is the one people new to Spark sometimes overlook. It is like map, except each input row can produce zero, one, or many output rows. It is the go-to for exploding nested structures when you are working with RDDs.

1
2
3
4
# Breaking sentences into words
sentences = spark.sparkContext.parallelize(["hello world", "foo bar baz"])
words = sentences.flatMap(lambda s: s.split(" "))
print(words.collect())  # ['hello', 'world', 'foo', 'bar', 'baz']

In the DataFrame world, explode serves a similar purpose. But when you are reading messy semi-structured data that does not map neatly to columns, flatMap on an RDD can be the quickest way to shape it.

reduceByKey vs groupByKey — Know the Difference

This is the one distinction that I see bite engineers the most. Both group values by key, but they do it differently.

reduceByKey

reduceByKey first combines values locally within each partition (map-side combine), then shuffles the reduced results. This means far less data moves over the network.

1
2
3
4
5
# Word count — the classic example
rdd = spark.sparkContext.parallelize(["a", "b", "a", "c", "b", "a"])
pairs = rdd.map(lambda x: (x, 1))
counts = pairs.reduceByKey(lambda a, b: a + b)
print(counts.collect())  # [('a', 3), ('b', 2), ('c', 1)]

groupByKey

groupByKey shuffles all values as-is, then groups them at the destination. If you have large value lists, this can be a disaster for memory and network.

1
2
3
# This works, but avoid it if reduceByKey can do the job
pairs = spark.sparkContext.parallelize([("a", 1), ("b", 2), ("a", 3)])
grouped = pairs.groupByKey().mapValues(sum)

In practice, I almost always reach for reduceByKey (or aggregateByKey, or the DataFrame equivalent .groupBy().agg()) instead of groupByKey. The only exception is when the aggregation function is not associative and commutative — but honestly, in 90% of real-world pipelines, it is.

Joins in Spark — It Is All About the Keys

Joins are where Spark pipelines get interesting. A poorly planned join is the fastest way to blow up your shuffle and get an out-of-memory error.

Basic Join Types

1
2
3
4
5
# DataFrame join
df1 = spark.createDataFrame([(1, "Alice"), (2, "Bob")], ["id", "name"])
df2 = spark.createDataFrame([(1, "NYC"), (2, "SF")], ["id", "city"])
result = df1.join(df2, on="id", how="inner")
result.show()

how can be inner, left, right, full_outer, left_semi, or left_anti. The last two are underrated. left_semi returns rows from the left side that have a match on the right — kind of like an IN subquery in SQL. left_anti returns rows from the left side that do not have a match — great for finding missing records.

Broadcast Joins

If one side of your join is small enough to fit in memory on each executor, use a broadcast join. Spark sends the small table to every executor, and the join happens locally without a shuffle.

1
2
3
4
5
6
from pyspark.sql.functions import broadcast

small_df = spark.read.parquet("s3://bucket/dim_customer")
large_df = spark.read.parquet("s3://bucket/fact_sales")

result = large_df.join(broadcast(small_df), on="customer_id", how="left")

By default, Spark auto-broadcasts tables under 10 MB (configurable via spark.sql.autoBroadcastJoinThreshold). But I have seen cases where the stats were off and Spark did not broadcast a table that was clearly small enough. Explicitly using broadcast() removes the guesswork.

Skew and Salting

If a few keys in your join are massively overrepresented — think null keys or a “default” user — one partition gets all the data, and your job crawls. This is data skew.

A practical fix is salting: split the hot keys by appending a random suffix, do the join, then strip the suffix.

1
2
3
4
5
6
7
8
from pyspark.sql.functions import col, monotonically_increasing_id, expr, concat, lit

# Add a salt column (0 to 9) to break up hot keys
df1_salted = df1.withColumn("salt", (monotonically_increasing_id() % 10).cast("string"))
df1_salted = df1_salted.withColumn("join_key", concat(col("key"), lit("_"), col("salt")))

# Replicate the small side 10 times so every salt value matches
# Then join on (original_key + salt) and drop salt afterward

This is a bit of boilerplate, but it works. In production, I look at the Spark UI for task duration skew — if one task takes 20x longer than the median, you probably have a hot key.

Practical Limitations and Caveats

Here are a few things I have learned the hard way:

  1. RDD API vs DataFrame API: The DataFrame API (with Catalyst optimizer) is almost always faster than raw RDD transformations for structured data. I use RDDs only when I am dealing with unstructured or semi-structured data that does not fit neatly into rows and columns.

  2. Lazy evaluation: Transformations are lazy. Nothing runs until you call an action like collect(), count(), write(), or show(). This is great for optimisation, but it means you will not catch type errors or null issues until runtime. Always test with a small subset first.

  3. Accumulators and broadcast variables: These are how you share state across executors without a shuffle. Use broadcast variables for read-only lookup data and accumulators for counters or sums. But be careful — accumulators inside transformations that get re-run (due to task retries or speculative execution) can be incremented more than once.

  4. Partitioning matters: After a wide transformation, Spark often creates 200 partitions by default (spark.sql.shuffle.partitions). For small datasets, this creates too many tiny tasks. For large ones, 200 might not be enough. Tune it.

  5. Avoid UDFs where possible: Python UDFs break the Catalyst optimiser and add serialisation overhead. Use built-in Spark SQL functions whenever you can. If you must use a UDF, try Pandas UDFs (vectorised) — they are faster than row-at-a-time Python UDFs.

Production Considerations

Everything above works fine in a notebook. Here is what changes when you take it to production:

  • Separate compute from config: Keep your transformation logic in version-controlled code, not in notebook cells. Parameterise paths, dates, and thresholds so the same code runs in dev and prod.
  • Handle late-arriving data: If your pipeline re-runs hourly, use append-only writes with a watermark or dedup logic to avoid double-counting.
  • Monitor shuffle spill: In the Spark UI, check whether shuffle data is spilling to disk. If it is, you probably need more memory per executor or better partitioning.
  • Test with production volume on staging: A transformation that works on 1 GB might blow up at 100 GB. Use a representative sample of real data, not just synthetic data, for load testing.
  • Use Delta Lake or Iceberg: These table formats give you schema enforcement, time travel, and ACID transactions — all of which make your transformation pipelines more robust than writing raw Parquet.

Wrapping Up

This is not an exhaustive list of Spark transformations, but these are the ones that come up every week in real data engineering work. The difference between a Spark job that runs smoothly and one that has you staring at the logs at 2 AM often comes down to understanding how these transformations behave under load — narrow vs wide, map-side combine vs full shuffle, broadcast joins vs salted joins.

If you are starting out, write a few pipelines with these transformations, throw some real data at them, and watch the Spark UI while it runs. That is honestly the fastest way to build intuition. The docs tell you what each transformation does; the execution plan tells you what it costs.

This post is licensed under CC BY 4.0 by the author.