Apache Spark and PySpark basics
Learn how Spark processes big data in parallel, then build a PySpark job that reads CSV files, joins and aggregates them with the DataFrame API and SQL, and writes partitioned Parquet. You will also learn lazy evaluation and shuffles.
What you will build: a small PySpark job that reads Brew & Bean orders and the menu from CSV files, joins them, calculates revenue per category and per day, and writes the result as partitioned Parquet files.
Prerequisites: Python basics and the SQL from *Advanced SQL* in this track. You also need Python 3.9 or newer and a Java runtime (Spark runs on the JVM; check the Spark install page for the Java versions your PySpark release supports). If installing Java is a hassle, run the same code in a free Google Colab notebook, where you only need pip install pyspark.
Why Spark exists
A normal Python script (or pandas) runs on one machine and keeps data in that machine's memory. When data reaches hundreds of GB or more, one machine becomes too slow or runs out of memory.
Apache Spark splits the data into pieces called partitions, sends each piece to a different CPU core (or machine) and runs the same operation on all of them at once. You write code as if you were working on one big table. Spark does the splitting, scheduling and recovery from failures.
Think of a kitchen at lunch rush. One cook making 1,000 sandwiches takes all day. Ten cooks each making 100 finish in a tenth of the time, provided someone hands out the work and collects the results. Spark is that manager.
Spark or pandas?
| Tool | Best for | Limit |
|---|---|---|
| pandas | Data that fits in one machine's memory (up to a few GB) | Single machine |
| Spark (PySpark) | Large data, many files, joins across big tables | Startup cost; overkill for small files |
| SQL warehouse (BigQuery, Snowflake) | SQL on managed storage | Less flexible for custom Python code |
Spark has four main parts you will meet: Spark SQL and DataFrames (what this lesson uses), Structured Streaming (covered in *Stream processing* later), MLlib for machine learning, and the core engine that runs everything. PySpark is simply the Python API.
How Spark runs your code
A Spark application has a driver (your Python program, which plans the work) and executors (workers that process partitions). In local mode, which we use here, the driver and executors are all threads on your laptop. On a cluster they are separate machines, but your code does not change.
Two ideas explain most Spark behaviour:
Lazy evaluation. Operations like filter, select, join and groupBy are transformations. They do not run; they only add a step to a plan. Work starts when you call an action such as show(), count(), collect() or write. Because Spark sees the whole plan first, its optimiser (Catalyst) can reorder and simplify it, for example by reading only the columns you need.
Shuffles. Some steps (groupBy, join, orderBy) need rows with the same key to be on the same machine. Spark must move data across the network to do that, which is called a shuffle. Shuffles are the main cost in most Spark jobs, so fewer shuffles usually means a faster job.
Transformations and actions
| Type | Examples | Runs immediately? |
|---|---|---|
| Transformation | select, filter, withColumn, join, groupBy, agg | No, only builds the plan |
| Action | show, count, collect, write, toPandas | Yes, triggers the job |
Set up
- 1Create a folder
spark-demoand open a terminal inside it. - 2Create and activate a virtual environment:
python -m venv .venv, thensource .venv/bin/activate(macOS/Linux) or.venv\Scripts\activate(Windows). - 3Install PySpark with
pip install pyspark. - 4Check Java with
java -version. If it is missing, install a supported JDK (for example from Adoptium) and try again. On Windows, Spark can also need Hadoop helper files for writing files; if you hit errors, use WSL or Google Colab instead. - 5Create the two CSV files below inside
spark-demo.
order_id,customer_name,item_id,qty,created_at
1,Asha,cappuccino,2,2026-10-01 09:15:00
2,Ravi,espresso,1,2026-10-01 09:40:00
3,Meera,croissant,2,2026-10-01 18:05:00
4,Asha,masala-chai,1,2026-10-02 08:30:00
5,Kiran,cold-brew,1,2026-10-02 17:45:00
6,Ravi,veg-sandwich,1,2026-10-02 19:10:00
7,Meera,filter-coffee,3,2026-10-03 08:55:00
8,Asha,banana-bread,2,2026-10-03 16:20:00One row per order line, using the Brew & Bean menu ids. Eight rows over three days.
item_id,name,category,price
espresso,Espresso,coffee,120
cappuccino,Cappuccino,coffee,180
cold-brew,Cold Brew,coffee,220
filter-coffee,Filter Coffee,coffee,80
masala-chai,Masala Chai,tea,90
croissant,Butter Croissant,bakery,150
banana-bread,Banana Bread,bakery,130
veg-sandwich,Veg Grilled Sandwich,food,160The Brew & Bean menu with prices in rupees.
Read the data
from pyspark.sql import SparkSession, functions as F
spark = (
SparkSession.builder
.appName("brew-and-bean")
.master("local[*]") # use all cores on this machine
.getOrCreate()
)
orders = spark.read.csv("orders.csv", header=True, inferSchema=True)
menu = spark.read.csv("menu.csv", header=True, inferSchema=True)
orders.printSchema()
orders.show(3, truncate=False)Save this as pipeline.py and keep adding to it. SparkSession is the entry point to everything in Spark.
root
|-- order_id: integer (nullable = true)
|-- customer_name: string (nullable = true)
|-- item_id: string (nullable = true)
|-- qty: integer (nullable = true)
|-- created_at: timestamp (nullable = true)
+--------+-------------+----------+---+-------------------+
|order_id|customer_name|item_id |qty|created_at |
+--------+-------------+----------+---+-------------------+
|1 |Asha |cappuccino|2 |2026-10-01 09:15:00|
|2 |Ravi |espresso |1 |2026-10-01 09:40:00|
|3 |Meera |croissant |2 |2026-10-01 18:05:00|
+--------+-------------+----------+---+-------------------+
only showing top 3 rowsDo not rely on inferSchema in production
inferSchema=True makes Spark read the whole file once to guess types. That is fine for a demo but slow and risky on big data, because one odd value can change a column's type. In real pipelines define the schema yourself with StructType, or read from Parquet, which stores the schema.
Transform: join and aggregate
The DataFrame API reads much like SQL. Each step returns a new DataFrame; DataFrames are never changed in place. Add this to pipeline.py.
lines = (
orders
.join(menu, on="item_id", how="inner")
.withColumn("order_date", F.to_date("created_at"))
.withColumn("revenue", F.col("qty") * F.col("price"))
)
by_category = (
lines.groupBy("category")
.agg(F.sum("revenue").alias("revenue"))
.orderBy(F.desc("revenue"))
)
by_category.show()
by_day = (
lines.groupBy("order_date")
.agg(
F.countDistinct("order_id").alias("orders"),
F.sum("revenue").alias("revenue"),
)
.orderBy("order_date")
)
by_day.show()Joins menu prices onto each order line, then totals revenue two ways. Nothing runs until show() is called.
+--------+-------+
|category|revenue|
+--------+-------+
| coffee| 940|
| bakery| 560|
| food| 160|
| tea| 90|
+--------+-------+
+----------+------+-------+
|order_date|orders|revenue|
+----------+------+-------+
|2026-10-01| 3| 780|
|2026-10-02| 3| 470|
|2026-10-03| 2| 500|
+----------+------+-------+Check one number by hand: Asha's two cappuccinos are 2 x 180 = 360, Ravi's espresso is 120 and Meera's two croissants are 2 x 150 = 300, so 1 October is 780. Always sanity-check a pipeline against a small case you can calculate yourself.
The same thing in SQL
Spark SQL and the DataFrame API compile to the same plan, so use whichever is clearer. Register a DataFrame as a temporary view and query it with SQL.
lines.createOrReplaceTempView("lines")
top_items = spark.sql("""
SELECT name, SUM(qty) AS units, SUM(revenue) AS revenue
FROM lines
GROUP BY name
ORDER BY revenue DESC
LIMIT 3
""")
top_items.show()Window functions, CTEs and everything from the Advanced SQL lesson work here too.
Write the result as Parquet
(
lines
.select("order_id", "customer_name", "item_id", "name", "category",
"qty", "price", "revenue", "created_at", "order_date")
.write
.mode("overwrite")
.partitionBy("order_date")
.parquet("out/order_lines")
)
# Read it back to prove it worked
back = spark.read.parquet("out/order_lines")
print(back.count()) # 8
back.filter(F.col("order_date") == "2026-10-02").show()
spark.stop()Writes one folder per order_date. Parquet is a compressed, columnar format; see the next lesson on file formats.
Look inside out/order_lines. You will see folders named order_date=2026-10-01, order_date=2026-10-02 and so on, each holding one or more .parquet files. This is partitioning: a query that filters on order_date reads only the matching folders and skips the rest. Choose a partition column that you filter on often and that has a modest number of distinct values (dates are the classic choice; a column like customer_name would create far too many tiny folders).
mode("overwrite") replaces the old output; other modes are append, ignore and error (the default, which fails if the output exists).
Look at the plan and the partitions
by_category.explain() # shows the physical plan
print(orders.rdd.getNumPartitions()) # how many pieces Spark split the data into
print(spark.conf.get("spark.sql.shuffle.partitions")) # default 200explain() prints the steps Spark will run. Look for Exchange: that word marks a shuffle. Run this before spark.stop().
The default of 200 shuffle partitions is sensible for big data but wasteful for our 8 rows. In a tiny local job you can set spark.conf.set("spark.sql.shuffle.partitions", "4") to make it faster. In recent Spark versions, Adaptive Query Execution (AQE) is on by default and adjusts such settings while the job runs.
Habits that keep Spark jobs fast
Practical rules
| Habit | Why |
|---|---|
| Select only the columns you need early | Parquet is columnar, so unused columns are never read |
| Filter early, especially on partition columns | Less data to shuffle |
Prefer built-in functions (F.col, F.sum) over Python UDFs | Built-ins run inside the JVM; Python UDFs move every row to Python and back |
Avoid collect() and toPandas() on big data | They pull everything into the driver's memory and can crash it |
| Avoid many tiny files | Thousands of small files make later reads slow; write fewer, larger files |
Broadcast small tables in joins: F.broadcast(menu) | Sends the small table to every worker so the big table is not shuffled |
lines = orders.join(F.broadcast(menu), on="item_id", how="inner")A broadcast join: menu has 8 rows, so copy it to every executor instead of shuffling orders.
I am learning PySpark. Explain this physical plan line by line in plain English. Tell me where the shuffles are and one change that could reduce them.
[paste the output of df.explain()]What to expect: A good answer names the Exchange (shuffle) steps, explains what each scan, filter and aggregate does, and suggests something specific such as filtering earlier, broadcasting a small table or selecting fewer columns.
How this comes up in interviews
Common questions: the difference between a transformation and an action; what a shuffle is and why it is expensive; narrow versus wide transformations; how to handle a skewed key (one key has far more rows than the others); when to use repartition versus coalesce (repartition can increase or rebalance partitions with a shuffle, coalesce only reduces them and avoids a full shuffle); and why Parquet is better than CSV for analytics. Be ready to explain lazy evaluation using the plan-then-execute idea above.
Try it: Extend the pipeline
Add two more outputs to pipeline.py: (1) the best-selling item by units on each day, and (2) the average revenue per order for each customer. Write the second result to out/customer_stats as Parquet. Then run explain() on it and count the Exchange steps.
Check yourself
- Spark is installed and
java -versionworks (or you are using Colab) pipeline.pyruns and prints the same numbers as above- You can name two transformations and two actions from memory
- You found the order_date folders in
out/order_lines - You can explain what a shuffle is
Key takeaways
- Spark splits data into partitions and processes them in parallel on many cores or machines.
- Transformations are lazy; actions such as show, count and write trigger the work, so Spark can optimise the whole plan.
- Shuffles (from groupBy, join, orderBy) are the main cost; fewer shuffles means faster jobs.
- DataFrames and Spark SQL give the same result; use whichever reads better.
- Write Parquet, partition by a column you filter on, and avoid tiny files, collect() on big data and Python UDFs where a built-in function exists.
Check yourself
Finishing the quiz marks the lesson as done.
1. You call df.filter(...).select(...) on a large DataFrame and nothing seems to happen. Why?
2. Which operation most likely causes a shuffle?
3. Why is df.toPandas() risky on a very large DataFrame?
4. You write order lines partitioned by order_date. What is the main benefit?
5. A small lookup table (the menu) is joined to a huge orders table. Which technique avoids shuffling the big table?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.