BeginnerSpark · 13 min · 12 challenges

Catalyst Optimizer: Beginner

Say 2 Netflix engineers each build the nightly job that totals yesterday's viewing minutes per title. One chains Python lambdas over an RDD. The other writes the same logic with DataFrame columns. They use the same cluster and the same input, and the DataFrame job finishes first, because Spark rewrote it before any task started. This lesson shows what that rewrite does and how to watch it happen.

DataFrames over RDDs

Daily Life
Interviews
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.

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.

rdd_result = (
orders_rdd
.filter(lambda row: row[1] == "Completed")
.map(lambda row: (row[2], row[3]))
.reduceByKey(lambda a, b: a + b)
)
df_result = (
orders
.filter(F.col("status") == "Completed")
.groupBy("region")
.agg(F.sum("profit").alias("total_profit"))
)

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 runningRDD chainDataFrame chain
Column namesNone, only tuple positionsorder_id, status, region, profit
Column typesUnknown until an element is processedFixed by the schema
What the filter testsNothing, it is a function to callstatus = 'Completed'
Columns later steps readUnknown, so every field is keptstatus, region, profit
What executes the logicPython code on each executor, element by elementSpark'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.

✓RDD: Spark follows your steps
  • 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
✗DataFrame: Spark plans your steps
  • 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
Multiple Choice

Spark receives this step in an RDD chain. Before any task runs, what can Spark determine about it?

big_orders = orders_rdd.filter(lambda row: row[3] > 1000)

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.

TIP
Make the DataFrame API the default for every new Spark job. Use RDD operations only for a step that has no column-expression form. The RDD Escape Hatch section below covers those cases.

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()

Your Query Gets Rewritten

Daily Life
Interviews
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.
RewriteYou wroteCatalyst runs
Filter pushdown`withColumn`, then `orderBy`, then a `filter` on statusThe status `filter` first, then `withColumn` and `orderBy`
Column pruningRead all 6 product columns, group by category, average the priceA read of `category` and `price` only
Constant foldingA 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

The rewrite with the biggest payoff moves a filter as early as the plan allows. Take a report of Completed orders sorted by profit, written in the order its author thought of the steps:
ranked = (
orders
.withColumn("profit_k", F.col("profit") / 1000)
.orderBy(F.col("profit_k").desc())
.filter(F.col("status") == "Completed")
)

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.

75%
of orders never enter the shuffle
The animation runs that chain on 8 orders read as 2 partitions. Watch where the Cancelled and Pending orders disappear. The read tasks drop them, so only the 4 Completed orders cross the shuffle to be sorted, and order 3, with a profit of 4500, comes out on top.
(
orders
.withColumn("profit_k", F.col("profit") / 1000)
.orderBy(F.col("profit_k").desc())
.filter(F.col("status") == "Completed")
.collect()
)
orders8 rows
order_id
status
region
profit
1
Completed
West
1200
2
Cancelled
East
800
3
Completed
East
4500
4
Pending
West
300
5
Completed
North
2600
6
Cancelled
North
950
7
Completed
West
3100
8
Pending
East
700
Preparing the animation
//

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.

Multiple Choice

Which filter can Catalyst run before the shuffle that this groupBy needs?

totals = (
orders
.groupBy("region")
.agg(F.sum("profit").alias("total_profit"))
.filter(F.col("region") != "Test")
.filter(F.col("total_profit") > 5000)
)

Columns nobody reads are never carried

The second rewrite works on columns. Catalyst walks the plan from the result back to the source and records which columns each step needs. A column that no step needs is cut at the read, so it is never carried between steps and never shuffled. When the files store each column separately, Spark also skips reading that column from disk. Wide tables are where this pays: a query that touches 3 columns of a 120-column events table moves 3 columns' worth of data through its shuffles.

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()
products8 rows
product_id
product_name
category
price
supplier
weight_kg
1
Chef knife
Kitchen
45
Ardent
0.3
2
Hose
Garden
30
Verdi
2.1
3
Skillet
Kitchen
60
Ardent
1.8
4
Kite
Toys
25
Lumo
0.4
5
Rake
Garden
20
Verdi
1.2
6
Kettle
Kitchen
35
Ardent
1.1
7
Puzzle
Toys
15
Lumo
0.6
8
Shears
Garden
28
Verdi
0.5
Preparing the animation

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()

The RDD Escape Hatch

Daily Life
Interviews

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.

orders = spark.read.parquet("/data/lake/orders")
totals = (
orders.rdd
.filter(lambda r: r.status == "Completed")
.map(lambda r: (r.region, r.profit))
.reduceByKey(lambda a, b: a + b)
.toDF(["region", "total_profit"])
)

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.
Multiple Choice

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:

completed = (
orders
.filter(F.col("status") == "Completed")
.select("region", "profit")
)
totals = (
completed.rdd
.map(lambda r: (r.region, r.profit))
.reduceByKey(lambda a, b: a + b)
.toDF(["region", "total_profit"])
)
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

RDD habitDataFrame 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:

Checking layout: df.rdd.getNumPartitions() returns a fresh DataFrame's partition count without reading rows.
Checking layout: df.rdd.getNumPartitions() returns a fresh DataFrame's partition count without reading rows.
Setup per partition: mapPartitions opens 1 connection or loads 1 model per partition, not once per row.
Setup per partition: mapPartitions opens 1 connection or loads 1 model per partition, not once per row.
Libraries that only accept whole Python objects, called on rows a DataFrame step has already filtered and trimmed.
Libraries that only accept whole Python objects, called on rows a DataFrame step has already filtered and trimmed.
Legacy code mid-migration, kept as an RDD step until each lambda is rewritten as a column expression.
Legacy code mid-migration, kept as an RDD step until each lambda is rewritten as a column expression.
TIP
Before writing a lambda, search the functions module you import as 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()

DataFrame, Dataset, and RDD

Daily Life
Interviews
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.
QuestionRDDDataFrameDataset
Unit of dataAny objectA Row with named, typed columnsAn object of a class you define
Optimized by CatalystNoYes, every stepColumn steps yes, lambda steps no
A wrong column or field name failsAt the action, inside a taskWhen the line runs, before any jobTyped field access at compile time, column names when the line runs
Available from PythonYesYesNo

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.

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 written as a lambda over your class is opaque. Spark must rebuild each row as an object of your class before it can call the function, then convert the result back, and Catalyst cannot move a filter past that step or prune columns through it. Typing buys earlier error checks, and the lambdas still cost what lambdas cost.

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.

completed_rdd = orders.rdd.filter(lambda r: r.statuss == "Completed")
n = completed_rdd.count()
completed_df = orders.filter(F.col("statuss") == "Completed")

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.

Multiple Choice

A nightly job fails 35 minutes in with AttributeError: statuss, raised inside a task. Which API did the failing step use?

Choosing between them

✓Do
  • 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
✗Don't
  • 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
One rule covers most cases. Start every job with DataFrames. Drop to an RDD for one step only when that step has no column-expression form, and do it after the filters and selects. Datasets make sense in a typed JVM codebase, where compile-time field checks are worth having, and even there the optimizer helps only their column-expression steps.
Multiple Choice

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?

Seeing It with explain()

Daily Life
Interviews

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.

from pyspark.sql import functions as F
orders = spark.read.parquet("/data/lake/orders")
west = (
orders
.groupBy("region")
.agg(F.sum("profit").alias("total_profit"))
.filter(F.col("region") == "West")
)
west.explain()

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.

ReadPlan lineWhat 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.
Multiple Choice

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.

Parsed: as you wrote it
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
Optimized: what Catalyst kept
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.

TIP
After a job has run, the Spark UI's SQL tab draws this same plan as a graph with row counts on each operator. If a filter you expected near the scan sits above an Exchange there, start your fix with that filter.
Multiple Choice

How many shuffles does the physical plan for this query contain?

report = (
orders
.filter(F.col("status") == "Completed")
.withColumn("profit_k", F.col("profit") / 1000)
.groupBy("region")
.agg(F.sum("profit_k").alias("total_k"))
.orderBy(F.col("total_k").desc())
)
report.explain()
❯❯❯PUTTING IT ALL TOGETHER

> 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.

DataFrames over RDDs: the lambda chain hands Spark functions it can't inspect, so no part of the job reaches Catalyst.
The RDD wall: because .rdd comes first, all 90 columns of every trip become Python objects before the filter drops a single cancelled trip.
Rewriting: with 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.
The 3 APIs: a misspelled fare column now raises AnalysisException when the line executes, before any job starts, where it once failed 30 minutes in.
explain(): the new plan should show a 3-column scan, a Filter below the first Exchange, and 2 Exchange lines, one for the city grouping and one for the sort.
KEY TAKEAWAYS
A DataFrame step is an expression Spark can read. An RDD step is a function Spark can only call, so only DataFrame code reaches Catalyst.
Catalyst rewrites your chain into a plan with the same rows and less work. Filters move toward the read and unused columns are pruned; Catalyst also folds constants and combines filters.
A filter can run earlier only when it can be written in columns that exist at that earlier point. A filter on an aggregate's result stays after the shuffle.
Calling .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().
Python has DataFrames and RDDs. The typed Dataset belongs to the compiled JVM languages, and its lambda steps are as opaque to Catalyst as RDD lambdas.
Read 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

  1. 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

  2. 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

  3. 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

  4. 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

  5. Seeing It with explain() (concepts: Catalyst Optimizer)

    3 questions for every plan Before and after the rewrite Spotting the RDD wall