print(spark.version)
print(spark.conf.get("spark.sql.session.timeZone"))
Module 3: Apache Spark Fundamentals
The Spark ideas you need to read plans, reason about performance and avoid surprises on serverless.
Topics in this module
The mental model behind Spark: where code runs, when work happens, what a shuffle costs.
- Goal: read a query plan and predict performance
- Examples use DataFrame and SQL only, so they work on Free Edition
Serverless
No RDD APIs, no sparkContext, caching APIs raise errors. Driver and executors are managed for you.
Spark Architecture
By the end: draw the Spark architecture and say which part plans work and which part does it.
- Driver: one machine. Holds the
sparksession, builds the plan, splits it into tasks, collects results - Executors: run tasks, one task per partition
- Data stays in cloud storage: executors read it only while a task runs
Remember
Work is split by data (partitions). Anything pulled back to the driver must fit on one machine.
Driver, Executors, and Workers
By the end: tell the driver, an executor, a worker and a task apart, and know which one runs your Python code.
- Driver: runs your Python notebook code, builds plans.
collect()here can run out of memory - Executor: holds shuffle files and cached data. Memory limits cause spills
- Task: one slow task = skewed partition
- DataFrame calls are descriptions of work. The heavy lifting runs in the engine, not in Python
- Python UDFs run per row in Python workers, so they are slower
Best practice
Filter, join and aggregate with DataFrame functions. Bring only small results to the driver.
SparkSession and SparkContext
By the end: use the `spark` session correctly, and replace SparkContext and RDD code with DataFrame code on serverless compute.
- Every notebook already has
spark. Do not build your own session SparkContext(sc) and RDDs are not available on serverless
| Old RDD code | Use instead |
|---|---|
sc.parallelize(data) |
spark.createDataFrame(data, schema) |
sc.textFile(path) |
spark.read.text(path) |
rdd.map(f) |
column expressions or pandas_udf |
rdd.reduceByKey(f) |
df.groupBy(key).agg(...) |
rdd.getNumPartitions() |
F.spark_partition_id() (topic 10) |
Pitfall
SparkSession.builder.getOrCreate() and .config(...) from tutorials: skip them. Most configs can't be set on serverless.
DataFrames vs RDDs
By the end: explain why DataFrames replaced RDDs for almost all work.
- Spark can't see inside an RDD function, so it can't optimise it
- Because a DataFrame has a schema, Spark can reorder and prune the plan
from pyspark.sql import functions as F
lines = spark.createDataFrame([("to be or not to be",), ("to do is to be",)], ["line"])
(lines.select(F.explode(F.split("line", " ")).alias("word"))
.groupBy("word").count()
.orderBy(F.desc("count")).limit(2).show())
+----+-----+ |word|count| +----+-----+ | to| 4| | be| 3| +----+-----+
Lazy Evaluation
By the end: see lazy evaluation for yourself: transformations return immediately, an action does the work.
import time
from pyspark.sql import functions as F
t = time.time()
df = (spark.range(10_000_000)
.filter("id % 2 = 0")
.withColumn("square", F.col("id") * F.col("id")))
print(f"plan: {time.time() - t:.2f}s") # instant
t = time.time()
print(f"count: {df.count():,} in {time.time() - t:.2f}s") # 5,000,000
- Laziness lets Spark see the whole chain: prune columns, push filters down, combine steps
- Every action recomputes the chain. Serverless can't cache: write expensive results to a Delta table
Errors can appear late
Spark Connect resolves names at execution time. A bad column name may fail at the action, not at the line that caused it.
Transformations vs Actions
By the end: tell transformations from actions and pick safe actions for exploring.
df = spark.range(5)
df.show()
print(df.count()) # 5
print(df.first()) # Row(id=0)
print(df.take(3)) # 3 Rows
Pitfall
collect() and toPandas() copy everything to the driver. Use limit or aggregate first.
Narrow vs Wide Transformations
By the end: tell narrow from wide transformations and spot the shuffle in a plan.
- Narrow: each output partition needs one input partition.
filter,select,withColumn - Wide: needs rows from many partitions, so Spark shuffles.
groupBy,join,distinct - A shuffle writes to disk, crosses the network, reads back: the expensive part
from pyspark.sql import functions as F
df = spark.range(1_000_000).withColumn("bucket", F.col("id") % 10)
df.filter(F.col("id") > 10).select("id", "bucket").explain() # no Exchange
df.groupBy("bucket").count().explain() # has Exchange
- In the plan, look for
Exchange hashpartitioning(...): that is the shuffle
Design rule
Narrow work first: filter early, select only needed columns. Shuffle as little as you can.
Jobs, Stages, and Tasks
By the end: explain how one action becomes jobs, stages and tasks.
- Example:
groupBy= 2 stages. With 3 input and 2 shuffle partitions: 3 tasks, then 2 tasks - Where to inspect jobs, stages and tasks depends on compute type (Spark UI, query profile: Module 12)
Two kinds of job
A Spark job is the work of one action. A Lakeflow Job (Module 10) is a scheduled workflow. Unrelated.
DAG and Execution Plans
By the end: read a query plan with `explain` and say what each stage of planning does.
from pyspark.sql import functions as F
q = (spark.read.table("samples.nyctaxi.trips")
.filter("trip_distance > 5")
.groupBy("pickup_zip")
.agg(F.avg("fare_amount").alias("avg_fare")))
q.explain("formatted")
- No
samplescatalog? Use any table of yours (Module 4 creates some) - Read the plan from source up to result. Look for:
- Scan with filters pushed down
- Filter and Project (columns kept)
- Exchange = shuffle
- Aggregate twice: partial before the shuffle, final after
Partitions and Parallelism
By the end: see how rows spread across partitions and when to use `repartition` and `coalesce`.
from pyspark.sql import functions as F
df = spark.range(1_000_000)
(df.withColumn("pid", F.spark_partition_id())
.groupBy("pid").count().orderBy("pid").show()) # rows per partition
more = df.repartition(8) # full shuffle
fewer = more.coalesce(2) # merge, no full shuffle
- Counts per partition depend on your compute, so your output will differ
getNumPartitions()is RDD-only, so usespark_partition_id()on serverless
More partitions is not faster
Tiny partitions = overhead and small files. Huge ones = slow, memory-hungry tasks. Repartition only with a measured reason.
Spark SQL vs PySpark
By the end: write one query in SQL and in PySpark and explain why they perform the same.
%sql
SELECT pickup_zip, AVG(fare_amount) AS avg_fare
FROM samples.nyctaxi.trips
WHERE trip_distance > 5
GROUP BY pickup_zip
from pyspark.sql import functions as F
display(spark.read.table("samples.nyctaxi.trips")
.filter(F.col("trip_distance") > 5)
.groupBy("pickup_zip")
.agg(F.avg("fare_amount").alias("avg_fare")))
- Run
explain()on both: same operators - SQL: set-based, readable for analysts, good for reports and declarative pipelines
- PySpark: reusable functions, loops, unit tests
- Mix freely:
df.createOrReplaceTempView(...)andspark.sql(...)(Module 4)
Spark Execution Lifecycle
By the end: describe in order what happens between pressing Shift+Enter and getting a result.
- Action called: nothing ran before this
- Logical plan built from your code
- Analysed: names and types resolved
- Optimised by Catalyst: pushdown, pruning
- Physical plan chosen (e.g. join strategy)
- Cut into stages at shuffles, then into tasks
- Executors run tasks, results return or are written
Remember
Data is read only in step 7. Steps 2 to 6 are cheap planning.
What you can do now
- Driver plans, executors run tasks, one task per partition
- Transformations are lazy, actions run work
- Shuffles (
Exchange) split jobs into stages - Read
explainoutput; inspect partitions withspark_partition_id() - On serverless: no SparkContext, RDDs or caching