datarekha

Catalyst — the optimizer making your code fast

Catalyst rewrites your DataFrame pipeline before it runs. Predicate pushdown, column pruning, constant folding — the work senior engineers used to do by hand, now automated.

8 min read Advanced PySpark Lesson 14 of 22

What you'll learn

  • The four-stage Catalyst pipeline: parse → analyze → optimize → physical plan
  • The most impactful optimizations: predicate pushdown, column pruning, constant folding
  • How to see Catalyst's decisions with `.explain()`

Before you start

The whole DataFrames chapter kept invoking one name as the invisible hand behind every speedup — predicate pushdown, column pruning, the reason native functions beat UDFs, the reason your obvious code runs fast. We said “Catalyst” a dozen times and never once opened it. The chapter was, in effect, a long lesson in writing code that keeps this optimizer happy, given without ever showing you the optimizer. This chapter shows it.

When you write a DataFrame pipeline, Spark doesn’t run it as written. A component called Catalyst takes your query, rewrites it for performance, then runs the rewritten version. The transformations are often dramatic — sometimes Catalyst eliminates 90% of the work before the cluster even starts.

Catalyst is the reason naive PySpark code is usually fast. It’s also the reason understanding the explain plan is the highest-leverage debugging skill in Spark.

The four stages

A query travels through Catalyst in four steps:

your codeDataFrame API1. Parseunresolved plan2. Analyzeresolve refs3. Optimizepushdown, prune4. Physicaljoin strategyexecuteon cluster
The Catalyst pipeline — each stage refines the plan before execution.

Stages 1–3 produce logical plans (what to compute, not how). Stage 4 produces a physical plan (which join algorithm, which file reader, which execution engine). You can see all of them with .explain(extended=True).

A worked example

Consider this query:

big_df = spark.read.parquet("s3://bucket/orders/")    # 100 columns, 1B rows

result = (big_df
    .filter(F.col("status") == "PAID")
    .filter(F.col("amount") > 100)
    .withColumn("net", F.col("amount") - F.col("discount"))
    .select("order_id", "user_id", "net")
    .where(F.col("net") > 50))

A naive engine would read all 100 columns and 1B rows, then apply each operation in sequence. Catalyst transforms the plan before execution. Here’s what it does:

Optimization 1: Combine filters

# Before
.filter(F.col("status") == "PAID")
.filter(F.col("amount") > 100)
# After
.filter((F.col("status") == "PAID") & (F.col("amount") > 100))

Same result, one pass through the data instead of two.

Optimization 2: Predicate pushdown

Catalyst pushes filters as close to the data source as possible. For Parquet, that means into the file scan itself:

Scan parquet(s3://bucket/orders/) 
  PushedFilters: [
    EqualTo(status, PAID),
    GreaterThan(amount, 100),
  ]

Parquet stores per-row-group min/max statistics. If a row group has status values only FAILED and REFUNDED, Spark skips the entire row group without reading the data. On a typical filtered query you might read 5% of the file instead of 100%.

Optimization 3: Column pruning

Spark sees that the final .select() keeps only order_id, user_id, and net. net is computed from amount and discount. So Catalyst reads only 5 columns out of the 100 stored in Parquet.

ReadSchema: struct<order_id:bigint, user_id:bigint, amount:double, discount:double, status:string>

Notice status is also read (the filter needs it) but the other ~95 columns are skipped. Column pruning often saves 80-95% of I/O.

Optimization 4: Constant folding

df.withColumn("vat_rate", F.lit(1.18))
  .withColumn("net", F.col("amount") * F.lit(1.18) * F.lit(1.05))

Catalyst pre-computes 1.18 * 1.05 = 1.239 once, instead of computing it per row.

Optimization 5: Filter reordering

Catalyst tries to apply cheap filters before expensive ones. A status = 'PAID' (one column compare) runs before a UDF that calls a regex (slow).

The same query, two plans

To see Catalyst at work, here are simplified before/after plans:

Before optimization (what you wrote):

Filter (net > 50)
+- Project [order_id, user_id, net]
   +- Project [..., (amount - discount) as net, ...]
      +- Filter (amount > 100)
         +- Filter (status = 'PAID')
            +- Scan parquet(..., all 100 cols, no pushdown)

After optimization (what runs):

Project [order_id, user_id, (amount - discount) as net]
+- Filter (status = 'PAID' AND amount > 100 AND (amount - discount) > 50)
   +- Scan parquet(..., 5 cols, PushedFilters: [status=PAID, amount>100])

The optimized plan reads ~5% of the file by row-group skipping, ~5% of the columns by pruning, and applies all filters in a single pass. The naive plan read everything and filtered in three passes.

TryCatalyst optimizer

Step through how Catalyst rewrites your query

The query df.filter(price > 100).select(id, category, price).groupBy(category).agg(avg(price)) travels through three plan stages. Hover or focus any highlighted node to see which optimization rule fired.

Your code as-is. The filter sits above the select, and the scan reads all columns.

AggregategroupBy(category) .agg(avg(price))Projectselect(id, category, price)Filterprice > 100Scanproducts.parquet(all columns)
Stage 1 of 3

Reading Catalyst’s decisions with explain()

You’ll learn to read the formatted plan in the next lesson, but here’s a quick taste:

result.explain(mode="formatted")

Output (abbreviated):

== Physical Plan ==
* Project (5)
+- * Filter (4)
   +- * ColumnarToRow (3)
      +- BatchScan parquet (2)
         +- Filter [status = 'PAID', amount > 100] (1)

(1) PushedFilters: status, amount
(2) ReadSchema: order_id, user_id, amount, discount, status

The * markers indicate whole-stage codegen — Catalyst compiles the chain of operations into a single Java function with no virtual calls. That’s another 2-5x speedup on top of the planning work.

When Catalyst can’t help

Catalyst optimizes what it can see. Things it can’t see through:

  • Python UDFs — opaque Python functions, no pushdown possible
  • Complex when().otherwise() chains — Catalyst can optimize some but not arbitrary ones
  • RDDs — fully opaque; Catalyst doesn’t optimize the RDD API at all

The practical advice: prefer built-in functions over UDFs, and prefer DataFrames over RDDs. Catalyst rewards you generously for both.

Adaptive Query Execution — runtime optimization

Catalyst optimizes the plan before execution, based on static information (schema, table stats). Spark 3.x added Adaptive Query Execution (AQE) which can re-optimize during execution based on actual data sizes observed at runtime. You’ll learn about AQE in its own lesson.

The combination — Catalyst plus AQE — handles most of what a senior engineer used to do by hand-tuning. You write clean code; the engine does the work.

A toy optimizer pass

Predicate pushdown isn’t magic. Here’s the kernel of it in 20 lines:

# Toy: push filters down through projections.
# Plan represented as a list of (op_name, args) tuples, leaf first.

plan = [
    ("scan", {"cols": ["a", "b", "c"]}),
    ("project", {"keep": ["a", "b"]}),
    ("filter", {"pred": "a > 5"}),         # depends only on column 'a'
]

def pushdown(plan):
    new_plan = list(plan)
    for i in range(len(new_plan) - 1, 0, -1):
        op, args = new_plan[i]
        below_op, below_args = new_plan[i - 1]
        if op == "filter" and below_op == "project":
            cols_needed = args["pred"].split()[0]
            if cols_needed in below_args["keep"]:
                # Swap filter and project — apply filter before project
                new_plan[i], new_plan[i - 1] = new_plan[i - 1], new_plan[i]
    return new_plan

print("Original plan:")
for op, args in plan:
    print(f"  {op}({args})")

print("\nAfter pushdown:")
for op, args in pushdown(plan):
    print(f"  {op}({args})")
Original plan:
  scan({'cols': ['a', 'b', 'c']})
  project({'keep': ['a', 'b']})
  filter({'pred': 'a > 5'})

After pushdown:
  scan({'cols': ['a', 'b', 'c']})
  filter({'pred': 'a > 5'})
  project({'keep': ['a', 'b']})

Watch the filter move. In the original plan it sat above the project, meaning Spark would have built the projected rows first and only then thrown most of them away. The rule noticed that a > 5 needs only column a, which project keeps, so it is safe to swap them — and after the pass the filter sits directly on the scan, discarding rows before the projection ever touches them. That is predicate pushdown in one rule: less data flows up the tree. Real Catalyst has hundreds of these rules (written in Scala), but the shape is exactly this — pattern- match a sub-tree, replace it with a cheaper equivalent that computes the same answer.

In one breath

Catalyst is the optimizer that rewrites your DataFrame query before it runs, in four stages (parseanalyze references → optimize the logical plan → pick a physical plan), applying rules that routinely cut most of the work: predicate pushdown (push filters into the Parquet scan so row groups whose min/max stats can’t match are skipped — often reading 5% of the file), column pruning (read only the columns the query needs — 80–95% less I/O on wide tables), constant folding, filter/join reordering, and whole-stage codegen (compile the operator chain into one tight Java loop, the * in explain); it can only optimize what it can see, which is precisely why opaque Python UDFs and RDDs are slow and native F.* functions are fast — and AQE later re-optimizes at runtime.

Practice

Before the quiz, trace the worked query’s two plans. The naive version read all 100 columns and 1B rows and filtered in three passes; the optimized version read 5 columns, ~5% of the rows, in one pass. Name the two specific Catalyst optimizations responsible for the “5 columns” and the “5% of rows,” and where in the Parquet file each one acts. Then connect it to the chapter’s refrain: explain, in terms of “what Catalyst can see,” exactly why a Python @udf defeats both of those optimizations.

Quick check

0/3
Q1You filter a Parquet file by `status = 'PAID'`. How does predicate pushdown help?
Q2Why does the Spark community recommend built-in functions (F.*) over Python UDFs whenever possible?
Q3Your query reads a Parquet with 200 columns but selects only 3. What does Catalyst's column pruning do?

A question to carry forward

We just watched Catalyst do remarkable things to a query — push filters into the scan, prune 95 of 100 columns, fold constants, reorder for cost. But notice how we watched: through a hand-simplified before/after plan we wrote out by hand, a toy that swaps two list elements, and a one-line “quick taste” of .explain() that we waved past with “you’ll learn to read this next.” Every claim in this lesson — “Catalyst pushed the filter down,” “it pruned to 5 columns” — was asserted. You took it on faith.

In production you cannot take it on faith. You force a broadcast and need to confirm it broadcast; you expect a filter pushed into Parquet and need to confirm the scan skipped row groups; a job is mysteriously slow and the answer is sitting in the plan if you can read it. The single most valuable debugging skill in Spark is reading what Catalyst actually decided for your specific query — and that lives in one output we keep glancing at and never deciphering. So the question to carry forward is: how do you read a Spark physical plan — the operators, the PushedFilters, the ReadSchema, the join type, the * codegen markers — and verify with your own eyes that the optimizer did what you hoped? That is reading execution plans, and it is the next lesson.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
How does the Spark Catalyst optimizer work, and what does Adaptive Query Execution add?

Catalyst is a rule-based and cost-based query optimizer that transforms a logical plan through four phases — analysis, logical optimization, physical planning, and code generation — before any data is touched. Adaptive Query Execution (AQE), introduced in Spark 3, extends this by re-optimizing the physical plan at runtime using actual shuffle statistics rather than stale estimates.

What is lazy evaluation in Spark, and how does it differ from transformations vs actions?

Spark does not execute any computation when you call a transformation — it builds a DAG of logical steps. Only when you call an action does Spark compile that DAG into physical tasks and execute them. This design lets Catalyst optimize the full query plan before touching any data.

When should you use Spark instead of pandas, and what are the key trade-offs?

pandas operates in-memory on a single machine, making it fast and simple for datasets under a few gigabytes. Spark distributes computation across a cluster, handles terabyte-scale data, and integrates with cloud storage — but adds significant overhead for small data. The crossover point is roughly when your data no longer fits in RAM or when processing time on a single machine becomes unacceptable.

What is the difference between an RDD, a DataFrame, and a Dataset in Spark?

RDD is the low-level, type-safe distributed collection with no schema knowledge. DataFrame adds a named-column schema on top, enabling the Catalyst optimizer and codegen — but loses compile-time type safety. Dataset merges both worlds: it carries a schema and passes through Catalyst while remaining statically typed in Scala/Java.

Related lessons

Explore further

Skip to content