import time
from pyspark.sql import functions as F
catalog = spark.sql("SELECT current_catalog()").first()[0]
spark.sql(f"USE SCHEMA {catalog}.dbx_course")
def timed(label, fn):
t = time.time(); out = fn(); print(f"{label}: {time.time() - t:.1f}s"); return out
(spark.range(0, 2_000_000).select(
F.col("id").alias("order_id"),
(F.pmod(F.hash("id", F.lit(1)), F.lit(1000)) + 1).alias("customer_id"),
(F.pmod(F.hash("id", F.lit(2)), F.lit(30)) + 1).alias("product_id"),
(F.pmod(F.hash("id", F.lit(3)), F.lit(5)) + 1).alias("quantity"),
F.timestamp_seconds(F.lit(1735689600) + F.col("id") * 15).alias("order_ts"))
.write.mode("overwrite").saveAsTable("perf_orders"))
(spark.range(0, 1000).select(
(F.col("id") + 1).alias("customer_id"),
F.concat(F.lit("Customer "), F.col("id") + 1).alias("name"),
F.element_at(F.array(F.lit("NL"), F.lit("DE"), F.lit("FR"), F.lit("US")),
(F.pmod(F.col("id"), F.lit(4)) + 1)).alias("country"))
.write.mode("overwrite").saveAsTable("perf_customers"))
(spark.range(0, 2_000_000).select(
F.col("id").alias("order_id"),
F.when(F.pmod(F.hash("id", F.lit(5)), F.lit(10)) < 7, F.lit(1))
.otherwise(F.pmod(F.hash("id", F.lit(6)), F.lit(1000)) + 1).alias("customer_id"),
F.lit(1).alias("quantity"))
.write.mode("overwrite").saveAsTable("perf_skewed"))
Module 12: Spark & Databricks Performance Optimization
Read query plans, tune joins and shuffles, and understand AQE and Photon.
Topics in this module
- Spark Query Execution
- Logical and Physical Plans
- Catalyst Optimizer
- Adaptive Query Execution (AQE)
- Photon Engine
- Predicate Pushdown and Partition Pruning
- Data Skipping
- Broadcast vs Shuffle Joins
- Shuffle Optimization
- Data Skew and Salting
- Repartition vs Coalesce
- Caching and Persistence
- Small File Problem
- Liquid Clustering vs Partitioning
- Spark UI and Query Profiling
- Memory and Executor Tuning
- Serverless Performance Optimization
Find out why a query is slow, then fix the cause. Most speed-ups come from reading less data, shuffling less, and giving the optimizer the right layout.
- Serverless has no Spark UI and accepts only a few Spark settings. You tune with code, table layout, and the query profile
- Habit: look at the plan or profile first, change one thing, measure again
- Setup: a 2-million-row table, a small dimension table and a skewed table (about 70% of rows share one key)
Spark Query Execution
By the end: trace how Spark runs a query from code to tasks.
- Transformations (
filter,select,join) are lazy: they build a plan - Actions (
count,write,display,collect) trigger work - A shuffle (join, groupBy, repartition) splits a job into stages
- A stage = many tasks, one per partition, run in parallel
- Slow queries are usually: too much data read, a big shuffle, one slow (skewed) task, or too many tiny files
Remember
Performance work = read less, shuffle less, balance the work.
Logical and Physical Plans
By the end: read a plan with explain.
q = (spark.table("perf_orders")
.filter("customer_id = 42")
.groupBy("product_id").agg(F.sum("quantity").alias("qty")))
q.explain("formatted")
- Read the physical plan bottom to top: scan first, result last
- Words to look for:
| In the plan | Means |
|---|---|
Scan ... PushedFilters |
The filter is applied at the source |
Exchange |
A shuffle |
BroadcastHashJoin / SortMergeJoin |
Join strategy |
HashAggregate |
Aggregation (partial and final) |
AdaptiveSparkPlan |
AQE is on |
Photon... |
Runs in Photon |
explain()shows the physical plan."extended"and"formatted"show more- SQL
EXPLAINdoesn't run the query. After running, AQE may have changed the real plan
Catalyst Optimizer
By the end: know what the Catalyst optimizer does for you.
| Rule | Effect |
|---|---|
| Predicate pushdown | Filter moves next to the scan |
| Column pruning | Only needed columns are read |
| Constant folding | 1 + 2 becomes 3 once |
| Join reordering, strategy | Cheaper join first, broadcast if small |
| Combining projects/filters | Fewer passes |
- You get these for free with built-in functions and DataFrames
- A Python UDF is a black box: no pushdown through it, extra serialization (Module 3)
- Good statistics (Delta stats,
ANALYZE) make better choices - Write the clear query. Check the plan only if it's slow
Adaptive Query Execution (AQE)
By the end: see Adaptive Query Execution at work.
j = (spark.table("perf_orders").join(spark.table("perf_customers"), "customer_id")
.groupBy("country").agg(F.count("*").alias("orders")))
j.collect()
j.explain() # after running: look at isFinalPlan=true
| AQE does at runtime | Sign in the plan |
|---|---|
| Switches to a broadcast join if one side turns out small | Join node differs from the initial plan |
| Coalesces many small shuffle partitions | AQEShuffleRead / CustomShuffleReader with Coalesced |
| Splits skewed partitions in joins | SortMergeJoin with isSkew=true |
| Skips work on empty inputs | Empty LocalTableScan |
- On by default
- Defaults: broadcast threshold 30 MB, advisory partition size 64 MB, skew factor 5 and 256 MB
- On serverless
spark.sql.shuffle.partitionsisauto - You can't tune most
spark.sql.adaptive.*settings on serverless: it's managed for you
Trust AQE first
Don't hand-tune partitions before checking what AQE already did.
Photon Engine
By the end: understand what Photon accelerates and how to confirm it ran.
- Photon = Databricks' native, vectorized engine, on by default for serverless compute and SQL warehouses
- Speeds up scans, filters, joins, aggregations, sorts, window functions and writes to Delta/Parquet
- No code change. Unsupported operations fall back to Spark. Results are the same
| Not accelerated | |
|---|---|
| Python UDFs, RDD and Dataset APIs | Run in Spark |
| Stateful streaming | Runs in Spark |
| Very short queries (< 2 s) | Overhead dominates |
- Check it: in the query profile, Photon operators are purple and others grey. Execution details shows the share of time in Photon
- Fewer Python UDFs = more of the plan in Photon
Built-ins over UDFs
It's the one change that helps Catalyst, Photon and the profile at the same time.
Predicate Pushdown and Partition Pruning
By the end: prune data with filter pushdown and partition pruning.
(spark.table("perf_orders").withColumn("order_date", F.to_date("order_ts"))
.write.mode("overwrite").partitionBy("order_date").saveAsTable("perf_orders_by_date"))
f = spark.table("perf_orders_by_date").filter("order_date = '2025-03-01'")
f.explain("formatted") # look for PartitionFilters / PushedFilters in the scan
print(f.count())
| Technique | How it prunes |
|---|---|
| Predicate pushdown | The scan applies the filter using file statistics |
| Partition pruning | Whole folders are skipped (PartitionFilters) |
| Column pruning | Unused columns aren't read |
| Dynamic pruning | A join with a filtered small table prunes the big table at runtime |
- Filter on the raw column.
WHERE to_date(order_ts) = ...can block pruning;WHERE order_ts >= ... AND order_ts < ...is safe selectonly needed columns before heavy steps- Hive-style partitioning suits very large tables with a low-cardinality column. For new tables prefer liquid clustering (topic 14)
Over-partitioning
Partitioning by a high-cardinality column (user id) creates millions of tiny files. Partition columns need few values and lots of data each.
Data Skipping
By the end: let Delta statistics skip files, and make them useful.
(spark.table("perf_orders").orderBy("order_ts")
.write.mode("overwrite").saveAsTable("perf_orders_sorted"))
timed("selective filter", lambda: spark.table("perf_orders_sorted")
.filter("order_ts BETWEEN '2025-03-01' AND '2025-03-02'").count())
- Delta stores min / max / null count per file for columns
- A filter on a column whose ranges are sorted or clustered skips most files
- Data order matters: ordered by time, a time filter skips well. Random order, no skipping
%sql
ALTER TABLE perf_orders_sorted SET TBLPROPERTIES ('delta.dataSkippingStatsColumns' = 'order_ts,customer_id')
| Option | Use |
|---|---|
delta.dataSkippingNumIndexedCols |
Stats on the first N columns (default 32) |
delta.dataSkippingStatsColumns |
Stats on named columns (overrides the number) |
ANALYZE TABLE ... COMPUTE DELTA STATISTICS |
Recompute stats after a change |
| Predictive optimization | Managed tables pick the columns for you |
- Long strings are truncated in stats: keep rarely filtered long text columns out
- Check files pruned in the query profile (topic 15)
- Liquid clustering is the reliable way to keep data clustered (topic 14)
Broadcast vs Shuffle Joins
By the end: choose between broadcast and shuffle joins.
from pyspark.sql.functions import broadcast
big, small = spark.table("perf_orders"), spark.table("perf_customers")
b = big.join(broadcast(small), "customer_id") # hint: broadcast the small side
s = big.join(small.hint("merge"), "customer_id") # force sort-merge, to compare
b.explain(); s.explain()
| Hint | Strategy |
|---|---|
broadcast |
Broadcast hash join |
merge |
Sort-merge join |
shuffle_hash |
Shuffled hash join |
shuffle_replicate_nl |
Nested loop (cartesian-like) |
- Spark broadcasts automatically when a side is under the threshold (AQE: 30 MB)
- Broadcast only genuinely small tables. Too big = out of memory
- In the plan:
BroadcastHashJoinwithBroadcastExchangevsSortMergeJoinwith twoExchange - Join on the same type and null-safe keys. Dedupe the small side first (Module 3)
Shuffle Optimization
By the end: reduce the cost of shuffles.
- Every
Exchangewrites data to disk and moves it across the network - Shuffle triggers:
join,groupBy,distinct,orderBy,repartition, window functions
# less shuffled data: filter and aggregate before the join
daily = (spark.table("perf_orders").filter("quantity > 1")
.groupBy("customer_id").agg(F.sum("quantity").alias("qty")))
result = daily.join(spark.table("perf_customers"), "customer_id")
| Do | Why |
|---|---|
| Filter and select first | Fewer rows and columns to move |
| Aggregate before joining | Smaller side to shuffle |
| Join on keys already partitioned the same | Fewer exchanges (clustered tables help) |
Avoid repeated distinct and orderBy |
Each one shuffles |
approx_count_distinct when exact isn't needed |
Cheaper than exact |
Leave shuffle.partitions on auto (serverless) |
AQE sizes the partitions |
- Too many shuffle partitions = tiny tasks; too few = huge tasks and spill.
autohandles this orderByonly at the end, only when output order matters
Data Skew and Salting
By the end: spot data skew and fix it with salting.
sk = spark.table("perf_skewed")
sk.groupBy("customer_id").count().orderBy(F.desc("count")).show(3) # key 1 has ~70% of rows
Salting a skewed aggregation: two steps.
N = 8
salted = (sk.withColumn("salt", F.pmod(F.hash("order_id"), F.lit(N)))
.groupBy("customer_id", "salt").agg(F.count("*").alias("n")) # partial, spread out
.groupBy("customer_id").agg(F.sum("n").alias("n"))) # final, small
Salting a join (when AQE doesn't fix it): replicate the small side across salt values.
small = spark.table("perf_customers")
small_salted = small.crossJoin(spark.range(N).select(F.col("id").cast("int").alias("salt")))
big_salted = sk.withColumn("salt", F.pmod(F.hash("order_id"), F.lit(N)))
joined = big_salted.join(small_salted, ["customer_id", "salt"])
- Signs of skew: one task far slower than the median, one long bar in the profile
- Try first: AQE skew handling (on), a filter that removes the hot key, a broadcast join
- Salting costs complexity: use it when the others fail
Measure the key distribution
groupBy(key).count() before joining on it. Nulls count as a key too.
Repartition vs Coalesce
By the end: change partition counts with repartition and coalesce.
df = spark.table("perf_orders")
wide = df.repartition(16, "customer_id") # full shuffle, even sizes, hash by key
narrow = df.coalesce(2) # merge partitions, no shuffle
repartition(n) |
coalesce(n) |
|
|---|---|---|
| Shuffle | Yes | No |
| Can increase partitions | Yes | No |
| Result sizes | Even | Can be uneven |
| Use | Spread work, partition by a key before writing | Reduce output files cheaply |
repartition("key")groups equal keys: useful before a write that is read by key- Don't
coalesce(1)big data: all work moves to one task - Don't repartition "just in case". Measure first
- Serverless manages partitions:
getNumPartitions()and the RDD API aren't available
Serverless
df.rdd is not supported on serverless. repartition and coalesce (DataFrame) work.
Caching and Persistence
By the end: replace caching with something that works on serverless.
df.cache()/persist()raise an error on serverless- Delta tables get automatic disk caching of data on classic compute and SQL warehouses
- SQL warehouses also keep a query result cache (an identical query returns instantly)
| Need | Use on serverless |
|---|---|
| Reuse an expensive intermediate | Write it to a Delta table, read it back |
| Reuse inside one query | A CTE or a temp view (recomputed per use, but optimized together) |
| Reuse across steps in a job | A Delta table per stage |
stage = (spark.table("perf_orders").join(spark.table("perf_customers"), "customer_id")
.groupBy("country", "product_id").agg(F.sum("quantity").alias("qty")))
stage.write.mode("overwrite").saveAsTable("perf_stage") # the 'cache'
by_country = spark.table("perf_stage").groupBy("country").agg(F.sum("qty").alias("qty"))
by_product = spark.table("perf_stage").groupBy("product_id").agg(F.sum("qty").alias("qty"))
- A written table is also inspectable, restartable and shareable
- On classic compute, cache only data used several times, and
unpersist()when done
Porting old code
Remove .cache(), .persist(), spark.catalog.cacheTable and df.rdd from notebooks you move to serverless.
Small File Problem
By the end: find and fix the small file problem.
(spark.table("perf_orders").repartition(100)
.write.mode("overwrite").saveAsTable("perf_small_files"))
display(spark.sql("DESCRIBE DETAIL perf_small_files").select("numFiles", "sizeInBytes"))
%sql
OPTIMIZE perf_small_files
display(spark.sql("DESCRIBE DETAIL perf_small_files").select("numFiles", "sizeInBytes")) # far fewer files
- Many tiny files = slow listing and many small tasks
- Causes: too many partitions, frequent small appends, streaming micro-batches, over-partitioned tables
- Fixes, in order of preference:
| Fix | Notes |
|---|---|
| Predictive optimization | Managed tables are compacted for you |
OPTIMIZE |
Manual compaction (Module 5.12) |
| Auto compaction, optimized writes | Table properties that reduce small files on write |
| Fewer partitions / liquid clustering | Fix the layout, not just the symptom |
- Never leave
coalesce(1)or hundreds of partitions on regular writes
Liquid Clustering vs Partitioning
By the end: cluster a table with liquid clustering instead of partitioning.
%sql
CREATE OR REPLACE TABLE perf_orders_lc CLUSTER BY (customer_id, order_ts) AS
SELECT * FROM perf_orders
%sql
SELECT count(*) FROM perf_orders_lc WHERE customer_id = 42 -- compare files pruned in the profile
| Partitioning | Liquid clustering | |
|---|---|---|
| Layout | Folder per value | Clustered by keys, flexible |
| Change keys | Rewrite the table | ALTER TABLE ... CLUSTER BY |
| High-cardinality column | Bad | Fine |
| Skew in key | Skewed folders | Handled |
| Recommended | Rare cases | All new tables |
- Up to 4 clustering keys: choose columns used in filters
- Data is clustered on
OPTIMIZE. Predictive optimization does it for managed tables CLUSTER BY AUTOlets Databricks choose keys (needs predictive optimization)- Not combined with partitioning or
ZORDER
Start with liquid clustering
Only fall back to partitioning for a specific, measured reason (Module 5.13).
Spark UI and Query Profiling
By the end: use the query profile to find the slow operator.
Open it: under a cell, See performance โ pick a statement โ See query profile. Or Query History โ the query.
| Look at | To learn |
|---|---|
| Top operators | Which step takes the time |
| Time spent, Rows, Memory peak | Per-operator cost and size |
| Filter icon on scans | % of files and data pruned |
| IO section | Bytes read and written |
| Wall-clock breakdown | Scheduling vs planning and pruning vs execution |
| Photon share | How much ran in Photon |
| Verbose mode | Hidden operators |
- Red flags: scan with little pruning, a join producing far more rows than its inputs, a shuffle with one huge partition, long scheduling time
- Your own timing with
timed()is a quick check; the profile says why - Queries served from the result cache have no profile
No Spark UI
Serverless doesn't have the Spark UI. The query profile replaces it. Classic compute still has Stages, SQL and Streaming tabs.
Memory and Executor Tuning
By the end: know the memory and executor settings, and what serverless takes off your hands.
- Classic: executor memory splits into execution (shuffles, joins) and storage (cache)
- Spill (data written to disk during a shuffle or sort) means memory is short: more memory per core, fewer partitions per task, or less data
- Out-of-memory causes: collecting to the driver, a huge broadcast, a skewed partition, very wide rows
- Serverless has few settable configs:
spark.sql.shuffle.partitions,spark.sql.session.timeZone,spark.sql.ansi.enabled,spark.sql.files.maxPartitionBytes, timeouts - If a serverless query runs out of memory: reduce the data, fix skew, avoid
collect(), or use a larger/high-memory option where offered
Fix the query before the cluster
Adding memory hides skew and bad joins. Cheaper to fix the cause.
Serverless Performance Optimization
By the end: apply the serverless performance checklist.
| Area | Do |
|---|---|
| Performance mode | Performance-optimized for interactive. Standard for scheduled jobs and pipelines that can wait a few minutes to start (lower cost) |
| Environments | Pin an environment version and library versions. Package caching speeds restarts |
| Streaming | availableNow with maxFilesPerTrigger/maxBytesPerTrigger so batches fit in memory |
| Layout | Liquid clustering, predictive optimization, no tiny files |
| Code | Built-ins, filter early, broadcast small tables, no collect() of big data |
| Caching | Write intermediates to Delta tables |
| Debugging | Query profile from See performance and Query History |
- Many serverless limits exist (Module 2): no RDDs, no caching, few Spark configs
- Free Edition has a daily compute quota: a wasteful query uses it up. Use
LIMITwhile exploring
Remember
On serverless you don't tune the machine. You tune the data layout and the query.
What you can do now
- Plans, Catalyst, AQE and Photon
- Pruning: pushdown, partition pruning, data skipping, clustering
- Joins, shuffles, skew, repartition and coalesce
- Caching alternatives, small files, the query profile
- What serverless changes about tuning