Spark Core · Module 2 · Examples
One query, six ways
The same question answered with each API, so you can see exactly what Spark can and can't optimise. Then every conversion between them, and better alternatives to dropping down to RDDs.
The question
An orders table in Parquet with 42 columns, including cust_id, segment, country and amount. Total order amount per customer segment, for the UAE only.
totals = (spark.read.parquet("s3://lake/orders").rdd # RDD of Row objects
.filter(lambda r: r.country == "AE")
.map(lambda r: (r.segment, r.amount))
.reduceByKey(lambda a, b: a + b)) # map-side combine, then shuffle
totals.collect()
- The scan reads all 42 columns of every row: Spark can't see that the lambdas only use three.
- The filter isn't pushed down, so no Parquet row groups are skipped.
- Every row is pickled, sent to a Python worker, filtered and mapped there, then sent back.
reduceByKeystill combines on the map side before the shuffle, so at least that part is efficient.
from pyspark.sql import functions as F
out = (spark.read.parquet("s3://lake/orders")
.filter(F.col("country") == "AE")
.groupBy("segment")
.agg(F.sum("amount").alias("total")))
out.show()
- Reads only 3 columns (
country,segment,amount). - The filter is pushed into the Parquet reader, which skips row groups whose statistics rule out "AE".
- A
partial_sumruns before the shuffle, and the whole stage is compiled to one generated function. - No Python involved at run time: Python only described the plan.
spark.read.parquet("s3://lake/orders").createOrReplaceTempView("orders")
out = spark.sql("""
SELECT segment, SUM(amount) AS total
FROM orders
WHERE country = 'AE'
GROUP BY segment
""")
- Parses into the same logical plan as the DataFrame version, so
out.explain()prints the same physical plan. - Pick SQL or the DataFrame API for readability and team habits, not performance.
import spark.implicits._
case class Order(cust_id: Long, segment: String, country: String, amount: Double)
val ds = spark.read.parquet("s3://lake/orders").as[Order]
// typed style: compile-time checked, but lambdas are opaque to Catalyst
val typed = ds.filter(_.country == "AE")
.groupByKey(_.segment)
.mapValues(_.amount)
.reduceGroups(_ + _)
// column style on the same Dataset: fully optimised
val fast = ds.filter($"country" === "AE")
.groupBy("segment")
.agg(sum("amount").as("total"))
- The typed version catches mistakes like
_.countrryat compile time, but each row is deserialised into anOrderobject for the lambda, and the filter can't be pushed down. - The column version gets the full Catalyst treatment. Many teams use typed Datasets at pipeline boundaries and column expressions inside.
import pandas as pd
pdf = pd.read_parquet("orders.parquet", columns=["segment", "country", "amount"])
out = pdf[pdf["country"] == "AE"].groupby("segment")["amount"].sum()
- Each line runs immediately. No cluster, no plan, no shuffle: fastest of all when the data fits in one machine's memory.
- Selecting columns on read matters here: pandas has no optimiser to do it for you.
- Good fit for small reference data, results after Spark has aggregated, or feeding plotting and ML libraries.
import pyspark.pandas as ps
psdf = ps.read_parquet("s3://lake/orders")
out = psdf[psdf["country"] == "AE"].groupby("segment")["amount"].sum()
out.head() # runs a Spark job
- The same syntax as pandas, but each call is translated into a Spark DataFrame plan and runs distributed.
- It keeps a pandas-style index. Creating a sequential default index costs an extra pass over the data; pass
index_col, or setcompute.default_index_typetodistributedwhen you don't need consecutive values. - Useful for moving existing pandas code to big data with few changes. For new pipelines, the DataFrame API is clearer about what Spark does.
What Spark sees
The real difference shows up in the physical plan. This is the DataFrame (or SQL) version's scan, from out.explain():
+- FileScan parquet [segment,country,amount]
PushedFilters: [IsNotNull(country), EqualTo(country,AE)]
ReadSchema: struct<segment:string,country:string,amount:double>
The RDD version has no equivalent: the scan reads the full 42-column schema with no pushed filters, and the filter shows up only as Python work inside the stage. You can see its lineage with totals.toDebugString(), but it has no optimiser output to read.
| Version | Columns read | Filter pushed down | Where the logic runs |
|---|---|---|---|
| RDD | 42 | No | Python workers, row by row |
| DataFrame / SQL | 3 | Yes | JVM, generated code |
| Dataset, typed lambdas | 4 | No | JVM, per-object lambdas |
| Dataset, column style | 3 | Yes | JVM, generated code |
| pandas | 3 (you chose) | n/a | One Python process |
| pandas API on Spark | 3 | Yes | JVM, generated code |
The typed Dataset reads the four fields of Order: the class tells Spark which columns are needed, but not which rows can be skipped.
Moving between them
| From → to | Code | Watch out |
|---|---|---|
| Spark DataFrame → pandas | df.toPandas() | Collects every row to the driver. Enable Arrow; use only on small results. |
| pandas → Spark DataFrame | spark.createDataFrame(pdf) | The data starts on the driver. Arrow speeds it up; pass a schema to control types. |
| DataFrame → RDD | df.rdd | An RDD of Row objects; optimisation stops here. Not available on Spark Connect. |
| RDD → DataFrame | rdd.toDF(["seg", "total"]) or spark.createDataFrame(rdd, schema) | Without a schema, Spark samples rows to infer types. Give one. |
| DataFrame → pandas API on Spark | df.pandas_api() | Adds a default index (see above). Spark 3.2+. |
| pandas API on Spark → DataFrame | psdf.to_spark(index_col="id") | The index is dropped unless you keep it with index_col. |
| pandas API on Spark → pandas | psdf.to_pandas() | Same as toPandas(): everything to the driver. |
| DataFrame ↔ Dataset (Scala) | df.as[Order] / ds.toDF() | Needs import spark.implicits._ for the encoder. |
# Arrow makes Spark ↔ pandas conversion much faster (off by default in open-source Spark)
spark.conf.set("spark.sql.execution.arrow.pyspark.enabled", "true")
small = big_df.filter("country = 'AE'").groupBy("segment").count().toPandas()
"I need my own Python function"
The usual reason people drop to RDDs. Try these in order:
- Built-in functions first. Most logic is expressible with
pyspark.sql.functions:when,regexp_extract,transformon arrays,from_json, window functions. Fastest, and fully optimised.df.withColumn("amount_with_tax", F.col("amount") * 1.05) - pandas UDF for column-wise logic that needs Python libraries. Data moves in Arrow batches and runs vectorised (what that means).
import pandas as pd from pyspark.sql.functions import pandas_udf @pandas_udf("double") def with_tax(amount: pd.Series) -> pd.Series: return amount * 1.05 df.withColumn("amount_with_tax", with_tax("amount")) mapInPandaswhen you need whole batches of rows, for example scoring a model, without grouping.def score(batches): for pdf in batches: # an iterator of pandas DataFrames pdf["score"] = model.predict(pdf[["amount"]]) yield pdf df.mapInPandas(score, schema="cust_id long, amount double, score double")applyInPandaswhen the logic needs a whole group at once (per-customer models, custom time-series fills). Watch group sizes: each group must fit in one Python worker. See Pandas UDF OOM.
Check yourself
Why does the RDD version read all 42 columns?
Would rewriting the DataFrame query in SQL make it faster?
When is plain pandas the best choice in a Spark project?
You need a Python library function applied to each row of a 2 TB table. What do you use?
mapInPandas if you need whole rows), not an RDD map. Arrow batches and vectorised execution are much faster than row-by-row pickling, and the rest of the plan stays optimised.