DE Learning

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.

runs on the clusternot optimisedPython per row
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.
  • reduceByKey still combines on the map side before the shuffle, so at least that part is efficient.

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.

VersionColumns readFilter pushed downWhere the logic runs
RDD42NoPython workers, row by row
DataFrame / SQL3YesJVM, generated code
Dataset, typed lambdas4NoJVM, per-object lambdas
Dataset, column style3YesJVM, generated code
pandas3 (you chose)n/aOne Python process
pandas API on Spark3YesJVM, 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 → toCodeWatch out
Spark DataFrame → pandasdf.toPandas()Collects every row to the driver. Enable Arrow; use only on small results.
pandas → Spark DataFramespark.createDataFrame(pdf)The data starts on the driver. Arrow speeds it up; pass a schema to control types.
DataFrame → RDDdf.rddAn RDD of Row objects; optimisation stops here. Not available on Spark Connect.
RDD → DataFramerdd.toDF(["seg", "total"]) or spark.createDataFrame(rdd, schema)Without a schema, Spark samples rows to infer types. Give one.
DataFrame → pandas API on Sparkdf.pandas_api()Adds a default index (see above). Spark 3.2+.
pandas API on Spark → DataFramepsdf.to_spark(index_col="id")The index is dropped unless you keep it with index_col.
pandas API on Spark → pandaspsdf.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:

  1. Built-in functions first. Most logic is expressible with pyspark.sql.functions: when, regexp_extract, transform on arrays, from_json, window functions. Fastest, and fully optimised.
    df.withColumn("amount_with_tax", F.col("amount") * 1.05)
  2. 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"))
  3. mapInPandas when 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")
  4. applyInPandas when 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?
Spark can't look inside the Python lambdas to see which fields they use, so it must materialise whole rows. DataFrame expressions name their columns, so Catalyst prunes the rest.
Would rewriting the DataFrame query in SQL make it faster?
No. Same logical plan, same optimiser, same physical plan.
When is plain pandas the best choice in a Spark project?
When the data comfortably fits on one machine: small lookup tables, results already aggregated by Spark, local exploration, or handing data to plotting and ML libraries that need pandas.
You need a Python library function applied to each row of a 2 TB table. What do you use?
A pandas UDF (or 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.