Spark builds a query plan before a DataFrame action distributes work across executors. Reading that plan exposes the filters, projections, aggregations, shuffles, and sorting Spark expects to perform, which helps distinguish an expensive transformation shape from a problem in the surrounding job.
The DataFrame.explain() method can show the plan at different levels. formatted mode pairs a compact physical operator tree with details for each numbered node, while extended mode shows how the parsed expression becomes an analyzed plan, an optimized logical plan, and executable physical operators.
Generated attribute numbers, plan IDs, and adaptive-plan details can differ across Spark releases or session settings. Match operator names to the transformation first, then use Exchange boundaries, aggregate stages, projections, and pushed filters to understand where Spark will move or reduce data.
from pyspark.sql import SparkSession from pyspark.sql import functions as F spark = ( SparkSession.builder.appName("dataframe-explain-plan") .config("spark.ui.enabled", "false") .config("spark.sql.adaptive.enabled", "true") .getOrCreate() ) spark.sparkContext.setLogLevel("ERROR")
orders = spark.createDataFrame( [ ("EMEA", "analytics", 1200), ("EMEA", "analytics", 800), ("APAC", "platform", 500), ("EMEA", "platform", 700), ("AMER", "analytics", 400), ], ["region", "team", "amount"], )
Related: How to create a Spark DataFrame
totals = ( orders.filter(F.col("region") == "EMEA") .groupBy("team") .agg(F.sum("amount").alias("total_amount")) .orderBy(F.col("total_amount").desc()) )
print("Formatted plan") totals.explain(mode="formatted")
print("Extended plan") totals.explain(mode="extended")
simple prints only the physical plan. cost adds logical-plan statistics when Spark has them, while codegen prints generated code for supported physical operators.
print("Result rows") totals.show()
spark.stop()
$ spark-submit --master 'local[2]' dataframe_explain_plan.py
Formatted plan
== Physical Plan ==
AdaptiveSparkPlan (9)
+- Sort (8)
+- Exchange (7)
+- HashAggregate (6)
+- Exchange (5)
+- HashAggregate (4)
+- Project (3)
+- Filter (2)
+- Scan ExistingRDD (1)
##### snipped #####
In this plan, Filter represents the region == “EMEA” condition, while Project retains only columns needed later. The two HashAggregate nodes perform partial and final aggregation. Each Exchange marks a shuffle boundary, while Sort orders the aggregated rows.
Extended plan
== Parsed Logical Plan ==
'Sort ['total_amount DESC NULLS LAST], true
+- Aggregate [team#1], [team#1, sum(amount#2L) AS total_amount#3L]
+- Filter (region#0 = EMEA)
+- LogicalRDD [region#0, team#1, amount#2L], false
##### snipped #####
== Optimized Logical Plan ==
Sort [total_amount#3L DESC NULLS LAST], true
+- Aggregate [team#1], [team#1, sum(amount#2L) AS total_amount#3L]
+- Project [team#1, amount#2L]
+- Filter (isnotnull(region#0) AND (region#0 = EMEA))
+- LogicalRDD [region#0, team#1, amount#2L], false
##### snipped #####
The optimized plan adds a null check and prunes region before aggregation. The physical section chooses executable operators and inserts shuffle boundaries for the grouped sum and global ordering.
Related: How to configure Spark adaptive query execution
Result rows +---------+------------+ | team|total_amount| +---------+------------+ |analytics| 2000| | platform| 700| +---------+------------+