Catalyst and reading explain()

PySpark · Advanced · 15 min

explain() is how you verify that Spark will do what you expect before burning cluster hours.

Every DataFrame or SQL query goes through Catalyst, Spark's optimizer:

1. Parsed logical plan: what you wrote. 2. Analyzed plan: names and types resolved against the catalog. Unknown columns fail here. 3. Optimized logical plan: rule-based rewrites such as predicate pushdown, column pruning, constant folding, combining filters and simplifying expressions. 4. Physical plan: concrete operators chosen, like scans, HashAggregate, SortMergeJoin or BroadcastHashJoin, and Exchange (shuffle), with cost-based choices.

df.explain() prints the physical plan; df.explain("formatted") gives a numbered, readable version; "extended" shows all four; "cost" adds statistics.

What to look for:

  • `Exchange` nodes are shuffles. Count them; each one is a stage boundary.
  • `PushedFilters` and `ReadSchema` on a file scan show filter pushdown and column pruning into Parquet.
  • `PartitionFilters` show partition pruning on partitioned tables.
  • The join operator chosen (broadcast versus sort-merge).
  • *(n) prefixes or WholeStageCodegen mark operators fused into generated code.
  • `AdaptiveSparkPlan isFinalPlan=false` means AQE may still change the plan at runtime.

Python UDFs show up as BatchEvalPython or ArrowEvalPython, and filters can't be pushed through them.

Example

from pyspark.sql import SparkSession, functions as F
spark = SparkSession.builder.master("local[2]").getOrCreate()

(spark.range(1000).withColumn("country", F.when(F.col("id") % 2 == 0, "NO").otherwise("SE"))
      .withColumn("amount", F.col("id") * 1.5)
      .write.mode("overwrite").parquet("sales_pq"))

q = (spark.read.parquet("sales_pq")
     .filter(F.col("country") == "NO")
     .groupBy("country").agg(F.sum("amount").alias("total")))
q.explain("formatted")
# == Physical Plan ==
# AdaptiveSparkPlan (...)
# +- HashAggregate (...)
#    +- Exchange (...)                      <- shuffle
#       +- HashAggregate (...)              <- partial aggregation
#          +- Filter (...)
#             +- Scan parquet  (...)
# ...
# PushedFilters: [IsNotNull(country), EqualTo(country,NO)]
# ReadSchema: struct<country:string,amount:double>   (id is pruned)
print(q.first().total)          # 374250.0

Remember

  • Catalyst: parsed, analyzed, optimized logical, then physical plan
  • Exchange = shuffle; PushedFilters and ReadSchema = pushdown and pruning
  • Use explain("formatted") before running costly jobs

Watch out for

  • Assuming a filter is pushed into the scan without checking PushedFilters, e.g. when a UDF sits in between.

Check yourself

  1. In a physical plan, what does an Exchange node indicate?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: spark.apache.org/docs/latest/sql-performance-tuning.html