Week 9–10 · Priority: High

PySpark

Don't spend months learning Spark internals. Learn why Spark exists and how to think in DataFrames — that's what actually matters in real pipelines.

Why Spark is needed Understand this first

A single machine (even pandas on a beefy laptop) can't hold or process data that's too large to fit in memory, or too slow to process serially. Spark solves this by distributing data and computation across many machines.

What
A framework for processing data too big to fit on one computer, by spreading the work across many machines.
Where
Any pipeline where pandas would run out of memory or take too long.
When
When your data genuinely doesn't fit comfortably on a single machine.
How
Write similar-looking DataFrame code; Spark handles splitting the work across a cluster.
  • pandas loads the entire dataset into one machine's memory. Spark splits data into partitions spread across a cluster and processes them in parallel.
  • Spark is lazy — it builds a plan of what to compute and only executes when you ask for a result (an "action"), letting it optimize the whole chain before running anything.
  • Understanding "when would you reach for Spark over pandas, and why" matters far more than memorizing every API method — internalize this before drilling syntax.

Here's a concrete way to think about it: a typical laptop has 8-16GB of RAM, and pandas usually needs several times a file's actual size in memory to work with it comfortably (every transform tends to create extra copies along the way). A 2GB CSV can already feel uncomfortable in pandas. A 200GB dataset simply can't fit on one machine, no matter how well you write the code. That's the hard line where "make pandas faster" stops being an option, and spreading the work across multiple machines becomes the only way forward.

# pandas: this either works fine on your laptop, or it doesn't fit — no middle ground
import pandas as pd
df = pd.read_csv("orders.csv")            # entire file loaded into ONE process's memory
result = df.groupby("customer_id")["amount"].sum()

# PySpark: same logic, but the read AND the computation are split across many machines
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
df = spark.read.csv("s3://bucket/orders/", header=True)   # split into partitions across the cluster
result = df.groupBy("customer_id").sum("amount")           # each executor sums its own partitions first

Notice the code looks almost identical to pandas — that's on purpose, PySpark's DataFrame API was designed to feel familiar. But underneath, it works completely differently: spark.read.csv never loads the whole file into one process's memory. It's split into partitions the moment it's read, and groupBy().sum() runs as a distributed operation — each partition computes its own partial sum, and those get combined at the end — instead of one process working through the whole dataset alone.

Spark architecture

What
The pieces that make Spark work — a driver that plans the job, and executors that do the work.
Where
Every Spark job, whether on your laptop or a real cluster.
When
You don't configure this by hand day-to-day, but it explains why Spark behaves the way it does.
How
Your code runs on the driver; the actual data processing happens in parallel on executors.
Driver (runs your code, builds the execution plan, coordinates everything) | v Cluster Manager (allocates resources — YARN / Kubernetes / Spark standalone) | v Executors (worker processes that actually run tasks on partitions of data, in parallel)
  • Driver — the process running your main()/script; it plans the job and sends tasks out.
  • Executors — distributed workers that hold data partitions in memory/disk and execute tasks.
  • You write DataFrame code once; Spark decides how to split and distribute the actual work across executors.

Let's follow one job through this architecture, step by step, using df.groupBy("customer_id").sum("amount") as the example:

  1. Your driver program plans out the job. It doesn't do any of the actual data crunching itself.
  2. The cluster manager (say, Kubernetes) starts up a set of executor processes across the cluster's machines.
  3. Spark's scheduler splits the job into tasks — one per partition — and sends each task to the executor already holding that partition's data.
  4. Each executor computes a partial sum for its own partitions, all in parallel.
  5. A final shuffle (covered below) combines those partial sums by customer_id into the finished result — which only actually flows back to the driver once you call an action like .show() or .collect().

A single machine can run Spark too. local[*] mode treats your own CPU cores as "executors" — this is exactly how you'll write and test PySpark code on a laptop before it ever runs on a real multi-machine cluster.

DataFrames

What
A table-like structure in Spark, similar to a pandas DataFrame, but spread across a cluster.
Where
The main way you'll interact with data in PySpark.
When
For almost any Spark transformation — filtering, selecting, adding columns.
How
spark.read... to load data, then chain .select()/.filter()/.withColumn().
from pyspark.sql import SparkSession

spark = SparkSession.builder.appName("orders-pipeline").getOrCreate()

df = spark.read.parquet("s3://bucket/orders/")
df.printSchema()
df.show(5)

df2 = df.select("customer_id", "total_amount") \
        .filter(df.total_amount > 0) \
        .withColumn("amount_dollars", df.total_amount / 100)
  • A distributed, tabular structure — same mental model as a pandas DataFrame or a SQL table, but partitioned across the cluster.
  • Immutable: every transformation (select, filter, withColumn) returns a new DataFrame rather than modifying in place.
  • Schema-aware — Spark knows column types, which lets it optimize and catch type errors before running anything.

Common DataFrame operations beyond select/filter, the ones you'll reach for constantly in a real transform:

from pyspark.sql import functions as F

df = (
    df.withColumn("order_date", F.to_date("order_date"))            # cast a string column to a real date
      .withColumnRenamed("amt", "total_amount")                     # rename a column
      .dropna(subset=["customer_id"])                                # same idea as pandas dropna
      .withColumn("tier",
          F.when(F.col("total_amount") >= 500, "high")
           .when(F.col("total_amount") >= 100, "medium")
           .otherwise("low"))                                        # F.when/otherwise = SQL's CASE WHEN
      .groupBy("customer_id")
      .agg(
          F.count("order_id").alias("order_count"),
          F.sum("total_amount").alias("revenue"),
      )
)

pyspark.sql.functions — usually imported as F — is where almost every column-level operation lives: F.when, F.col, date/string functions, aggregates. You'll notice this reads almost line-for-line like the pandas transform from Week 3, and the SQL CASE expression from Week 1-2. That's on purpose — PySpark was built to feel familiar to both.

Spark SQL

What
Writing plain SQL against a Spark DataFrame instead of using the DataFrame API.
Where
Anywhere you'd rather write SQL than Python method chains.
When
When a query is easier to express in SQL, especially with joins/CTEs.
How
df.createOrReplaceTempView("name"), then spark.sql("SELECT ...").
df.createOrReplaceTempView("orders")

result = spark.sql("""
    SELECT customer_id, SUM(total_amount) AS revenue
    FROM orders
    GROUP BY customer_id
    ORDER BY revenue DESC
""")
result.show()

Every SQL skill from Week 1-2 carries over directly — just register a DataFrame as a temporary view, and query it with plain SQL. Underneath, Spark SQL and the DataFrame API turn into the exact same execution plan, so use whichever one is easier to read for the task at hand.

Proving they're the same plan — this is worth seeing once so "compiles to the same execution plan" isn't just an assertion to take on faith:

df.createOrReplaceTempView("orders")

sql_version = spark.sql("SELECT customer_id, SUM(total_amount) FROM orders GROUP BY customer_id")
api_version = df.groupBy("customer_id").sum("total_amount")

sql_version.explain()
api_version.explain()
# both print the exact same physical plan — Spark's Catalyst optimizer
# parses SQL into the identical internal representation the DataFrame API builds directly

In practice, most teams default to Spark SQL for anything with several joins or CTEs — it's more readable than a long chain of DataFrame methods, and your Week 1-2 SQL knowledge carries straight over. They reach for the DataFrame API instead when Python's control flow actually helps — looping over a list of tables, building column expressions in code, or mixing in non-SQL logic.

Transformations vs Actions

What
Two kinds of Spark operations — transformations (build a plan) and actions (actually run it).
Where
Every line of PySpark code you write is one or the other.
When
Knowing the difference explains why your code seems to “do nothing” until you call .show() or .count().
How
Chain transformations freely; nothing runs until you call an action.
Transformations (lazy)Actions (trigger execution)
select, filter, withColumn, groupBy, joinshow(), collect(), count(), write.parquet()
  • Transformations just build up a plan (a DAG of operations) — nothing actually runs yet.
  • An action forces Spark to execute the whole plan and produce a real result.
  • This laziness lets Spark's optimizer (Catalyst) rearrange and combine steps for efficiency before anything touches real data — this is the core reason Spark code often "does nothing" until you call .show() or .count().

Watching laziness happen — this line runs instantly, no matter how large the underlying data is, because nothing has executed yet:

big_df = spark.read.parquet("s3://bucket/orders/")   # instant — just registers where to read from
filtered = big_df.filter(big_df.total_amount > 100)   # instant — just adds a step to the plan
selected = filtered.select("customer_id", "total_amount")   # instant — still just a plan

selected.show()   # THIS is where Spark actually reads data, applies the filter, and computes — all at once

Because Catalyst can see the whole plan before running anything, it's free to reorder steps for you — for example, pushing your filter down so it happens during the file read itself, skipping non-matching files entirely, even though you wrote .filter() as a separate line after .read(). This "plan everything, then run it once" approach is exactly why chaining lots of small transformations in PySpark doesn't slow things down the way it would in pandas, where each line runs immediately.

Partitions

What
The chunks Spark splits your data into so it can process pieces in parallel.
Where
Every DataFrame is split into partitions behind the scenes.
When
When a job feels too slow or is using the cluster inefficiently.
How
Check with df.rdd.getNumPartitions(), adjust with .repartition() or .coalesce().
df.rdd.getNumPartitions()
df = df.repartition(8)          # shuffle to a new number of partitions
df = df.coalesce(2)             # reduce partitions without a full shuffle
  • A partition is a chunk of the DataFrame that lives on one executor and is processed independently — this is the unit of parallelism.
  • Too few partitions → you're not using the cluster's full parallelism. Too many/tiny partitions → overhead from managing them dominates.
  • repartition shuffles data to rebalance partition count/distribution; coalesce is a cheaper way to reduce partition count when you don't need a full shuffle.

A concrete symptom of the wrong partition count — the Spark UI's Stages tab shows one task per partition, and this is usually how the problem actually surfaces:

# symptom: 200 tiny tasks each processing a few KB, most of the job's time
# spent on scheduling overhead rather than real work
df = spark.read.parquet("s3://bucket/small_daily_export/")   # e.g. only a few MB total
df.rdd.getNumPartitions()   # -> 200 (Spark's default shuffle partition count)
df = df.coalesce(4)          # collapse down to a sane number for this data's actual size

# opposite symptom: a few enormous partitions, most executors sitting idle
# while one or two executors churn through a skewed partition alone
df = df.repartition(200, "customer_id")   # explicitly shuffle into 200 partitions, spread by key

Spark's default of 200 shuffle partitions (spark.sql.shuffle.partitions) is a reasonable guess for a large cluster, but it's often just wrong for small local jobs or small data. Adjusting this one setting is one of the easiest, highest-impact performance fixes in Spark — it's worth checking first whenever a job feels slower than the data size would suggest.

Shuffle

Moving data across the network between executors so related rows end up on the same partition — required for operations like groupBy, wide joins, and repartition.

What
Moving data across the network so related rows end up together.
Where
Triggered by operations like groupBy, join, and repartition.
When
This is the most expensive thing Spark does — worth knowing when it's happening.
How
Filter data down before a shuffle-triggering operation, not after.
  • Shuffles are the most expensive operation in Spark: disk I/O + network transfer across the whole cluster.
  • A large part of "Spark performance tuning" in practice is simply minimizing unnecessary shuffles — e.g. filtering data down before a join/groupBy, not after.
  • You'll see "shuffle" constantly in the Spark UI when debugging slow jobs — it's the first place to look for a bottleneck.

Here's why groupBy specifically needs a shuffle: before Spark can add up total_amount per customer_id, every row for a given customer needs to land on the same executor — you can't get a correct total if that customer's rows are scattered across five machines that never talk to each other. A shuffle is the network step that physically moves rows around so all of customer 101's data ends up together, all of customer 102's data ends up together, and so on — before any actual summing happens.

# filter BEFORE the shuffle-triggering groupBy — shrinks what has to move across the network
df.filter(df.order_date >= "2026-01-01") \
  .groupBy("customer_id") \
  .sum("total_amount")

# NOT this — filtering after groupBy still shuffled the full unfiltered dataset first
df.groupBy("customer_id").sum("total_amount").filter(...)   # too late, the expensive part already happened

Catalyst's optimizer often handles simple cases like this for you automatically. But understanding why "filter early" is the right instinct — smaller data before an expensive shuffle beats filtering after — is what helps you reason about performance in cases the optimizer can't fix on its own, like a shuffle triggered by joining two large tables together.

Joins & Broadcast joins

What
Combining two DataFrames — the same idea as a SQL join.
Where
Anywhere your data is split across more than one DataFrame.
When
Use a normal join for two large DataFrames; use a broadcast join when one side is small.
How
df1.join(df2, on="key"), or wrap the small side in broadcast() to skip a shuffle.
orders.join(customers, on="customer_id", how="left")

from pyspark.sql.functions import broadcast
orders.join(broadcast(small_lookup_table), on="product_id", how="left")
  • Regular joins between two large DataFrames require a shuffle so matching keys land on the same executor — expensive at scale.
  • Broadcast join — when one side is small enough to fit in memory (e.g. a lookup/dimension table), Spark copies it whole to every executor, avoiding the shuffle entirely. Huge speedup for fact-to-small-dimension joins (exactly your star schema pattern from Week 4).
  • Spark auto-broadcasts small tables below a size threshold, but knowing the concept lets you force it (broadcast()) when the optimizer guesses wrong.

Regular join vs broadcast join, what actually moves across the network:

# regular join: BOTH orders (huge) and customers (small) get shuffled so matching
# customer_id values land on the same executor — expensive even though customers is tiny
orders.join(customers, on="customer_id")

# broadcast join: the small `customers` table is copied WHOLE to every executor;
# `orders` never moves at all — each executor joins its local orders partition
# against its own local full copy of customers, no shuffle needed
from pyspark.sql.functions import broadcast
orders.join(broadcast(customers), on="customer_id")

The whole win comes down to which side has to move. A regular join pays the shuffle cost for both tables, no matter their size. A broadcast join pays a one-time cost to copy the small table to every executor — and then the large table never has to move at all. This is exactly the star-schema shape from Week 4 (a large fact table joined to small dimension tables), which is why broadcast joins show up constantly in real pipelines.

Spark's default cutoff for auto-broadcasting a table is 10MB (spark.sql.autoBroadcastJoinThreshold). Raise it if you know a bigger dimension table will still comfortably fit in executor memory, or force broadcast() yourself when Spark's own size estimate is off — which happens fairly often right after a filter Spark can't accurately predict the result size of.

Handling Data Skew Performance

When a shuffle splits data unevenly, one task ends up doing almost all the work while the rest of the cluster sits idle waiting for it to finish.

What
A situation where a few values of your key show up far more often than the rest, so one partition ends up holding way more data (and work) than the others.
Where
Anywhere a shuffle groups data by key — groupBy, a join on a key, or a window function partitioned by a key.
When
You'll suspect skew when a job has 199 tasks finish in seconds and 1 task that takes 10x longer, dragging out the whole job.
How
Spread the hot key across more partitions artificially ("salting"), isolate it and handle it separately, or let Spark's built-in skew handling (AQE) do it for you.

A concrete example of skew: say you're grouping orders by customer_id, and one customer is actually a test account or a big retail partner with 40 million orders while every other customer has a few hundred. Spark's shuffle sends every row for a given customer_id to the same partition — so that one partition ends up holding 40 million rows, while every other partition holds a few hundred. Spark still waits for the slowest task to finish before the job is considered done, so that one oversized partition alone can dominate the entire job's runtime.

customer_idrow countpartition
1340Partition 0
2512Partition 1
7 (the skewed one)40,000,000Partition 4
8289Partition 5

Every other executor finishes almost instantly. The one holding customer 7's partition becomes a straggler that the whole job waits on — classic data skew.

1. Salting — artificially spread a hot key across several partitions. Attach a random number to the skewed key before the shuffle, group on that combined key so the hot key's rows land on several different partitions instead of one, then combine the partial results back together afterward:

from pyspark.sql.functions import concat, lit, floor, rand

SALT_BUCKETS = 10

# stage 1: spread the hot key across N salted sub-keys, aggregate partially
salted = df.withColumn(
    "salted_key",
    concat(df.customer_id, lit("_"), floor(rand() * SALT_BUCKETS).cast("int"))
)
partial = salted.groupBy("salted_key", "customer_id").sum("total_amount")

# stage 2: strip the salt back off and combine the partial sums into the real total
final = partial.groupBy("customer_id").sum("sum(total_amount)")

Customer 7's 40 million rows are now spread across 10 different salted sub-keys instead of piling onto one partition, so 10 executors share the work instead of 1. Stage 2's final groupBy is cheap, because by then the data has already been mostly summed down to just a handful of rows per real customer_id.

2. Isolate the skewed key and handle it separately. If you know exactly which key (or keys) are skewed — often true in practice, since it's usually the same test account or big customer every time — split your DataFrame in two and handle each path differently:

skewed_keys = [7]  # known from prior runs, or found by profiling row counts per key

skewed_df = orders.filter(orders.customer_id.isin(skewed_keys))
normal_df = orders.filter(~orders.customer_id.isin(skewed_keys))

# the skewed slice is now small enough to broadcast-join or process on its own
skewed_result = skewed_df.join(broadcast(customers), on="customer_id")
normal_result = normal_df.join(customers, on="customer_id")

result = skewed_result.unionByName(normal_result)

This trades a bit of extra code for a big win: the skewed key gets a code path built for its size, and the rest of the data goes through the normal, efficient path without being held back by it.

3. Let Spark handle it automatically with Adaptive Query Execution (AQE). Spark 3.x can detect skewed partitions during a shuffle (using real, observed partition sizes, not just an upfront guess) and automatically split an oversized partition into several smaller ones:

spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")

AQE is on by default in current Spark versions, and it's worth trying before reaching for manual salting — it solves the same problem with zero code changes to your actual transformations. Manual salting is still worth knowing for older Spark versions, for skew inside a plain groupBy (AQE's skew handling specifically targets joins), or for cases extreme enough that you want more control than the automatic version gives you.

Salting only helps for aggregations (groupBy, PARTITION BY) where you can split the work and recombine it afterward. It doesn't apply to a plain join the same way — for a skewed join, reach for AQE's skew join handling first, or the isolate-and-broadcast pattern above.

Caching

What
Telling Spark to keep a DataFrame's result in memory instead of recomputing it.
Where
Any DataFrame you're going to use more than once.
When
When you notice the same expensive chain of transformations being run repeatedly.
How
df.cache(), then call an action once to actually store it.
df_expensive = df.filter(...).join(...).groupBy(...).agg(...)
df_expensive.cache()      # or .persist()

df_expensive.count()      # first action materializes and caches it
df_expensive.show()       # reuses the cached result, doesn't recompute
  • Because Spark is lazy, without caching, every action on df_expensive would recompute the entire chain from scratch.
  • .cache() stores the DataFrame in memory (spilling to disk if needed) after the first action, so subsequent actions reuse it.
  • Only cache DataFrames you'll reuse multiple times — caching everything wastes executor memory and can hurt performance.

Seeing the cost of skipping it — without caching, this recomputes the entire filter+join chain twice, from the original data read, once per action:

expensive = orders.filter(orders.total_amount > 100).join(broadcast(customers), on="customer_id")

count = expensive.count()          # runs the FULL chain: read -> filter -> join
top10 = expensive.orderBy(expensive.total_amount.desc()).limit(10).collect()   # runs it ALL again from scratch
# with caching, the second action reuses the already-computed result
expensive = orders.filter(orders.total_amount > 100).join(broadcast(customers), on="customer_id")
expensive.cache()

count = expensive.count()          # runs the full chain once, AND stores the result in executor memory
top10 = expensive.orderBy(expensive.total_amount.desc()).limit(10).collect()   # reuses the cached data — no recompute

.cache() is just shorthand for .persist(StorageLevel.MEMORY_AND_DISK) — it tries memory first, and spills over to disk if the DataFrame doesn't fit, instead of just failing. Call .unpersist() once you're done reusing a cached DataFrame, so its memory gets freed up for the rest of the job instead of sitting there unused.

Window functions

What
The same idea as SQL window functions — a calculation across related rows, without collapsing them.
Where
Ranking, running totals, or “get the latest row per group” in PySpark.
When
Same situations as SQL window functions — you're already comfortable with the concept.
How
Window.partitionBy(...).orderBy(...), then use it with .over(w).
from pyspark.sql import Window
from pyspark.sql.functions import row_number, lag

w = Window.partitionBy("customer_id").orderBy("order_date")

df.withColumn("order_seq", row_number().over(w)) \
  .withColumn("prev_amount", lag("total_amount").over(w))

This is the same idea as SQL window functions from Week 1-2, giving the same result — PARTITION BY/ORDER BY map directly onto Window.partitionBy()/.orderBy(). If you're already solid on SQL window functions, this is mostly just learning the same idea's PySpark spelling.

The "latest record per customer" pattern, in PySpark — this is the direct port of the Week 1-2 SQL dedup pattern:

from pyspark.sql import Window
from pyspark.sql.functions import row_number, col

w = Window.partitionBy("customer_id").orderBy(col("updated_at").desc())

latest = (
    df.withColumn("rn", row_number().over(w))
      .filter(col("rn") == 1)
      .drop("rn")
)

Notice you can filter directly with .filter(col("rn") == 1) right after adding the column. Unlike raw SQL, there's no need to wrap it in a separate subquery — each DataFrame step is already its own step in the plan, not one giant query.

Window functions with a single, unpartitioned window (no partitionBy) force all data onto one executor to compute — a common performance trap. Always partition a Spark window by something, even if it's a coarse grouping, unless the dataset is genuinely small.

Practice goal

What
A small end-to-end PySpark job — read, dedupe, join, and write data back out.
Where
Your own S3 data lake from Week 5.
When
After you're comfortable with the individual pieces above.
How
Read Parquet, dedupe with a window function, broadcast-join a small table, write the result.
df = spark.read.parquet("s3://bucket/orders/")

result = (
    df.groupBy("customer_id")
      .sum("amount")
)
result.show()

Putting several concepts from this page together — a small end-to-end job: read from S3, broadcast-join to a dimension table, dedupe with a window function, and write back out:

from pyspark.sql import SparkSession, Window
from pyspark.sql.functions import broadcast, col, row_number, sum as spark_sum

spark = SparkSession.builder.appName("orders-summary").getOrCreate()

orders = spark.read.parquet("s3://bucket/raw/orders/")
customers = spark.read.parquet("s3://bucket/raw/customers/")   # small — good broadcast candidate

# dedupe: keep only the latest version of each order
w = Window.partitionBy("order_id").orderBy(col("updated_at").desc())
orders_clean = (
    orders.withColumn("rn", row_number().over(w))
          .filter(col("rn") == 1)
          .drop("rn")
)

# broadcast join + aggregate
result = (
    orders_clean.join(broadcast(customers), on="customer_id")
                .groupBy("customer_id", "city")
                .agg(spark_sum("total_amount").alias("revenue"))
)

result.write.mode("overwrite").parquet("s3://bucket/curated/revenue_by_customer/")

Every line here maps back to something covered above: partitioned S3 reads, a window-function dedupe, a broadcast join to avoid shuffling the large table, and a final write — the same shape as the Glue job in Week 5, and the "PySpark / dbt" box in your capstone architecture.

Target: Read your Week 5 S3 data lake (orders/products/customers as Parquet) into PySpark, do a broadcast join between a large fact-like DataFrame and a small dimension DataFrame, run a window-function query, and write the result back to S3 or your warehouse. Focus on getting comfortable with the DataFrame API and reading a Spark UI job — not on tuning cluster internals.