Spark ETL Pipeline Project
ETL means extract, transform, and load. A pipeline pulls raw data from a source, cleans and reshapes it, and stores the result where analysts can use it. The project below ties together reading, schemas, cleaning, joins, aggregation, quality checks, and partitioned output in one realistic job.
The Layman View
A juice factory receives fruit crates, washes and sorts the fruit, presses the juice, and bottles it. Each station depends on the one before. A data pipeline treats raw files like fruit crates and ends with clean, labelled tables.
The Business Goal
An online shop wants a daily report of revenue per country and product category. Two raw files arrive every day.
orders.csv customers.csv order_id, customer_id, product, customer_id, name, country category, quantity, price, order_date
Pipeline Overview
EXTRACT TRANSFORM LOAD
------- --------- ----
orders.csv ---\ clean --> join --> aggregate ---> Parquet report
customers.csv ----> | partitioned by date
quality checks
Step 1: Extract with Explicit Schemas
from pyspark.sql import SparkSession, functions as F
spark = SparkSession.builder.appName("daily_revenue").getOrCreate()
orders_schema = ("order_id STRING, customer_id STRING, product STRING, "
"category STRING, quantity INT, price DECIMAL(10,2), order_date DATE")
customers_schema = "customer_id STRING, name STRING, country STRING"
orders = spark.read.csv("/data/raw/orders.csv", header=True, schema=orders_schema)
customers = spark.read.csv("/data/raw/customers.csv", header=True, schema=customers_schema)
Step 2: Clean the Orders
Raw data holds duplicates, missing values, and impossible numbers. Remove them early so later steps run on trustworthy rows.
clean_orders = (orders
.dropDuplicates(["order_id"])
.filter(F.col("order_id").isNotNull() & F.col("customer_id").isNotNull())
.filter((F.col("quantity") > 0) & (F.col("price") > 0))
.withColumn("category", F.initcap(F.trim("category")))
.withColumn("revenue", F.col("quantity") * F.col("price")))
Step 3: Join Customer Details
The customer table is small, so a broadcast join avoids a large shuffle.
enriched = clean_orders.join(F.broadcast(customers), "customer_id", "left") \
.withColumn("country", F.coalesce("country", F.lit("Unknown")))
Step 4: Aggregate
report = (enriched
.groupBy("order_date", "country", "category")
.agg(F.sum("revenue").alias("total_revenue"),
F.countDistinct("order_id").alias("orders"),
F.sum("quantity").alias("units")))
Rows in clean_orders: one per order
|
groupBy(date, country, category)
|
order_date country category total_revenue orders units
2026-01-15 India Books 1,240.00 18 35
2026-01-15 India Toys 860.50 11 19
Step 5: Check Quality Before Loading
rows = report.count()
negative = report.filter(F.col("total_revenue") < 0).count()
unknown_share = enriched.filter(F.col("country") == "Unknown").count() / enriched.count()
assert rows > 0, "Report is empty"
assert negative == 0, "Negative revenue found"
assert unknown_share < 0.05, "Too many orders without a matching customer"
A failed check stops the job before bad numbers reach any dashboard.
Step 6: Load the Result
spark.conf.set("spark.sql.sources.partitionOverwriteMode", "dynamic")
(report.write
.mode("overwrite")
.partitionBy("order_date")
.parquet("/data/report/daily_revenue"))
Dynamic overwrite replaces only the dates present in the new data. A rerun of the same day gives identical output.
Project Layout
daily_revenue/ |-- job.py main script |-- schemas.py schema definitions |-- transforms.py clean, join, aggregate functions |-- tests/ unit tests with small sample data |-- job.conf Spark settings
Running the Job
spark-submit --master yarn --deploy-mode cluster --properties-file job.conf job.py
Ideas to Extend the Project
- Read the input from cloud storage and write the report to a Delta table.
- Add a window function to rank top products per country.
- Stream new orders from Kafka and update the report every few minutes.
- Schedule the job each morning and send an alert when a quality check fails.
Handling Late and Repeated Files
Source systems resend files and deliver some late. Two rules keep the pipeline steady. Track the processed file names or dates in a small control table, and make every run overwrite its own date partition. A resent file then replaces its earlier result and never doubles the totals.
Day 1 file arrives --> report for day 1 written Day 1 file resent --> report for day 1 overwritten with the same values
