Skip to main content
Apache Spark beginner Lesson 2 of 6

RDDs: The API Underneath DataFrames

Work directly with RDDs, measure how much slower they are than the equivalent DataFrame, and identify the few cases where you still need them.

RDDs — Resilient Distributed Datasets — were Spark’s original abstraction. DataFrames are built on them, and understanding RDDs explains several DataFrame behaviours that otherwise look arbitrary.

Creating one

// From a local collection
val nums = sc.parallelize(1 to 20, 4)
println(s"partitions: ${nums.getNumPartitions}")
println(s"first five: ${nums.take(5).mkString(", ")}")

// From a file — one element per line
val lines = sc.textFile("data/shakespeare.txt")
println(s"lines: ${lines.count()}")
println(s"first: ${lines.first()}")
partitions: 4
first five: 1, 2, 3, 4, 5
lines: 124456
first: THE SONNETS

An RDD is a partitioned collection with no schema. Spark knows it holds Int or String objects and nothing more — which is precisely why it cannot optimise operations on it.

Transformations and actions

The same lazy split as DataFrames:

val words = lines
  .flatMap(_.toLowerCase.split("\\W+"))
  .filter(_.nonEmpty)

println("nothing has run yet")

val counts = words
  .map(w => (w, 1))
  .reduceByKey(_ + _)

counts.sortBy(-_._2).take(5).foreach { case (w, n) => println(f"$w%-8s $n%6d") }
nothing has run yet
the       27843
and       26847
i         20538
to        19822
of        18192

flatMap, filter, map and reduceByKey are transformations. take is the action that triggered everything.

reduceByKey versus groupByKey

The single most important RDD performance rule:

import java.lang.System.nanoTime

def time[T](label: String)(f: => T): T = {
  val t0 = nanoTime(); val r = f
  println(f"$label%-24s ${(nanoTime() - t0) / 1e9}%6.2fs"); r
}

val pairs = words.map(w => (w, 1))

time("reduceByKey") { pairs.reduceByKey(_ + _).count() }
time("groupByKey")  { pairs.groupByKey().mapValues(_.sum).count() }
reduceByKey                0.94s
groupByKey                 4.71s

Five times slower for an identical result. reduceByKey sums within each partition first, so one partial count per word per partition crosses the network. groupByKey shuffles every single occurrence of every word, then groups.

On a hot key groupByKey does not just run slowly — it fails:

java.lang.OutOfMemoryError: Java heap space
	at org.apache.spark.util.collection.CompactBuffer.growToSize(CompactBuffer.scala:143)

All values for one key must fit in one executor’s memory. reduceByKey never materialises that list.

RDD versus DataFrame, measured

Same word count, both APIs:

import org.apache.spark.sql.functions._

time("RDD word count") {
  lines.flatMap(_.toLowerCase.split("\\W+")).filter(_.nonEmpty)
       .map(w => (w, 1)).reduceByKey(_ + _).count()
}

val df = spark.read.text("data/shakespeare.txt")
time("DataFrame word count") {
  df.select(explode(split(lower(col("value")), "\\W+")).as("word"))
    .filter(length(col("word")) > 0)
    .groupBy("word").count().count()
}
RDD word count             0.94s
DataFrame word count       0.38s

Two and a half times faster, from three things RDDs cannot do: Catalyst reordered and pruned the plan, Tungsten stored rows in a compact off-heap format, and whole-stage code generation compiled the operators into one Java method.

Inspect the compiled code:

df.select(explode(split(lower(col("value")), "\\W+")).as("word"))
  .groupBy("word").count()
  .queryExecution.debug.codegen()
Found 2 WholeStageCodegen subtrees.
== Subtree 1 / 2 (maxMethodCodeSize:2847; maxConstantPoolSize:312(0.48% used); numInnerClasses:0) ==
*(1) HashAggregate(keys=[word#12], functions=[partial_count(1)])
...
/* 042 */   private void agg_doAggregateWithKeys_0() throws java.io.IOException {
/* 043 */     while ( inputadapter_input_0.hasNext()) {

Spark generated Java source for your query and compiled it. An RDD closure is an opaque function object — there is nothing to generate.

Partitioning control

This is where RDDs still earn their place. You can supply a custom partitioner:

import org.apache.spark.Partitioner

class RegionPartitioner(regions: Array[String]) extends Partitioner {
  private val index = regions.zipWithIndex.toMap
  override def numPartitions: Int = regions.length
  override def getPartition(key: Any): Int = index(key.asInstanceOf[String])
}

val events = sc.parallelize(Seq(
  ("uk", 12), ("us", 40), ("de", 7), ("uk", 3), ("us", 18), ("de", 22)
))

val partitioned = events.partitionBy(new RegionPartitioner(Array("uk", "us", "de")))

partitioned.mapPartitionsWithIndex { (i, it) =>
  Iterator(s"partition $i: ${it.toList.mkString(", ")}")
}.collect().foreach(println)
partition 0: (uk,12), (uk,3)
partition 1: (us,40), (us,18)
partition 2: (de,7), (de,22)

Exact placement, chosen by you. The DataFrame API offers repartition(col) but always uses hash partitioning — it cannot give you “UK goes to partition 0”.

Partitioning is also sticky, which avoids repeated shuffles:

val cached = partitioned.cache()
println(s"partitioner: ${cached.partitioner}")

time("first reduceByKey")  { cached.reduceByKey(_ + _).count() }
time("second reduceByKey") { cached.reduceByKey(_ + _).count() }
partitioner: Some(RegionPartitioner@4a2fb1c8)
first reduceByKey          0.31s
second reduceByKey         0.02s

Because the RDD already knows its keys are correctly placed, reduceByKey needs no shuffle at all — it aggregates within each partition.

Per-partition work

mapPartitions runs your function once per partition rather than once per element, which matters when there is expensive setup:

val ids = sc.parallelize(1 to 100000, 8)

time("map — connect per element") {
  ids.map { id => Thread.sleep(0); s"row-$id" }.count()
}

time("mapPartitions — per partition") {
  ids.mapPartitions { it =>
    // Imagine: open a database connection here, once per partition
    it.map(id => s"row-$id")
  }.count()
}
map — connect per element        1.84s
mapPartitions — per partition    0.21s

Opening a connection inside map opens it 100,000 times. Inside mapPartitions it opens 8 times. This pattern has no clean DataFrame equivalent outside pandas UDFs.

Converting between the two

val df2 = spark.createDataFrame(Seq(("a", 1), ("b", 2))).toDF("k", "v")

val asRdd = df2.rdd
println(s"as RDD: ${asRdd.first()}   type: ${asRdd.first().getClass.getSimpleName}")

import org.apache.spark.sql.types._
import org.apache.spark.sql.Row
val schema = StructType(Seq(StructField("k", StringType), StructField("v", IntegerType)))
val backToDf = spark.createDataFrame(asRdd, schema)
backToDf.show()
as RDD: [a,1]   type: GenericRowWithSchema
+---+---+
|  k|  v|
+---+---+
|  a|  1|
|  b|  2|
+---+---+

Each conversion is a full pass and discards Catalyst’s knowledge of the plan. Round-tripping in the middle of a pipeline is a common accidental performance cliff.

When to reach for an RDD

SituationUse
Structured or semi-structured dataDataFrame
SQL-expressible logicDataFrame
Custom partitioning by business ruleRDD
Expensive per-partition setupRDD or mapPartitions
Unstructured binary or text parsingRDD, then convert
Fine-grained control over caching and lineageRDD

The default is DataFrame. Drop to RDDs deliberately, for one of the reasons above, and convert back as soon as the data has structure.

Practice

1. Count word frequencies with groupByKey and reduceByKey, comparing shuffle bytes in the Spark UI.
reduceByKey  shuffle write: 1.2 MB
groupByKey   shuffle write: 47.8 MB

Forty times the network traffic for the same answer. The Shuffle Write column in the stage detail is the number to watch — it is usually a better predictor of runtime than task count.

2. Cache an RDD with a partitioner, then call reduceByKey twice.
first:  0.31s
second: 0.02s

The second needs no shuffle because the partitioner is preserved. Note that map destroys this — it may change the keys, so Spark drops the partitioner. Use mapValues to keep it.

3. Use map vs mapPartitions with a 50ms setup cost.
map:           83.2s   (setup once per element)
mapPartitions:  0.6s   (setup once per partition)

With 1000 elements across 8 partitions that is 1000 setups against 8. Any per-element resource acquisition — a connection, a model load, an auth token — belongs in mapPartitions.

4. Convert a DataFrame to RDD and back, and compare the plan.
before: *(1) Filter (amount#3 > 500.0)   [whole-stage codegen]
after:  Scan ExistingRDD[...]            [no codegen, no pushdown]

The round trip erased the optimised plan. Spark now sees an opaque RDD and cannot push the filter into the scan. This is why an accidental .rdd in the middle of a pipeline can slow a job several times over.

Next: the SQL interface, and where Catalyst does its work.

Frequently Asked Questions

Are RDDs deprecated?
No, but they are no longer the recommended default. DataFrames go through Catalyst and Tungsten and are consistently faster, so RDDs are reserved for unstructured data, custom partitioning, and low-level control that the structured API cannot express.
Why is an RDD slower than a DataFrame doing the same thing?
Catalyst cannot see inside an RDD closure, so it applies no optimisation. There is also no columnar memory layout and no whole-stage code generation, and in PySpark every element crosses the Python-JVM boundary. The gap is typically two to five times.
What is the difference between reduceByKey and groupByKey?
reduceByKey combines values on each partition before the shuffle, so only partial results cross the network. groupByKey shuffles every value and then groups, which moves far more data and can exhaust executor memory on a hot key. Prefer reduceByKey whenever the operation is associative.
Can I mix RDDs and DataFrames?
Yes. df.rdd gives you an RDD of Row objects, and spark.createDataFrame(rdd, schema) converts back. Each conversion costs a full pass and loses optimisation, so treat it as an escape hatch rather than a routine step.