Catalyst, code generation & AQE
Follow a DataFrame from parsed plan to executed plan, read what explain prints, and see what adaptive execution changes once real sizes arrive.
Catalyst rewrites the logical plan with rules, then plans it physically by applying strategies in a fixed order to size estimates — it does not cost-compare physical plans. Adaptive query execution re-plans the rest of a query each time a shuffle stage finishes, using measured sizes instead of estimates.
From DataFrame to executed plan
A DataFrame is a description of work. Its QueryExecution turns that description into something that runs, in a fixed sequence, and explain("extended") prints each stage:
- Parsed logical plan — the tree as written, still unresolved. For the example below it begins
'Aggregate ['category], ['category, sum('gross) AS total#12]; the leading quote marks names not yet resolved. - Analyzed logical plan — names resolved against the schema and catalog, types checked, and every attribute given an ID such as
amount#2. Analysis runs as soon as you build the DataFrame: selecting a misspelled column raisedAnalysisExceptionbefore any action. - Optimized logical plan — batches of rewrite rules. The filter
amount > 10becameisnotnull(amount#2) AND (amount#2 > 10.0): a null check was inferred and the analyzer'scast(10 as double)folded into a literal. - Physical plan — operators that execute:
Scan,Filter,Project, a partial and a finalHashAggregate, and theExchangebetween them. What explain prints here is the prepared plan, after exchanges and code generation stages were added.
Read physical plans bottom up: data flows from the scan toward the root. The other modes answer other questions. "formatted" prints a numbered outline followed by per-operator details — Input, Condition, Keys, Functions, and for file scans PartitionFilters and PushedFilters — which is easier to scan than one long tree. "cost" prints the optimized logical plan with its statistics when they are available, and "codegen" prints generated code when there is any.
Check the sources: EXPLAIN modes · DataFrame.explain · QueryExecution source
Physical planning: rule order, estimates, and where cost enters
SparkPlanner holds a list of strategies — join selection, aggregation, file-source scans, basic operators — and applies them to the optimized plan. The planner interface can yield several candidates, but in Spark 3.5.7 QueryExecution.createSparkPlan takes the first one: its source comment says it uses next() "for now, but we will implement to choose the best plan." No search costs alternative physical plans against each other.
Choices such as the join strategy are rules evaluated in order. For an equi-join, JoinSelection checks hints first. Without an applicable hint it picks a broadcast hash join if one side's estimated size fits spark.sql.autoBroadcastJoinThreshold (10 MB) and the join type allows it; then a shuffled hash join only if one side is small enough for a local hash map, much smaller than the other, and spark.sql.join.preferSortMergeJoin is false; then a sort-merge join for sortable keys; and finally a cartesian product or broadcast nested loop join.
Cost enters through statistics on the logical plan. By default Spark estimates size only: a file relation reports its file size, and a filter keeps its child's estimate. A 1,000-row table filtered to amount > 900 still estimated 5.5 KiB. ANALYZE TABLE … COMPUTE STATISTICS records size and row count for a catalog table, and FOR COLUMNS adds column statistics such as min, max, null count, and distinct count. Those numbers drive estimates only when spark.sql.cbo.enabled is true, and it is false by default. With column statistics and CBO on, the same filter estimated rowCount=100 and 2000 B; with CBO off, the estimate stayed 5.5 KiB. Cost-based join reordering also requires spark.sql.cbo.joinReorder.enabled, false by default.
The accurate summary: rule-based logical optimization and rule-ordered physical planning, fed by estimates that become cost-based only when you collect statistics and turn CBO on. Better estimates change the physical plan by changing what the rules see.
Check the sources: QueryExecution source · QueryPlanner source · JoinSelection · Size-only estimates · ANALYZE TABLE · CostBasedJoinReorder · CBO settings
Preparations: exchanges and whole-stage codegen
The chosen physical plan still needs preparation rules. In 3.5.7 they include PlanSubqueries, RemoveRedundantProjects, EnsureRequirements, RemoveRedundantSorts, ApplyColumnarRulesAndInsertTransitions, CollapseCodegenStages, and exchange reuse. With AQE enabled, InsertAdaptiveSparkPlan runs first; when it wraps the query in AdaptiveSparkPlan, the remaining rules are applied later, stage by stage, as the adaptive plan executes.
EnsureRequirements compares what each operator needs from its children — a hash distribution on the grouping keys, a sort order for a sort-merge join — with what they produce, and inserts a shuffle Exchange or a Sort where they differ. That is the origin of Exchange hashpartitioning(category#1, 200), ENSURE_REQUIREMENTS between the partial and final aggregates.
CollapseCodegenStages fuses each run of adjacent operators that support code generation into one generated Java function. A *(n) prefix marks membership in codegen stage n. The run breaks at an operator without codegen support, and at one whose expressions need the interpreted fallback. Observed boundaries:
Exchangeseparated*(1)(scan, filter, project, partial aggregate) from*(2)(final aggregate).collect_listplanned asObjectHashAggregate, printed without a star.- A Python UDF became
BatchEvalPython, splitting*(1)below from*(2)above — inside one scheduler stage, since no shuffle was involved. A codegen stage ID is not a scheduler stage ID. - A
Projectcomputingtransform(xs, x -> x * 2)lost its star: that higher-order function evaluates through the interpreted fallback, although it never leaves the JVM.
Codegen can also switch itself off: if a generated method's bytecode exceeds the internal spark.sql.codegen.hugeMethodLimit (65535), whole-stage codegen is deactivated for that subtree.
With AQE on, look after execution. Before an action, AdaptiveSparkPlan isFinalPlan=false showed no *(n) markers, and explain("codegen") reported "Found 0 WholeStageCodegen subtrees." After collect(), the final plan showed *(1) and *(2). With AQE disabled, the same query showed both stages up front and found 2 subtrees.
Check the sources: Preparation rules · EnsureRequirements · WholeStageCodegenExec · AdaptiveSparkPlanExec
What blocks predicate pushdown
A file scan in the physical plan lists three kinds of filter. PartitionFilters are evaluated by Spark against partition values from the directory listing, so pruned directories are never opened. PushedFilters are predicates translated into the data source filter API and handed to the reader; for Parquet, that lets the reader skip data using its statistics. DataFilters are the data-column predicates. Spark keeps a Filter above the scan for every non-partition predicate, so pushdown saves reading, not correctness checks.
Translation accepts specific shapes: a column compared with a literal, IN with literals, null checks, string prefix, suffix, and contains tests, and AND/OR/NOT combinations of those. Everything else stays in Spark. On a 3,000-row Parquet dataset partitioned by day (3 directories, 6 files):
| Predicate | What the scan received | Why |
|---|---|---|
amount > 400 | PushedFilters: [IsNotNull(amount), GreaterThan(amount,400)] | A column compared with a literal translates into a source filter. |
| The same test in a Python UDF | PushedFilters: []; Filter above BatchEvalPython | The UDF moves into its own operator; the reader sees no predicate. |
cast(amount as string) = '450' | Only IsNotNull(amount) | A function around the column has no source-filter equivalent. |
amount > 400 AND rand(7) < 0.5 | GreaterThan(amount,400) pushed; rand stays in Spark | Non-deterministic expressions are not pushed; deterministic conjuncts still are. |
user_id < 10 OR amount > 400 | Or(LessThan(user_id,10),GreaterThan(amount,400)) | An OR of translatable predicates translates. |
day = '2026-09-02' (partition column) | PartitionFilters; 2 of 6 files read | Directories that cannot match are never opened. |
cast(day as string) = '2026-09-02' | Still PartitionFilters; 2 of 6 files read | Spark evaluates deterministic partition-only predicates itself. |
Python UDF on day | No partition or pushed filter; 6 of 6 files read | A Python UDF cannot run during partition pruning. |
day = '2026-09-02' OR amount > 400 | Neither; 6 of 6 files read | The OR mixes a directory value with a file column, so it is neither prunable nor pushable. |
Pushdown depends on the shape of an expression, not on how simple it looks. Write predicates as built-in expressions, compare columns to literals rather than wrapping the column, and keep partition predicates separate from data predicates. Then confirm in the plan and in the scan's files-read metric. Storage formats explains what a Parquet reader does with a pushed filter; Lazy evaluation shows why the plan exists before anything is read.
Check the sources: FileSourceStrategy · Filter translation · ExtractPythonUDFs · Parquet pushdown
Adaptive query execution: re-planning between stages
AQE is enabled by default since Spark 3.2.0 (spark.sql.adaptive.enabled). AdaptiveSparkPlanExec splits the plan into query stages at exchanges, runs the stages whose inputs are ready, and after each stage materializes it re-optimizes and re-plans the rest of the query using the measured output. It wraps queries that contain an exchange or a subquery, or whose operators require a distribution; a plain filtered scan is not wrapped.
Before an action, explain prints AdaptiveSparkPlan isFinalPlan=false. After an action, explain on the same DataFrame prints isFinalPlan=true with a == Final Plan == and the == Initial Plan ==. The final plan names completed stages (ShuffleQueryStage 0, BroadcastQueryStage 2) and how their output is read: AQEShuffleRead coalesced, local, or skewed. The SQL tab in the Spark UI shows the same final plan.
- Coalescing post-shuffle partitions. A
groupByover 10 keys with the default 200 shuffle partitions ran its reduce stage as 1 task behindAQEShuffleRead coalesced; with coalescing disabled, the same stage ran 200 tasks. Under the defaultspark.sql.adaptive.coalescePartitions.parallelismFirst=true, Spark ignores the 64 MB advisory size and respects only the 1 MB minimum partition size; the setting's own description recommends false. - Switching a join to broadcast. A Parquet side filtered to 50 rows kept its 13.4 MiB file-size estimate, so under a 1 MiB broadcast threshold the initial plan was
SortMergeJoin. After both shuffle map stages ran, the final plan wasBroadcastHashJoin, reading each shuffle throughAQEShuffleRead local. The shuffle writes had already happened; the switch saved the sorts and reduce-side fetches. (The optimizer had also inferredfk < 50for the other side from the join key, so that side was built.) Converting to a shuffled hash join instead requiresspark.sql.adaptive.maxShuffledHashJoinLocalMapThresholdabove its default of 0. - Splitting skewed partitions. A join partition counts as skewed when it exceeds both 5 × the median partition size and 256 MB (defaults). With both thresholds lowered for a laptop-sized dataset, the plan showed
SortMergeJoin(skew=true)andAQEShuffleRead skewed, and the join stage ran 9 tasks instead of 8. The documentation describes sort-merge joins; the 3.5.7 rule also matches shuffled hash joins.
Check the sources: AQE guide · AdaptiveSparkPlanExec · InsertAdaptiveSparkPlan · OptimizeSkewedJoin · AQE settings
What AQE cannot fix
AQE changes decisions that come after a completed stage, using that stage's output sizes. That leaves a lot out of reach:
- Work already done. A runtime broadcast arrives after the shuffle map stages were written. If you know a side is small, let the static plan broadcast it: collect statistics or add a hint you have checked.
- The first read. Scan parallelism comes from file splitting (
spark.sql.files.maxPartitionBytes), not from AQE. Thousands of small files, an unpushable predicate, or unsorted data that defeats row-group statistics are untouched. - Queries without an exchange. They are not wrapped in an adaptive plan at all.
- Hot keys outside joins. Skew splitting covers joins and rebalance partitions. One huge key in a
groupByor a window still lands in one task, and coalescing only merges partitions — it never splits one. - Opaque code. A Python UDF still blocks pushdown of predicates on its result and splits codegen.
- Memory inside one task or the driver. A partition too large for its task, or a large
collect(), is not a planning decision.
Treat the final plan as evidence of what AQE decided, then fix the remaining cause where it lives: file layout, the query's expressions, or configuration. Joins & data skew covers manual fixes when AQE's split is not enough.
Check the sources: AQE guide · When AQE applies · OptimizeSkewedJoin
Try it on a small dataset
Run this in a PySpark notebook with an existing spark session. Create a local session if needed. The example was run for review; this website does not execute Spark.
from pyspark.sql import functions as F
orders = spark.createDataFrame(
[(1, "books", 12.0), (2, "games", 30.0), (3, "books", 8.0), (4, "music", 15.0)],
"order_id INT, category STRING, amount DOUBLE",
)
totals = (
orders.filter(F.col("amount") > 10)
.select("category", (F.col("amount") * 1.1).alias("gross"))
.groupBy("category")
.agg(F.sum("gross").alias("total"))
)
totals.explain("extended") # parsed, analyzed, optimized, physical
totals.collect() # run it: AQE plans each stage with real sizes
totals.explain() # same DataFrame: now the final and initial plansThe extended explain printed four plans. The optimized plan's filter read isnotnull(amount#2) AND (amount#2 > 10.0). The physical plan was AdaptiveSparkPlan isFinalPlan=false with Exchange hashpartitioning(category#1, 200) between a partial and a final HashAggregate, and no *(n) codegen markers. After collect(), explain() on the same DataFrame printed isFinalPlan=true: a Final Plan with *(1) around the scan, filter, project, and partial aggregate, then ShuffleQueryStage 0, AQEShuffleRead coalesced, and *(2) around the final aggregate, followed by the Initial Plan. Attribute and plan IDs vary between runs; whether and how far partitions coalesce depends on data size and the spark.sql.adaptive settings.
Ran on local PySpark 3.5.7 (local[2], JDK 17) with default SQL settings on 2026-09-15; output above is observed.
Reading the pre-execution explain as the plan that ran. With AQE on, the initial plan can show a sort-merge join that became a broadcast join, 200 shuffle partitions that became one task, and no codegen markers at all. Run the action, then read the final plan — from explain on the same DataFrame or in the SQL tab — and remember that a plan produced from a small local sample can differ from the one production sizes produce.
An explain before an action shows a sort-merge join and 200 shuffle partitions, but the SQL tab shows a broadcast hash join and far fewer tasks. Which plan is wrong?
Say your answer out loud before opening the notes.
Reveal the reasoning
Neither. With AQE enabled, explain before an action prints the initial adaptive plan, built from static estimates: a filtered file relation keeps its file-size estimate, so the join side exceeded the broadcast threshold, and the shuffle used the configured partition count. At run time each shuffle map stage reported its real output size. AQE re-planned the rest of the query, switched to a broadcast hash join once a side proved small, and coalesced the small post-shuffle partitions. The SQL tab, and explain on the same DataFrame after the action, show the final plan with the initial plan kept for comparison. The shuffle writes before the switch still happened.
Sources & verification
Core claims checked against Apache Spark 3.5.7 documentation and source references on 2026-09-15. Example output observed in a local run: Ran on local PySpark 3.5.7 (local[2], JDK 17) with default SQL settings on 2026-09-15; output above is observed. Explanations, scenarios, and diagrams are original teaching material. Other releases and managed runtimes can differ.
- Spark 3.5.7 SQL reference: EXPLAIN and its modes ↗
- PySpark 3.5.7: DataFrame.explain modes ↗
- QueryExecution source (v3.5.7): planning phases, createSparkPlan, and preparations ↗
- QueryPlanner source (v3.5.7): strategies and candidate plans ↗
- SparkStrategies source (v3.5.7): JoinSelection rule order ↗
- SizeInBytesOnlyStatsPlanVisitor source (v3.5.7): default size estimates ↗
- Spark 3.5.7 SQL reference: ANALYZE TABLE ↗
- CostBasedJoinReorder source (v3.5.7): CBO and join-reorder switches ↗
- Spark 3.5.7 configuration: runtime SQL settings (CBO, AQE) ↗
- EnsureRequirements source (v3.5.7): inserting exchanges and sorts ↗
- WholeStageCodegenExec source (v3.5.7): CollapseCodegenStages and fallbacks ↗
- FileSourceStrategy source (v3.5.7): partition, data, and post-scan filters ↗
- DataSourceStrategy source (v3.5.7): translating predicates into source filters ↗
- ExtractPythonUDFs source (v3.5.7) ↗
- Spark 3.5.7: Parquet files and filter pushdown ↗
- Spark 3.5.7 SQL performance tuning: join hints and adaptive query execution ↗
- AdaptiveSparkPlanExec source (v3.5.7): query stages and re-planning ↗
- InsertAdaptiveSparkPlan source (v3.5.7): when AQE applies ↗
- OptimizeSkewedJoin source (v3.5.7) ↗