PySpark joins, groupBy and writing Parquet without losing or copying rows
A PySpark join returns 1 row for every pair of rows whose keys match. An inner join drops the rows with no match, a left join keeps them with nulls, and a left_anti join returns only them. A key that appears twice on the other side copies the row, so check that side's key is unique before you join, and count the rows after.
Module 2: PySpark DataFrames
At the end of this part you can:
- Join DataFrames with inner, left and left_anti, and count the rows each returns
- Find the rows a join did not match, and the keys that copy rows
- Add up a DataFrame with groupBy and agg, in Python or in SQL
- Write the result with a save mode that is safe to run again
Take this module into your AI
Open in ChatGPT (new tab)Open in Claude (new tab)A join is where a pipeline most often goes wrong without telling anyone. It can drop rows, so a total comes out short, or copy them, so a total comes out high, and both look like a normal table. This part joins the current orders from part 2 to the customers, adds them up and writes the result.
Start from the current orders
If you are starting here, this block rebuilds what parts 1 and 2 made, from the bronze copy part 1 saved:
from pyspark.sql import SparkSession, Window
from pyspark.sql import functions as F
spark = SparkSession.builder.appName("orders").getOrCreate()
RAW, LAKE = "raw", "lake"
good = spark.read.parquet(f"{LAKE}/bronze_orders").filter(F.col("_bad").isNull()).drop("_bad")
newest_first = Window.partitionBy("order_id").orderBy(F.col("updated_at").desc())
current = (
good.dropDuplicates()
.withColumn("version", F.row_number().over(newest_first))
.filter(F.col("version") == 1)
.drop("version")
)
Join, and count what comes out
Add each customer's name and country. A left join keeps every order, with nulls where no customer matches:
customers = spark.read.csv(
f"{RAW}/customers.csv", header=True, schema="customer_id STRING, name STRING, country STRING"
)
with_customer = current.join(customers, on="customer_id", how="left")
with_customer.select("order_id", "status", "amount", "name", "country").orderBy("order_id").show()
+--------+---------+------+----+-------+
|order_id| status|amount|name|country|
+--------+---------+------+----+-------+
| 1001| shipped| 25.50| Ana| PT|
| 1002|cancelled| 12.00| Ben| IE|
| 1004| placed| 40.00| Ana| PT|
| 1005| placed| 8.75|NULL| NULL|
+--------+---------+------+----+-------+
Order 1005 belongs to customer C09, who is not in the file. An inner join, which is what you get when you leave out how, would have dropped it, and the revenue would be short by 8.75 with nothing to show why. To find such rows on purpose, use a left_anti join. It returns the left rows that have no match, and nothing else:
current.join(customers, on="customer_id", how="left_anti").select("order_id", "customer_id").show()
+--------+-----------+
|order_id|customer_id|
+--------+-----------+
| 1005| C09|
+--------+-----------+
Joins can also add rows, because a join returns 1 row for every pair of rows whose keys match. If a customer appears twice in the customers file, each of their orders comes out twice. Switch the demo's file to C01 twice and watch Ana's orders double.
current.join(customers, on="customer_id", how="left")
customers
- C01 Ana PT
- C02 Ben IE
- C03 Chen DE
result
| order_id | status | amount | name | |
|---|---|---|---|---|
| 1001 | shipped | 25.50 | Ana | |
| 1002 | cancelled | 12.00 | Ben | |
| 1004 | placed | 40.00 | Ana | |
| 1005 | placed | 8.75 | NULL | No match |
rows for 4 orders
Revenue Right
left keeps every order: 4 rows, 1005 with nulls where no customer matched, and revenue of 74.25.
The module's 4 current orders and its customers file. Revenue leaves out the cancelled order, as the module's daily totals do.
So before you join, check the side that should have 1 row per key: customers.count() == customers.select("customer_id").distinct().count(). Passing on="customer_id" as a name, rather than a condition such as current.customer_id == customers.customer_id, also keeps a single customer_id column in the result instead of 2 with the same name.
Add it up
Revenue per day and country, leaving out cancelled orders:
daily = (
with_customer.filter(F.col("status") != "cancelled")
.groupBy(F.to_date("ordered_at").alias("day"), "country")
.agg(F.count("*").alias("orders"), F.sum("amount").alias("revenue"))
.orderBy("day", "country")
)
daily.show()
+----------+-------+------+-------+
| day|country|orders|revenue|
+----------+-------+------+-------+
|2026-10-01| PT| 1| 25.50|
|2026-10-02| PT| 1| 40.00|
|2026-10-03| NULL| 1| 8.75|
+----------+-------+------+-------+
Each order counts once, on the day it was made, in its current state. That is the answer the raw file could not give.
If you call daily.explain(), 2 words in the plan are worth knowing now. Exchange is a shuffle: Spark moving rows between machines so that all the rows of 1 order, or 1 day, end up together, which is the costly part of most jobs. BroadcastHashJoin means Spark sent the small customers table to every worker instead of shuffling the orders, which it does by itself for tables under 10 MB. Module 3 reads these plans in the Spark UI and sizes the shuffles.
The same query can be SQL. Name a DataFrame as a temporary view with with_customer.createOrReplaceTempView("orders_now"), and spark.sql("SELECT ... FROM orders_now GROUP BY ...") returns a DataFrame like any other. Both become the same plan on the same engine, so choose whichever reads better for the step.
Write it so it can run again
Writing is an action, so this is where the whole plan runs:
(
current.withColumn("order_date", F.to_date("ordered_at"))
.write.mode("overwrite")
.partitionBy("order_date")
.parquet(f"{LAKE}/orders")
)
mode("overwrite") replaces what was there, which makes the step safe to run again: run it twice and you get the same result, not the orders twice. That property, called an idempotent pipelineIdempotent Pipelines and Exactly-OnceA pipeline step is idempotent when running it twice gives the same result as running it once. That is what makes retries safe, and it is how systems that deliver a message more than once still count it exactly once., is what lets a scheduler retry a failed run without anyone checking it by hand. The default mode, errorifexists, fails if the folder exists, and append adds to it, which duplicates every row on a rerun.
partitionBy("order_date") writes 1 folder per day, order_date=2026-10-01 and so on, so a query for 1 day reads 1 folder. It helps only when each partition is large. Databricks' own advice is not to partition a table under 1 TB and to give each partition at least 1 GB; with less, thousands of tiny files make every query slower. On Databricks you will use liquid clustering instead, in module 4.
Check yourself
1 questions, each in a new situation
1. You join 1,000,000 orders to a customers table on customer_id with a left join, and the result has 1,000,340 rows. Every order has exactly 1 customer_id. What happened?
Not quite. A join returns 1 row for every pair of rows whose keys match, so a key that appears twice on the right copies the order. A left join keeps every row of the left side once when there is no match, with nulls, and never adds rows from the right side alone. Shuffles move rows but never duplicate them. Check the right side's key is unique before the join, with a count of rows against a count of distinct keys.
Questions
- What is the difference between an inner join and a left join in PySpark?
- An inner join keeps only the rows that found a match on both sides. A left join keeps every row of the left DataFrame, with nulls in the right side's columns where there was no match. inner is the default when you do not pass how, so a row with no match disappears without an error.
- How do I find the rows that did not match in a PySpark join?
- Use a left_anti join, left.join(right, on="key", how="left_anti"). It returns the rows of the left DataFrame that have no match on the right, with only the left side's columns. Count them in every pipeline run, so a row that stops matching is seen rather than lost.
- What is the difference between overwrite and append in PySpark write?
- mode("overwrite") replaces what is at the path or table, so running the step twice gives the same result. mode("append") adds the new rows to what is there, so a rerun adds every row again. The default, errorifexists, fails if anything is already there.
- Is PySpark the same as Spark SQL?
- They are 2 ways of writing the same queries. A DataFrame built with PySpark and the same query written in Spark SQL become the same plan and run on the same engine at the same speed, so you can mix them, for example by naming a DataFrame as a view with createOrReplaceTempView and querying it with spark.sql.
Sources