Lab 1 — Build a PySpark batch pipeline
Objective
By the end of this lab you will have a runnable PySpark script,
batch_pipeline.py, that reads a raw orders CSV, cleans and deduplicates the
records, aggregates revenue and order count per category, and writes the result
as Parquet partitioned by category. It is the ingest-clean-aggregate-write core
of every batch job in this school, built end to end and verified against a known
answer. It runs on your own machine in Spark local mode — a single-process
Spark that behaves like a cluster of one — so it costs nothing.
Skills exercised:
- Ingesting CSV with an explicit schema instead of trusting inference
- Cleaning with narrow filters and deduplicating with a window function
- Aggregating with
groupByand writing partitioned Parquet - Verifying a pipeline's output against an expected result
Prerequisites and setup
Prerequisites: the three lessons in this module — Spark's execution model, DataFrame transformations and actions, and Partitions, shuffles, and performance. You should also have finished DE-103 Python for Data Engineering.
Setup: local mode needs a Java runtime (Spark runs on the JVM) and Python
3.10 or newer. Install a JDK and the pyspark package, then verify both. Create a
working folder for the lab.
- macOS / Linux
- Windows (PowerShell)
java -version
python3 -m venv .venv && source .venv/bin/activate
pip install "pyspark==3.5.1"
mkdir de202-batch-lab && cd de202-batch-lab
java -version
python -m venv .venv; .venv\Scripts\Activate.ps1
pip install "pyspark==3.5.1"
mkdir de202-batch-lab; cd de202-batch-lab
The java -version command should print a version line such as
openjdk version "17.0.10". If Java is not found, install a JDK (version 11 or 17) before continuing; PySpark cannot start without one. The pip install line
pulls in PySpark and its bundled Spark. Use python3 on macOS/Linux and python
on Windows for the steps below.
Spark local mode starts a fresh single-process Spark each time you run the script.
If you close your terminal, re-activate the virtualenv and cd de202-batch-lab
before running again; your files are still there.
This lab pins pyspark==3.5.1 so the explain() output and Parquet behavior
match what you see here. A newer Spark will still run the pipeline, but plan text
and defaults can differ.
Step 1 — Create the raw orders and start a Spark session
Goal: create the raw input file and confirm PySpark can start a local session.
Save this exactly as raw_orders.csv:
order_id,event_time,customer_id,category,amount,status
1001,2026-03-01T09:00:00,c-01,books,20.00,completed
1002,2026-03-01T09:05:00,c-02,electronics,300.00,completed
1003,2026-03-01T09:10:00,c-03,books,,completed
1004,2026-03-01T09:15:00,c-01,electronics,150.00,cancelled
1005,2026-03-01T09:20:00,c-04,home,45.50,completed
1002,2026-03-01T10:00:00,c-02,electronics,320.00,completed
1006,2026-03-01T09:30:00,c-05,home,-10.00,completed
1007,2026-03-01T09:35:00,c-06,books,15.00,completed
1008,2026-03-01T09:40:00,c-07,electronics,500.00,completed
1009,2026-03-01T09:45:00,c-08,home,60.00,completed
1010,2026-03-01T09:50:00,c-09,books,25.00,completed
1005,2026-03-01T08:00:00,c-04,home,40.00,completed
This file carries defects on purpose: 1003 has a missing amount, 1004 is
cancelled, 1006 has a negative amount, and 1002 and 1005 each appear
twice with different timestamps. Your pipeline will drop the bad rows and keep the
latest version of each duplicate.
Now create batch_pipeline.py with just enough to start a session:
from pyspark.sql import SparkSession
spark = (
SparkSession.builder
.appName("de202-batch-pipeline")
.master("local[2]")
.getOrCreate()
)
spark.sparkContext.setLogLevel("WARN")
print("spark version:", spark.version)
Checkpoint: run the script and confirm Spark starts.
python3 batch_pipeline.py
You should see spark version: 3.5.1 after a few seconds of startup logging. If
you see JAVA_HOME is not set or Unable to locate a Java Runtime, install a JDK
and re-run — see the Troubleshooting table.
Step 2 — Ingest with an explicit schema
Goal: read the CSV into a DataFrame using a schema you declare, so a bad column never gets silently guessed.
Replace the print line in batch_pipeline.py with the schema and read:
from pyspark.sql.types import (
StructType,
StructField,
StringType,
DoubleType,
)
schema = StructType(
[
StructField("order_id", StringType(), nullable=False),
StructField("event_time", StringType(), nullable=False),
StructField("customer_id", StringType(), nullable=True),
StructField("category", StringType(), nullable=False),
StructField("amount", DoubleType(), nullable=True),
StructField("status", StringType(), nullable=False),
]
)
raw = spark.read.csv("raw_orders.csv", header=True, schema=schema)
print("ingested rows:", raw.count())
Declaring the schema does two things at once: it skips Spark's inference pass over
the file, and it turns the empty amount on row 1003 into a proper null
Double rather than the string "".
Checkpoint: run it. You should see ingested rows: 12 — every raw row,
defects included. If you see 11, your CSV is missing a line; re-save all twelve
data rows from Step 1.
Step 3 — Clean with narrow filters
Goal: keep only completed orders with a real, positive amount, using the narrow transformations from Lesson 2.
Add the cleaning stage below the read:
from pyspark.sql import functions as F
cleaned = (
raw
.filter(F.col("status") == "completed")
.filter(F.col("amount").isNotNull())
.filter(F.col("amount") > 0)
)
print("rows after cleaning:", cleaned.count())
Each filter is narrow — it drops rows without moving any data across the
cluster. Together they remove the cancelled order (1004), the missing-amount
order (1003), and the negative-amount order (1006).
Checkpoint: run it. You should see rows after cleaning: 9. The count is 9,
not 7, because the two duplicate orders (1002 and 1005) are both still
present — you remove those next. If you see 12, one of the filters is not being
applied; check that all three .filter calls are chained onto raw.
Step 4 — Deduplicate to the latest row per order
Goal: keep exactly one row per order_id — the one with the most recent
event_time — using a window function.
Add the deduplication stage:
from pyspark.sql.window import Window
latest_first = Window.partitionBy("order_id").orderBy(
F.col("event_time_ts").desc()
)
deduped = (
cleaned
.withColumn("event_time_ts", F.to_timestamp("event_time"))
.withColumn("row_rank", F.row_number().over(latest_first))
.filter(F.col("row_rank") == 1)
.drop("row_rank", "event_time_ts")
)
print("rows after dedup:", deduped.count())
deduped.filter(F.col("order_id").isin("1002", "1005")).select(
"order_id", "amount"
).show()
The window ranks each order's rows newest-first, and keeping row_rank == 1
selects the latest. For 1002 that keeps the 10:00 row with amount 320.0 over
the 09:05 row; for 1005 it keeps the 09:20 row with 45.5 over the 08:00
row.
Checkpoint: run it. You should see rows after dedup: 7, then a table showing
1002 with amount 320.0 and 1005 with amount 45.5:
+--------+------+
|order_id|amount|
+--------+------+
| 1002| 320.0|
| 1005| 45.5|
+--------+------+
If 1002 shows 300.0, your window is ordering ascending — make sure the
orderBy uses .desc().
Step 5 — Aggregate revenue per category
Goal: collapse the clean orders into one row per category with total revenue and an order count — the wide step of the pipeline.
Add the aggregation:
summary = (
deduped
.groupBy("category")
.agg(
F.round(F.sum("amount"), 2).alias("revenue"),
F.count("*").alias("orders"),
)
.orderBy("category")
)
summary.show()
This groupBy is the one wide transformation in the pipeline: Spark shuffles the
seven clean rows so each category's rows meet, then sums within each group. Every
step before it was narrow, so the shuffle moves only these few rows.
Checkpoint: run it. You should see exactly three category rows:
+-----------+-------+------+
| category|revenue|orders|
+-----------+-------+------+
| books| 60.0| 3|
|electronics| 820.0| 2|
| home| 105.5| 2|
+-----------+-------+------+
If electronics shows 1120.0 with 3 orders, the duplicate 1002 was not
removed — revisit the window in Step 4.
Step 6 — Write partitioned Parquet
Goal: write the summary to Parquet, partitioned by category, so a reader can prune to one category without scanning the rest.
Add the write:
(
summary.write
.mode("overwrite")
.partitionBy("category")
.parquet("out/category_summary")
)
print("wrote out/category_summary")
partitionBy("category") lays the output out as one subfolder per category value.
The category column is encoded in the folder name rather than stored in the
files, and Spark restores it when the dataset is read back.
Checkpoint: run the script, then list the output folder.
python3 batch_pipeline.py
ls out/category_summary
You should see three partition folders — category=books, category=electronics,
and category=home — alongside a _SUCCESS marker file. On Windows PowerShell,
use Get-ChildItem out/category_summary instead of ls. If the folder is
missing, confirm the write block ran without an error above it.
Validation
Verify the whole build against the objective: correct partitioned Parquet, read
back independently of the pipeline that wrote it. Save this as validate.py:
from pyspark.sql import SparkSession
spark = (
SparkSession.builder
.appName("de202-validate")
.master("local[2]")
.getOrCreate()
)
spark.sparkContext.setLogLevel("WARN")
result = spark.read.parquet("out/category_summary")
rows = {
r["category"]: (round(r["revenue"], 2), r["orders"])
for r in result.collect()
}
expected = {
"books": (60.0, 3),
"electronics": (820.0, 2),
"home": (105.5, 2),
}
total_revenue = round(sum(rev for rev, _ in rows.values()), 2)
total_orders = sum(cnt for _, cnt in rows.values())
if rows == expected and total_revenue == 985.5 and total_orders == 7:
print(
"PASSED — 3 categories, revenue 985.50 across 7 clean orders"
)
else:
print("FAILED — got", rows)
spark.stop()
Run it:
python3 validate.py
Checkpoint: the command prints PASSED — 3 categories, revenue 985.50 across 7 clean orders. If it prints FAILED, compare the printed dictionary against the
expected three categories: books should total 60.0 over 3 orders,
electronics 820.0 over 2, and home 105.5 over 2. Re-run
python3 batch_pipeline.py first if you changed the pipeline after the last
write.
Teardown
Nothing here is billable — the whole lab runs on your machine. To reclaim space,
remove the output and working folder with rm -rf out de202-batch-lab
(macOS/Linux) or Remove-Item -Recurse out, de202-batch-lab (Windows). Keep the
folder if you want batch_pipeline.py as a reference for the course's Spark Forge
challenges. Deactivate the virtualenv with deactivate when you are done.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Unable to locate a Java Runtime or JAVA_HOME is not set | No JDK installed or not on the path | Install a JDK 11 or 17 and re-run; PySpark needs a JVM |
ingested rows: 11 in Step 2 | A CSV data row is missing | Re-save all twelve data rows from Step 1 exactly |
rows after cleaning: 12 in Step 3 | A filter is not chained onto raw | Confirm all three .filter calls are applied in one chain |
1002 shows amount 300.0 in Step 4 | Window orderBy is ascending | Order the window by event_time_ts with .desc() |
electronics shows 1120.0 in Step 5 | Duplicate 1002 was not removed | Re-check the row_number filter keeps row_rank == 1 |
Validation prints FAILED | Stale output from an earlier run | Re-run python3 batch_pipeline.py, then python3 validate.py |