Skip to main content
Apache Spark beginner Lesson 3 of 6

Spark SQL and the Catalyst Optimizer

Register views, query them with SQL, then read Catalyst's four plan stages to see exactly which rewrites it applied to your query.

Spark SQL is not a separate engine bolted onto Spark. It is the engine — the DataFrame API and SQL are two front ends over the same optimiser.

Registering views

from pyspark.sql import SparkSession, functions as F

spark = SparkSession.builder.appName("sql").master("local[*]").getOrCreate()

orders = spark.createDataFrame(
    [(1, "alice", "UK", 250.00, "2026-01-15"),
     (2, "bob",   "US", 180.50, "2026-01-16"),
     (3, "carol", "UK", 320.75, "2026-01-16"),
     (4, "dave",  "DE",  95.00, "2026-01-17"),
     (5, "erin",  "US", 410.20, "2026-01-18")],
    ["order_id", "customer", "country", "amount", "order_date"],
)
customers = spark.createDataFrame(
    [("alice", "gold"), ("bob", "silver"), ("carol", "gold"), ("dave", "bronze")],
    ["customer", "tier"],
)

orders.createOrReplaceTempView("orders")
customers.createOrReplaceTempView("customers")

spark.sql("SHOW TABLES").show()
+---------+---------+-----------+
|namespace|tableName|isTemporary|
+---------+---------+-----------+
|         |customers|       true|
|         |   orders|       true|
+---------+---------+-----------+

Querying

spark.sql("""
    SELECT c.tier,
           COUNT(*)              AS orders,
           ROUND(SUM(o.amount),2) AS revenue,
           ROUND(AVG(o.amount),2) AS avg_order
    FROM orders o
    JOIN customers c ON o.customer = c.customer
    WHERE o.amount > 100
    GROUP BY c.tier
    ORDER BY revenue DESC
""").show()
+------+------+-------+---------+
|  tier|orders|revenue|avg_order|
+------+------+-------+---------+
|  gold|     2| 570.75|   285.38|
|silver|     1|  180.5|    180.5|
+------+------+-------+---------+

SQL and DataFrames compile to the same thing

sql_plan = spark.sql("""
    SELECT country, SUM(amount) AS total
    FROM orders WHERE amount > 100 GROUP BY country
""")

df_plan = (
    orders.filter(F.col("amount") > 100)
          .groupBy("country")
          .agg(F.sum("amount").alias("total"))
)

print(sql_plan._jdf.queryExecution().optimizedPlan().toString())
print("---")
print(df_plan._jdf.queryExecution().optimizedPlan().toString())
print("identical:", sql_plan._jdf.queryExecution().optimizedPlan().toString()
                 == df_plan._jdf.queryExecution().optimizedPlan().toString())
Aggregate [country#2], [country#2, sum(amount#3) AS total#41]
+- Project [country#2, amount#3]
   +- Filter (isnotnull(amount#3) AND (amount#3 > 100.0))
      +- LogicalRDD [order_id#0L, customer#1, country#2, amount#3, order_date#4], false
---
Aggregate [country#2], [country#2, sum(amount#3) AS total#48]
+- Project [country#2, amount#3]
   +- Filter (isnotnull(amount#3) AND (amount#3 > 100.0))
      +- LogicalRDD [order_id#0L, customer#1, country#2, amount#3, order_date#4], false
identical: False

Structurally identical — only the auto-generated expression IDs differ, which is why the string comparison says False. Use whichever front end reads better; there is no performance argument either way.

The four stages

spark.sql("""
    SELECT o.customer, o.amount
    FROM orders o JOIN customers c ON o.customer = c.customer
    WHERE o.country = 'UK' AND o.amount > 100
""").explain(True)
== Parsed Logical Plan ==
'Project ['o.customer, 'o.amount]
+- 'Filter (('o.country = UK) AND ('o.amount > 100))
   +- 'Join Inner, ('o.customer = 'c.customer)
      :- 'SubqueryAlias o
      :  +- 'UnresolvedRelation [orders]
      +- 'SubqueryAlias c
         +- 'UnresolvedRelation [customers]

== Analyzed Logical Plan ==
customer: string, amount: double
Project [customer#1, amount#3]
+- Filter ((country#2 = UK) AND (amount#3 > 100.0))
   +- Join Inner, (customer#1 = customer#5)
      ...

== Optimized Logical Plan ==
Project [customer#1, amount#3]
+- Join Inner, (customer#1 = customer#5)
   :- Project [customer#1, amount#3]
   :  +- Filter (((isnotnull(country#2) AND isnotnull(amount#3)) AND (country#2 = UK))
   :             AND (amount#3 > 100.0)) AND isnotnull(customer#1))
   :     +- LogicalRDD [order_id#0L, customer#1, country#2, amount#3, order_date#4]
   +- Project [customer#5]
      +- Filter isnotnull(customer#5)
         +- LogicalRDD [customer#5, tier#6]

== Physical Plan ==
AdaptiveSparkPlan isFinalPlan=false
+- BroadcastHashJoin [customer#1], [customer#5], Inner, BuildRight
   :- Project [customer#1, amount#3]
   :  +- Filter (...)
   :     +- Scan ExistingRDD[...]
   +- BroadcastExchange HashedRelationBroadcastMode(...)
      +- Project [customer#5]
         +- Filter isnotnull(customer#5)
            +- Scan ExistingRDD[customer#5, tier#6]

Four things happened between the parsed and optimised plans, none of which you wrote:

Filter pushdown. The WHERE clause moved below the join, so filtering happens before rows are joined rather than after. Fewer rows enter the join.

Column pruning. The right side became Project [customer#5]tier was dropped because the query never selects it.

Null inference. isnotnull(customer) was added on both sides. An inner join can never match a null key, so Catalyst makes that explicit and uses a faster code path.

Join strategy. The physical plan chose BroadcastHashJoin because the customers table is small, avoiding a shuffle entirely.

Watching pushdown reach the file

orders.write.mode("overwrite").parquet("data/orders_parquet")
spark.read.parquet("data/orders_parquet").createOrReplaceTempView("orders_pq")

spark.sql("SELECT customer FROM orders_pq WHERE amount > 300").explain()
== Physical Plan ==
*(1) Project [customer#71]
+- *(1) Filter (isnotnull(amount#73) AND (amount#73 > 300.0))
   +- FileScan parquet [customer#71,amount#73]
        DataFilters: [isnotnull(amount#73), (amount#73 > 300.0)],
        PushedFilters: [IsNotNull(amount), GreaterThan(amount,300.0)],
        ReadSchema: struct<customer:string,amount:double>

Two lines matter. PushedFilters means the predicate went into the Parquet reader, which uses per-row-group min/max statistics to skip chunks without decoding them. ReadSchema lists two columns of five — the other three are never read off disk.

CSV cannot do either:

orders.write.mode("overwrite").option("header", True).csv("data/orders_csv")
spark.read.option("header", True).csv("data/orders_csv").createOrReplaceTempView("orders_csv")
spark.sql("SELECT customer FROM orders_csv WHERE amount > 300").explain()
+- FileScan csv [customer#88,amount#90]
     DataFilters: [isnotnull(amount#90), (cast(amount#90 as double) > 300.0)],
     PushedFilters: [],
     ReadSchema: struct<customer:string,amount:double>

PushedFilters: [] — empty. Every row is read and parsed, then filtered in Spark. This is the concrete reason Parquet beats CSV, expressed in the plan rather than as folklore.

Adaptive execution changes the plan mid-flight

spark.conf.set("spark.sql.adaptive.enabled", True)

q = spark.sql("SELECT country, COUNT(*) FROM orders GROUP BY country")
q.explain()
q.collect()
print("--- after execution ---")
q.explain()
== Physical Plan ==
AdaptiveSparkPlan isFinalPlan=false
+- HashAggregate(keys=[country#2], functions=[count(1)])
   +- Exchange hashpartitioning(country#2, 200), ENSURE_REQUIREMENTS
      ...
--- after execution ---
== Physical Plan ==
AdaptiveSparkPlan isFinalPlan=true
+- == Final Plan ==
   *(2) HashAggregate(keys=[country#2], functions=[count(1)])
   +- AQEShuffleRead coalesced
      +- ShuffleQueryStage 0

isFinalPlan flipped from false to true, and AQEShuffleRead coalesced appeared. Spark measured the actual shuffle output — three groups, tiny — and collapsed 200 partitions into one. The plan you see before running is a plan, not a promise.

Catalog access

print([t.name for t in spark.catalog.listTables()])
print([c.name for c in spark.catalog.listColumns("orders")])
print(spark.catalog.currentDatabase())
['customers', 'orders', 'orders_csv', 'orders_pq']
['order_id', 'customer', 'country', 'amount', 'order_date']
default

Useful when generating SQL dynamically or validating that an expected table exists before a job runs.

Practice

1. Write a query as SQL and as DataFrame chains. Compare optimised plans.

They match node for node, differing only in expression IDs. The DataFrame API is a plan builder, and SQL is a parser that builds the same plan. Any performance advice that says “use X instead of SQL” is wrong at this level.

2. Query Parquet and CSV with the same filter. Compare PushedFilters.
parquet: PushedFilters: [IsNotNull(amount), GreaterThan(amount,300.0)]
csv:     PushedFilters: []

Parquet skips row groups whose statistics rule out a match; CSV parses every byte. On a large file the difference is often an order of magnitude.

3. Join two tables and filter one side. Does the filter appear on both?

With a filter on the join key, yes — Catalyst infers the predicate through the join and adds it to the other side, since a matching row must satisfy it too. With a filter on a non-key column it stays on one side. Look for the duplicated Filter in the optimised plan.

4. Create a temp view, open a second session, and query it.
AnalysisException: Table or view not found: my_view

Temp views are session-scoped. Use createOrReplaceGlobalTempView and query it as global_temp.my_view to share across sessions in the same application. Neither survives the application exiting — persisting requires a real catalog such as Hive or Unity Catalog.

Next: reading the Spark UI to work out why a job was slow.

Frequently Asked Questions

Is SQL slower than the DataFrame API?
No. Both are parsed into the same logical plan and go through identical optimisation, so a SQL query and the equivalent DataFrame chain produce byte-identical physical plans. Choose whichever is clearer for the query at hand.
What is the difference between a temp view and a global temp view?
A temp view is scoped to the SparkSession that created it. A global temp view lives in the global_temp database and is visible to every session in the same application, which matters when different parts of an app create their own sessions.
What is predicate pushdown?
Moving a filter as close to the data source as possible so fewer rows are read. With Parquet, Spark pushes the predicate into the file reader, which uses row-group statistics to skip chunks entirely — often reading a small fraction of the file.
Why does my query plan show a filter I never wrote?
Catalyst adds isnotnull checks when a filter or join key implies non-nullness, because that lets it use faster code paths. It also infers new predicates through joins — a filter on one side can be copied to the other when the join key makes it valid.