4.1 Spark fundamentals¶
Concept¶
You already met Spark in 3.7 — the pre-created spark session,
DataFrames that feel like pandas, and the transformations vs actions rule. That page was
how to use it from a notebook; this unit is how it actually works, and how to build a
lakehouse with it. Where Unit 2's Trino was mostly for reading the
lake, Spark is the distributed engine you'll use to build the medallion tables (Bronze →
Silver → Gold, from the medallion). This page goes under the hood: the
driver/executor split, partitions, and why laziness makes Spark fast.
What "distributed" means¶
Spark splits your data into partitions and processes them in parallel. A cluster has one driver (plans the work) and many executors (do the work). You write one program; Spark fans it out.
flowchart TB
D[Driver<br/>builds the plan] --> E1[Executor 1]
D --> E2[Executor 2]
D --> E3[Executor 3]
E1 --> P1[partition a,b]
E2 --> P2[partition c,d]
E3 --> P3[partition e,f]
DataFrames¶
A DataFrame is a distributed table: named, typed columns, split into partitions. Same
mental model as a SQL table or a pandas DataFrame — but it can be terabytes and lives across
the cluster. You transform it with method chains (.filter(), .select(), .groupBy()) or
with SQL via spark.sql(...).
Transformations vs actions (lazy evaluation)¶
You met this in 3.7; here's what's happening underneath — the single most important Spark idea:
- Transformations (
filter,select,withColumn,join,groupBy) are lazy — they only build up a plan. Nothing runs yet. - Actions (
show,count,collect,write) trigger execution — Spark optimizes the whole plan, then runs it.
flowchart LR
T1[read] --> T2[filter] --> T3[select] --> T4[groupBy]
T4 -->|action: show| RUN[Optimize + run the whole chain]
Because Spark sees the entire plan before running, it can push filters down, prune columns, and avoid reading data you don't need — warehouse-grade optimization on lake files.
Partitions¶
Partitions are the unit of parallelism. More partitions = more parallelism (up to a point); too many tiny partitions = overhead. You'll rarely tune this early, but being able to see how many partitions a DataFrame has (the lab shows how) helps you reason about performance later.
Lab¶
Connect to Spark from Jupyter at http://localhost:8008 (sign in with
your lab account). The notebook is a lightweight Spark Connect client: it's pre-wired to the
cluster's Connect server (sc://spark-connect:15002), and the my lakehouse catalog is
already configured — pointing at your own lakehouse (demouser_lake), so the tables you build in
this unit are yours alone. So the whole connection is a single line — the equivalent of Unit 2's
USE …:
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate() # Connect client → the cluster; iceberg catalog ready
print(spark.version)
Read it step by step:
from pyspark.sql import SparkSession— imports the entry-point class. ASparkSessionis your handle to the cluster: every DataFrame, everyspark.sql(...), every read and write goes through it. Think of it as the "connection object" for Spark.SparkSession.builder.getOrCreate()—.builderstarts configuring a session;.getOrCreate()reuses one if it already exists, otherwise makes a new one. Normally you'd pass a.master(...)or.remote(...)here to say which cluster — but this notebook is a pre-wired Spark Connect client (a thin client that talks to a remote Spark cluster over thesc://spark-connect:15002address the environment already set). So the address, themycatalog, and the storage credentials are all supplied by the server, and one line is enough. Nothing is computed on the cluster yet — you've only opened the connection.print(spark.version)— asks the cluster which Spark version it's running and prints it. A quick "am I really connected?" check.
Driver, executors, partitions — the words behind the diagram
When you call an action later, Spark runs your program in two roles. The driver is the
process holding spark — it builds the plan and hands out work. The executors are the
worker processes that actually crunch data, in parallel. Each executor works on
partitions — slices of your DataFrame. You write one script; Spark fans it across the
cluster so data far bigger than one machine's RAM still fits.
Where's all the config?
In Unit 2 you saw catalogs are admin-configured, not created by you. Same here: the
cluster's Connect server already knows the my catalog (object storage + Iceberg
REST) and the S3 credentials, so your notebook doesn't repeat any of it. You'll build the
lakehouse under my.bronze/silver/gold — your own lakehouse, which Trino (Unit 2) reads as demouser_lake.
Build a first DataFrame from a small in-memory sample and feel lazy evaluation. (This is a
tiny synthetic sample, using ShopFlow's real status values — real revenue comes from
order_items, which you'll join in 4.3.)
orders = spark.createDataFrame(
[
(1, 101, "2024-09-01", "delivered"),
(2, 102, "2024-09-01", "delivered"),
(3, 101, "2024-09-02", "cancelled"),
(4, 103, "2024-09-02", "delivered"),
],
["order_id", "customer_id", "order_date", "status"],
)
# Transformations — still lazy, nothing has executed:
delivered = orders.filter("status = 'delivered'").select("customer_id", "order_date")
# Action — NOW Spark plans, optimizes and runs:
delivered.show()
# Another action:
print("delivered orders:", delivered.count())
# Inspect the physical plan Spark will run (no execution of data):
delivered.explain()
Read it step by step:
spark.createDataFrame([...], [...])— builds a DataFrame (a distributed, typed table) from a Python list of rows plus a list of column names. Here four rows and the columnsorder_id, customer_id, order_date, status. A DataFrame looks like a pandas table, but it's split into partitions and lives on the cluster, so the same code works whether it's 4 rows or 4 billion. Building it is cheap — no data is scanned yet.orders.filter("status = 'delivered'")— a transformation (lazy). It says "keep only rows wherestatusisdelivered", but runs nothing — it just adds a step to the plan. The filter is written as a SQL-like string here; you'll also see the column-object formF.col("status") == "delivered"in the Challenge..select("customer_id", "order_date")— another transformation (lazy). It prunes the DataFrame down to just those two columns. Still no execution;deliveredis now a recipe (read → filter → select), not a computed result.delivered.show()— the first action. An action is what forces Spark to actually run: it optimizes the whole chain, ships the work to executors, and — forshow()— prints the first ~20 rows as a table. This is the moment the lazy plan finally executes.delivered.count()— another action. It re-runs the plan to count the rows and returns a single number. (Each action triggers its own run; Spark doesn't cache results unless you ask it to.)delivered.explain()— prints the physical plan — the steps Spark would run — but without touching the data. Great for seeing the optimizer at work (e.g. the filter pushed down before the projection). Treat it as "show me the plan", not an action on the data.
Why lazy? Because Spark optimizes the whole plan
Because transformations only build a plan, Spark sees the entire chain before running a
single row. That lets it push the filter down, drop columns you didn't select, and skip
reading data it doesn't need — the same warehouse-grade optimization Trino did in Unit 2,
now applied to your Python. The rule to memorize: transformations are lazy; an action
(show, count, collect, write) triggers execution.
Peek at partitioning and a first aggregation:
from pyspark.sql.functions import spark_partition_id
print("partitions:", orders.select(spark_partition_id()).distinct().count())
orders.groupBy("status").count().show()
Read it step by step:
orders.select(spark_partition_id()).distinct().count()— reports how many partitions this DataFrame is split into (its slices of parallelism). On real lake files Spark chooses this from the data size. You rarely tune it early — this is just to see that a DataFrame is physically chunked.orders.groupBy("status")— a transformation (lazy). It buckets rows bystatus, the DataFrame equivalent of SQL'sGROUP BY. Nothing runs yet..count()— herecount()chained aftergroupByis a transformation: it declares "count the rows in each bucket" and returns a new DataFrame (one row per status). (Contrast with the earlierdelivered.count(), which was called on a plain DataFrame and returned a number — that one is an action. Same word, two behaviours: on a grouping it's lazy; on a DataFrame it's an action.).show()— the action that triggers the wholegroupBy → countplan and prints the per-status counts.
Challenge¶
Using the orders sample above, produce the count of delivered orders per order_date,
sorted by date — with DataFrame methods only (no SQL). Then print the plan and identify
which steps are transformations vs the action.
Solution
from pyspark.sql import functions as F
result = (
orders
.filter(F.col("status") == "delivered") # transformation
.groupBy("order_date") # transformation
.agg(F.count("*").alias("delivered")) # transformation
.orderBy("order_date") # transformation
)
result.explain() # inspect plan — still nothing computed
result.show() # ACTION — triggers the whole chain
show() (and explain) force execution; every .filter/.groupBy/.agg/.orderBy
just extended the lazy plan.
Read it step by step:
from pyspark.sql import functions as F— imports Spark's column-function library under the short aliasF. This is whereF.col,F.count,F.sum, etc. live — the building blocks for expressing columns and aggregates in the DataFrame API.F.col("status") == "delivered"— the column-object way to write a filter.F.colnames a column;==builds a comparison expression. Same effect as the string"status = 'delivered'"you used in the Lab — just typed rather than parsed. Still a lazy transformation..groupBy("order_date")— buckets rows by date. Transformation (lazy)..agg(F.count("*").alias("delivered"))— computes one aggregate per bucket:F.count("*")counts rows in the group, and.alias("delivered")names the output column. Transformation (lazy) — it describes the aggregation, runs nothing..orderBy("order_date")— sorts the result by date. Transformation (lazy).result.explain()— prints the plan; no data computed.result.show()— the single action that finally runs the whole chain.
🎯 This runs unchanged on Azure, Databricks, Snowflake & Fabric
What you just did: connected to a Spark cluster and ran lazy DataFrame transformations vs. actions across partitions.
- Azure Databricks — this is Apache Spark: the DataFrame API, lazy evaluation, the
driver/executor/partition model, and
spark.sql()are the same product; this code lifts straight into a Databricks notebook. - Microsoft Fabric — Fabric Spark notebooks run this same PySpark, one-to-one.
- Snowflake — Snowpark gives the same lazy DataFrame style (execute-on-action) on a virtual warehouse; deliberately Spark-like, though not byte-identical.
- Azure Data Factory — Mapping Data Flows run on managed Spark under the hood; you build the same transformations visually instead of in code.
Learn Spark once here, use it on every platform.
Key terms, at a glance¶
| Term | Plain meaning |
|---|---|
| Spark | A distributed compute engine — splits work across a cluster to process data bigger than one machine's RAM |
| Cluster | The set of machines Spark runs on: one driver + many executors |
| Driver / executor | Plans the work / does the work in parallel |
| Partition | A slice of the data — the unit of parallelism |
| DataFrame | A distributed, typed table you transform (like pandas, but scales out) |
| Transformation | A lazy step that only builds the plan (filter, select, groupBy, join…) |
| Action | Triggers execution (show, count, collect, write) |
| Lazy evaluation | Nothing runs until an action; Spark optimizes the whole plan first |
SparkSession (spark) |
Your entry point / handle to the cluster — every DataFrame and query goes through it |
Spark Connect (sc://…) |
Lightweight client protocol to a remote Spark cluster |
You can now…¶
- Explain distributed compute: driver, executors, partitions
- Distinguish transformations (lazy) from actions (trigger execution)
- Connect to Spark via Jupyter / Spark Connect and run first DataFrame ops