Catalyst Optimizer: Beginner
Here is a single question, total profit of completed orders per region, in both styles. orders_rdd holds tuples of (order_id, status, region, profit), and orders is a DataFrame with those same 4 columns.
Lambdas hide their logic, expressions show it
Run either chain and you get the same totals. The difference is what Spark knows before running them. To Spark, lambda row: row[1] == "Completed" is a compiled Python function. Spark can call it on an element and get a value back, and that is all it can do. It can't tell which field the function reads, that the test is an equality, or that position 1 holds a status string. The DataFrame filter hands Spark the expression itself: = applied to the column status and the literal Completed.
| Known before running | RDD chain | DataFrame chain |
|---|---|---|
| Column names | None, only tuple positions | order_id, status, region, profit |
| Column types | Unknown until an element is processed | Fixed by the schema |
| What the filter tests | Nothing, it is a function to call | status = 'Completed' |
| Columns later steps read | Unknown, so every field is kept | status, region, profit |
| What executes the logic | Python code on each executor, element by element | Spark's engine, on its compact row format |
If Spark can read a step, it can change that step. The DataFrame chain is built from expressions over named columns, so Spark passes the whole plan through its optimizer, called Catalyst, before scheduling the first task. Catalyst notices that order_id is never used and leaves it unread, and it chooses how the grouping is carried out. The RDD chain gets none of this. Spark runs each lambda exactly where you placed it, on every field of every element, in the written order.
- Each step is a function Spark can call but cannot inspect
- Steps run in the order written, with nothing reordered
- Every field of every element travels through every step
- Elements are Python objects handled by Python code
- Each step is an expression over named, typed columns
- Catalyst may reorder or merge steps when the rows stay the same
- Columns no step reads are cut from the plan
- Rows stay in Spark's binary format inside its engine
Spark receives this step in an RDD chain. Before any task runs, what can Spark determine about it?
Laziness gives the optimizer its chance
You already know Spark waits for an action before doing any work. That wait is what gives Catalyst room to act. When you call collect() or count(), Spark holds the full chain from the read to the result, so it can optimize all of it at once. With DataFrames, a messy chain of 10 steps and a tidy chain of 4 can come out as the same plan. With RDDs, 10 steps run as 10 steps.
Run the DataFrame version on the practice orders table. The extra filter drops orders that have no region, which would otherwise form a group of their own. Then change "Completed" to "completed". The query still runs, but every group disappears, because string comparison is case-sensitive.
from pyspark.sql import functions as F
orders = spark.table("orders")
df_result = (
orders
.filter(F.col("status") == "Completed")
.filter(F.col("region").isNotNull())
.groupBy("region")
.agg(
F.count("*").alias("completed_orders"),
F.round(F.sum("profit"), 2).alias("total_profit"),
)
.orderBy("region")
)
df_result.show()| Rewrite | You wrote | Catalyst runs |
|---|---|---|
| Filter pushdown | `withColumn`, then `orderBy`, then a `filter` on status | The status `filter` first, then `withColumn` and `orderBy` |
| Column pruning | Read all 6 product columns, group by category, average the price | A read of `category` and `price` only |
| Constant folding | A filter on `F.date_sub(F.lit("2024-03-15"), 7)` | The date `2024-03-08`, computed once during planning |
| Filter combining | `.filter(a)` followed by `.filter(b)` | A single filter testing `a AND b` |
Filters move toward the read
Read literally, this chain sorts every order and then throws most of them away. A global sort needs a shuffle: every row crosses the network so that each task owns a slice of the sorted range. Catalyst sees that the filter reads only status. That column exists before withColumn and the sort leaves it untouched, so testing it first can't change which rows come out. Catalyst moves the filter below the sort and below withColumn, right after the read. Say orders holds 8,000,000 rows and 2,000,000 of them are Completed. As written, the sort would shuffle all 8,000,000. After the rewrite it shuffles 2,000,000.
(orders.withColumn("profit_k", F.col("profit") / 1000).orderBy(F.col("profit_k").desc()).filter(F.col("status") == "Completed").collect())
When a filter cannot move
A filter moves below a step only when that step can't affect its answer. Put a filter on region after groupBy("region") and it moves below the grouping, because removing a region's rows before grouping removes exactly that region's group. A filter on total_profit > 5000 after the same grouping stays where it is. total_profit does not exist until the grouping has run, and a rule cannot test a value nobody has computed yet.
You can apply the rule yourself. A filter runs earlier only if it can be written in columns that exist at that earlier point. A derived column qualifies when its formula reads such columns, so Catalyst rewrites profit_k > 2 as profit / 1000 > 2 and moves it too. An aggregate's result never qualifies, because computing it takes every row of the group.
Which filter can Catalyst run before the shuffle that this groupBy needs?
Columns nobody reads are never carried
In the next animation the query averages price by category over a 6-column products table. Catalyst dropped product_id and product_name before the first task ran, along with supplier and weight_kg, so none of those 4 columns crosses the shuffle. Kitchen comes out at (45 + 60 + 35) / 3, about 46.67.
products.groupBy("category").agg(F.avg("price").alias("avg_price")).collect()
The guarantee and its limit
Every rewrite returns the rows your written chain would have returned. That's why Catalyst applies them without asking you. Its limit is that it can only rewrite what it can read. It cannot remove a shuffle your logic requires: a groupBy still groups and an orderBy still sorts. It also can't see inside a Python function. Write the query in whatever order reads clearest, and spend your effort on the steps Catalyst cannot change.
Run this version, where every filter comes last. Then move the status and region filters up to sit right after spark.table("orders") and run again: the rows match. Now try moving the profit_k filter up as well. Spark rejects it at once, because profit_k does not exist at that point in your code. Catalyst can make that move by rewriting the filter in terms of profit, but your code cannot.
from pyspark.sql import functions as F
orders = spark.table("orders")
late_filters = (
orders
.withColumn("profit_k", F.round(F.col("profit") / 1000, 3))
.select("order_id", "status", "region", "profit_k")
.orderBy("order_id")
.filter(F.col("status") == "Completed")
.filter(F.col("region").isNotNull())
.filter(F.col("profit_k").isNotNull())
)
late_filters.show()Every DataFrame has an .rdd attribute. It returns the same data as an RDD of Row objects, and from there map accepts any Python function, as do filter and reduceByKey. Engineers reach for it when a step reads more naturally as a lambda, or out of habit carried over from older code.
This produces the totals you saw in the DataFrames over RDDs section, at a higher price. The expression orders.rdd is a wall in the plan. Catalyst optimizes what lies before the wall, here the bare read, and then hands every row across it. Everything past the wall stays out of Catalyst's view until toDF builds a fresh DataFrame from the result.
The cost of the .rdd wall
- Every column is readNo pruningThe lambdas never read the fourth column, but Spark cannot see that, so it produces all 4 columns of every row. On a 120-column table, all 120 come along.
- Every row is convertedEngine format to Python objectEach row leaves Spark's compact internal format and becomes a Python Row object before a lambda can run on it.
- The filter stays putNo reorderingThe status test runs inside Python once that conversion is done. Catalyst can neither move it earlier nor combine it with another filter.
- The plan goes darkHidden from explain()After toDF, the new DataFrame starts from an existing RDD. Its plan shows none of the steps that built it, including the shuffle inside reduceByKey.
A job reads a 120-column table, calls .rdd, and its lambdas read 2 fields. How many columns does Spark produce for each row it hands to the lambdas?
Where you place the wall decides how much you lose. DataFrame operations written before .rdd are still optimized. Filter and select first, and less crosses the wall:
Translating lambdas back to columns
| RDD habit | DataFrame equivalent |
|---|---|
| `.map(lambda r: (r.region, r.profit))` | `.select("region", "profit")` |
| `.filter(lambda r: r.profit > 0)` | `.filter(F.col("profit") > 0)` |
| `.map(lambda r: r.region.upper())` | `.select(F.upper("region"))` |
| `.reduceByKey(lambda a, b: a + b)` on region pairs | `.groupBy("region").agg(F.sum("profit"))` |
| `.sortBy(lambda r: r.profit, ascending=False)` | `.orderBy(F.col("profit").desc())` |
| `.map(lambda r: 1).sum()` | `.count()` |
Jobs that need the RDD API
Some jobs have no column-expression form, and those are what .rdd exists for:
F. It holds hundreds of built-ins. String and date functions are there, and so are math and array functions and conditionals, and Catalyst can see every one of them.This cell does the job of an old RDD program that counted products and found the top price per category with map and reduceByKey. Run it, then compare: F.max skips products whose price is NULL, while Python's max given a None would raise a TypeError and fail the task.
from pyspark.sql import functions as F
products = spark.table("products")
per_category = (
products
.filter(F.col("category").isNotNull())
.groupBy("category")
.agg(
F.count("*").alias("n_products"),
F.max("price").alias("max_price"),
)
.orderBy("category")
)
per_category.show()| Question | RDD | DataFrame | Dataset |
|---|---|---|---|
| Unit of data | Any object | A Row with named, typed columns | An object of a class you define |
| Optimized by Catalyst | No | Yes, every step | Column steps yes, lambda steps no |
| A wrong column or field name fails | At the action, inside a task | When the line runs, before any job | Typed field access at compile time, column names when the line runs |
| Available from Python | Yes | Yes | No |
A DataFrame is a Dataset of rows
Dataset is the typed API in Spark's compiled JVM languages. You declare a class, say Order with 4 fields, and get a distributed collection of Order objects. The compiler then rejects code that reads a field the class lacks. A DataFrame is the same structure with the generic Row type standing in for your class, which is why Spark's documentation describes a DataFrame as a Dataset of Row. Python has no compile step that could check field names, so it gets DataFrames only. Every DataFrame you have written in these lessons is this untyped form: rows whose column names Spark checks when each line runs, with no compiler involved.
A typo caught at once or 40 minutes in
Suppose a teammate types statuss for status. Here is where each of the 2 Python APIs stops them.
The RDD line is accepted without complaint, because Spark does not inspect a lambda when you define it. The mistake surfaces at count(), when a task first calls the lambda on a row and Python raises an AttributeError. If that filter sits after a 40-minute stretch of work in the same job, the job runs for 40 minutes before failing. The DataFrame line fails the moment it executes, with an AnalysisException that names statuss and points to the columns that do exist, and no job ever starts.
This holds for a standard Spark session, where your program runs inside the driver. A session that talks to a remote Spark server through a thin client can defer the check until the first action or the first schema lookup.
A nightly job fails 35 minutes in with AttributeError: statuss, raised inside a task. Which API did the failing step use?
Choosing between them
- Write new jobs with DataFrames so every step reaches Catalyst
- Filter and select before any .rdd call to shrink what crosses
- Use .rdd for partition counts and setup done once per partition
- Check printSchema() before typing column names into a long job
- Copy old RDD code line by line into a new job
- Convert to .rdd to express a filter or a sum
- Expect a plan to show work done before an RDD conversion
- Assume a typed lambda is optimized because its data is typed
You're writing a Python job and want Spark to check column names before reading any data, and to optimize every step. Which API meets both needs?
Everything so far happens inside Spark, out of sight. explain() prints the plan Catalyst produced for a DataFrame. It starts no job and reads no rows, so you can call it on a query over a huge table before you commit any cluster time.
The filter is written last, after the grouping. The printed plan is a tree, drawn with the result at the top and the read at the bottom. Data flows upward, so start at the bottom line. The numbers after # are internal ids that change from run to run, and long lines are trimmed here to their first part.
| Read | Plan line | What it tells you |
|---|---|---|
| 6th | `AdaptiveSparkPlan isFinalPlan=false` | A wrapper from a feature a later lesson covers. Start reading on the line below it. |
| 5th | `HashAggregate(keys=[region#2], functions=[sum(profit#3)])` | The final total per region, computed after rows are grouped. |
| 4th | `Exchange hashpartitioning(region#2, 200)` | The shuffle. Rows with the same region go to the same task, across 200 partitions, the default. |
| 3rd | `HashAggregate(keys=[region#2], functions=[partial_sum(profit#3)])` | A subtotal per partition before the shuffle. A later lesson explains this 2-step aggregation. |
| 2nd | `Filter (isnotnull(region#2) AND (region#2 = West))` | Your last line, now running right after the read and before the shuffle, with a NULL check Catalyst added. |
| 1st | `FileScan parquet [region#2,profit#3] ... ReadSchema: struct<region:string,profit:double>` | The read. Only 2 of the 4 columns load: `order_id` and `status` were pruned. |
3 questions for every plan
- Which columns does the scan read? The bracketed list on the scan line and its ReadSchema should hold only what your query uses. A long list means something above needs those columns, often the final result itself.
- Where does each filter sit? A filter next to the scan is cheap. One that stays above an Exchange reads a column that only exists after it, such as an aggregate's output.
- How many Exchange lines are there? Each is a shuffle. A groupBy adds a hash shuffle, an orderBy adds a range shuffle, and narrow steps add none.
You wrote a status filter at the end of a chain that groups by region. Which sign in the physical plan confirms Catalyst moved it ahead of the shuffle?
Before and after the rewrite
explain(mode="extended") prints 4 plans. The parsed plan comes first and is your chain as written. After the analyzed plan comes the optimized plan, which is what Catalyst turned it into, and the physical plan prints last. Comparing the parsed and optimized plans shows each rewrite in action.
| Operator, top to bottom |
|---|
| 'Filter ('region = West) |
| Aggregate [region#2], [region#2, sum(profit#3) AS total_profit#10] |
| Relation [order_id#0,status#1,region#2,profit#3] parquet |
| Operator, top to bottom |
|---|
| Aggregate [region#2], [region#2, sum(profit#3) AS total_profit#10] |
| Project [region#2, profit#3] |
| Filter (isnotnull(region#2) AND (region#2 = West)) |
| Relation [order_id#0,status#1,region#2,profit#3] parquet |
On the left, the filter sits above the aggregate. On the right it has dropped below it and gained a NULL check, and a Project now keeps only region and profit. The relation still lists 4 columns at this level, and the physical planner turns that Project into the 2-column scan from the table above.
Spotting the RDD wall
Call explain() on the totals DataFrame from the escape-hatch section, and you get a short plan whose bottom line reads Scan ExistingRDD[region#20,total_profit#21]. The 3 lambdas and the shuffle inside reduceByKey are all missing, because Catalyst received the RDD as a finished source. A plan that starts at Scan ExistingRDD tells you 2 things: the steps between .rdd and toDF were not optimized and don't appear here, and counting its Exchange lines undercounts the job's shuffles. Any DataFrame work before .rdd was optimized on its own and isn't shown in this plan either.
How many shuffles does the physical plan for this query contain?
> A ride-hailing company's finance team needs weekly revenue per city from a 90-column trips table. The job they inherited calls .rdd right after the read, parses the fare field with a lambda, filters to completed trips, sums fares per city with reduceByKey, and sorts cities by revenue. It is slow, and a renamed column once broke it 30 minutes into a run.
.rdd comes first, all 90 columns of every trip become Python objects before the filter drops a single cancelled trip.F.col expressions in place of the lambdas, Catalyst prunes the read to the 3 columns used and moves the completed-trips filter next to the scan..rdd builds a wall in the plan: every column is produced, every row becomes a Python object, and nothing past it is optimized or shown by explain().explain() from the bottom up: check the scan's columns, find where each filter sits, and count Exchange lines to count shuffles.Spark rewrites every DataFrame query before it runs. See what it changes, what blocks it, and how to read the result.
- Category
- Spark
- Difficulty
- beginner
- Duration
- 13 minutes
- Challenges
- 12 hands-on challenges
Topics covered: DataFrames over RDDs, Your Query Gets Rewritten, The RDD Escape Hatch, DataFrame, Dataset, and RDD, Seeing It with explain()
Lesson Sections
- DataFrames over RDDs
Every transformation you write adds a step to a plan, and nothing runs until an action. What Spark can do with that plan before the action depends on what each step is made of. A step is either an RDD operation, which takes a Python function, or a DataFrame operation, which takes column expressions. Written both ways, one business question can look almost identical on the page. Lambdas hide their logic, expressions show it Laziness gives the optimizer its chance
- Your Query Gets Rewritten (concepts: Catalyst Optimizer)
Once an action needs a DataFrame's result, Catalyst takes the chain you built and produces a new plan that returns exactly the same rows with less work. You describe the result, and Catalyst picks the order of operations. It works through rules, each a rewrite that is safe to apply wherever its pattern appears in the plan. Nearly every job you write will hit 4 of them. 2 cut data early by moving filters toward the read and pruning unused columns; the other 2 fold constants and combine filters. F
- The RDD Escape Hatch
The cost of the .rdd wall Now only Completed rows cross the wall, carrying 2 columns each, so far fewer Python objects get built. The better fix keeps the job on the near side of the wall entirely, since each of these lambdas has a DataFrame equivalent. Translating lambdas back to columns Jobs that need the RDD API
- DataFrame, Dataset, and RDD
Spark exposes 3 APIs over a single engine. They differ in what a unit of data is, how much of your code the optimizer can see, and when a mistake in your code gets caught. That last difference matters more than it sounds, because it decides whether a typo costs you a second at your desk or 40 minutes of cluster time. A DataFrame is a Dataset of rows The typed API has a catch that mirrors the escape hatch. A Dataset step written with column expressions is optimized like any DataFrame step. A step
- Seeing It with explain() (concepts: Catalyst Optimizer)
3 questions for every plan Before and after the rewrite Spotting the RDD wall