Getting the plan
df.explain() # physical plan only
df.explain("formatted") # numbered operators + details per node (best for reading)
df.explain("extended") # parsed, analyzed, optimized logical + physical
df.explain("cost") # optimized logical plan with size/row statistics
df.explain("codegen") # generated Java code per whole-stage codegen block
EXPLAIN FORMATTED
SELECT c.country, sum(o.amount)
FROM orders o JOIN customers c ON o.customer_id = c.id
WHERE o.order_date >= '2026-11-01'
GROUP BY c.country;
Tip
With AQE on (the default since Spark 3.2),
explain()before execution showsAdaptiveSparkPlan isFinalPlan=false. That's the initial plan. Join strategies and partition counts can change at runtime. For the plan that actually ran, open the SQL / DataFrame tab in the Spark UI after the query finishes, or callexplain()on the same DataFrame after an action has completed.
Read the plan bottom-up: scans at the bottom, the final operator at the top. In formatted mode, the numbers in parentheses link the tree to the detail sections below it.
Scans and pushdown
FileScan parquet [order_id,customer_id,amount,order_date]
PartitionFilters: [isnotnull(order_date), (order_date >= 2026-11-01)]
PushedFilters: [IsNotNull(customer_id)]
DataFilters: [isnotnull(customer_id)]
ReadSchema: struct<order_id:bigint,customer_id:bigint,amount:decimal(18,2)>
| Field | Meaning | If it's wrong |
|---|---|---|
PartitionFilters | Directory-level pruning on partition columns | Empty when you filter on a derived expression (year(ts) = 2026) instead of the partition column itself; filter on the column |
PushedFilters | Predicates handed to the file reader for row-group skipping | Missing when there is a cast on the column (cast(id as string) = '42') or a UDF; fix the literal type instead |
DataFilters | Filters applied to the read data | Normal; pushdown can still skip row groups with them |
ReadSchema | Columns actually read | Too wide means a select * upstream or a UDF on a whole struct |
dynamicpruningexpression(...) in PartitionFilters | Dynamic partition pruning from a join with a filtered dimension | Absent when the join key isn't the partition column or the dimension side is large |
Delta and Iceberg scans show their own node names (for example BatchScan for DSv2 sources) and file skipping comes from table statistics. Names vary by version, so check your build's output.
Exchanges (shuffles)
| Operator | Meaning |
|---|---|
Exchange hashpartitioning(customer_id, 200) | Full shuffle by key into N partitions |
Exchange rangepartitioning(...) | Shuffle for a global orderBy or range repartition (samples first) |
Exchange RoundRobinPartitioning(n) | repartition(n) without columns |
Exchange SinglePartition | Everything to one task: global aggregate without grouping, limit before a sort, window without partitionBy |
BroadcastExchange | Small side collected to the driver and sent to every executor |
ReusedExchange | Same shuffle reused (self-join, CTE referenced twice). Good |
Every Exchange is a stage boundary, and the shuffle write and read time shows up in the stage metrics. Two exchanges on the same key in a row usually mean a repartition() that the join or aggregate then redid. Remove the manual one.
Warning
Exchange SinglePartitionon a large dataset is a single-task bottleneck, and can run out of memory in one executor. A window function withWindow.orderBy(...)and nopartitionByis the usual cause, and Spark logs a warning about moving all data to a single partition.
Join strategies
| Operator | When Spark picks it | What to watch |
|---|---|---|
BroadcastHashJoin | One side estimated under spark.sql.autoBroadcastJoinThreshold (10 MB default) or hinted, or AQE converted it at runtime | Driver memory and broadcast timeouts when estimates are wrong |
SortMergeJoin | Default for two large sides with equi-join keys | Two exchanges plus two sorts; skew shows as one slow task |
ShuffledHashJoin | Hinted, or chosen when one side is much smaller per partition (AQE can convert) | Builds a hash table per partition; can OOM on skewed keys |
BroadcastNestedLoopJoin | Non-equi join (<, between, OR across columns) with a broadcastable side | Cost is rows × rows; fine only when one side is tiny |
CartesianProduct | Non-equi join with no broadcastable side, or a missing join condition | Almost always a bug. Check the ON clause |
The BuildRight/BuildLeft label on hash joins shows which side is loaded into memory. If the big table is on the build side, the statistics are wrong. Run ANALYZE TABLE ... COMPUTE STATISTICS or use a hint.
Aggregates
| Operator | Meaning |
|---|---|
HashAggregate(keys=[..], functions=[partial_sum(..)]) then Exchange then HashAggregate(functions=[sum(..)]) | Normal two-phase aggregate: partial on the map side, final after the shuffle |
ObjectHashAggregate | Aggregates with object buffers (collect_list, percentile_approx); falls back to sort-based above spark.sql.objectHashAggregate.sortBased.fallbackThreshold |
SortAggregate | Sort-based fallback; slower. Seen with non-mutable buffer types or after fallback |
HashAggregate with count(distinct ...) appearing as extra aggregate stages and an Expand | Multiple distinct aggregates expand rows: one copy per distinct expression |
Expand with several count(DISTINCT ...) columns multiplies the input rows. Use approx_count_distinct where an exact count isn't required.
Python and Arrow nodes
| Operator | Meaning |
|---|---|
BatchEvalPython | Row-at-a-time Python UDF: rows pickled to a Python worker and back |
ArrowEvalPython | Pandas/Arrow UDF (vectorised); much cheaper per row |
FlatMapGroupsInPandas / MapInPandas / MapInArrow | applyInPandas, mapInPandas, mapInArrow |
A BatchEvalPython above a FileScan with an empty PushedFilters means the UDF blocked pushdown. Apply built-in filters before the UDF, or replace the UDF with built-in functions.
AQE nodes
AdaptiveSparkPlan isFinalPlan=true
+- == Final Plan ==
...
+- AQEShuffleRead coalesced
+- AQEShuffleRead coalesced and skewed
| Node or label | Meaning |
|---|---|
isFinalPlan=true | Plan after runtime re-optimisation |
AQEShuffleRead coalesced | Small shuffle partitions merged (spark.sql.adaptive.coalescePartitions.enabled) |
AQEShuffleRead skewed | Skewed partitions split for a sort-merge join (spark.sql.adaptive.skewJoin.enabled) |
AQEShuffleRead local | Shuffle read locally after a join was converted to broadcast |
ShuffleQueryStage / BroadcastQueryStage | Materialised stages that AQE re-planned around |
On Spark 3.0–3.1 the same node is named CustomShuffleReader. If you expected skew handling and see no skewed label, check spark.sql.adaptive.skewJoin.skewedPartitionFactor and skewedPartitionThresholdInBytes. Skew split also applies only to sort-merge joins (and shuffled hash joins in newer versions), not to aggregates.
Hints that change the plan
SELECT /*+ BROADCAST(c) */ ... -- force BroadcastHashJoin with c as build side
SELECT /*+ MERGE(o, c) */ ... -- force SortMergeJoin
SELECT /*+ SHUFFLE_HASH(c) */ ... -- force ShuffledHashJoin with c as build side
SELECT /*+ SHUFFLE_REPLICATE_NL(c) */ ... -- cartesian-style nested loop
SELECT /*+ REPARTITION(200, customer_id) */ ...
SELECT /*+ COALESCE(16) */ ...
SELECT /*+ REBALANCE(order_date) */ ... -- AQE-aware even output partitions before a write
from pyspark.sql.functions import broadcast
orders.join(broadcast(customers), "customer_id")
orders.join(customers.hint("shuffle_hash"), "customer_id")
Caution
A
BROADCASThint overrides the size threshold. If the hinted side grows (a "small" dimension that becomes 5 GB), the job fails with driver out-of-memory or a broadcast timeout. Prefer fixing statistics over hard-coding hints in production jobs, and review existing hints when data volumes change.
Whole-stage codegen
*(3) Project means the operator runs inside whole-stage codegen block 3. Operators without the *(n) prefix break codegen. Python UDFs, some generators and wide schemas above spark.sql.codegen.maxFields are common causes. One unfused operator is fine. A chain of them on a hot path is worth rewriting.
On Databricks with Photon, supported operators appear with Photon prefixes (for example PhotonShuffleExchangeSink), and a ColumnarToRow transition marks where execution drops back to the JVM engine. Operator names change between runtime versions.
Quick diagnosis
| You see | Likely problem | Fix |
|---|---|---|
CartesianProduct | Missing or non-equi join condition | Fix the ON clause |
SortMergeJoin with a tiny side | Missing statistics | ANALYZE TABLE, or a broadcast hint for a genuinely tiny table |
Empty PartitionFilters | Filter on an expression of the partition column | Filter on the raw column |
Exchange SinglePartition | Window without partitionBy, global sort | Add a partition key, or use sortWithinPartitions |
| Exchange immediately after an Exchange | Redundant repartition | Remove the manual repartition |
BatchEvalPython | Row-wise Python UDF | Built-in functions or a pandas UDF |
No skewed in the final plan, one slow task | Skew thresholds not met, or skew in an aggregate | Tune thresholds; salt the aggregate key |
Prevent it next time
Save explain("formatted") output for critical jobs in the repository and diff it in code review. An unexpected CartesianProduct, a new SinglePartition exchange or a lost PartitionFilters entry is cheaper to catch in a diff than in a 3 a.m. stage that never finishes.