PySpark Join Types: When to Use Left vs Inner
An inner join silently drops any row that has no match on the other side. If you want every row in the left DataFrame to appear in the output - with NULLs where the right side has no data - you need a left join. This is one of the most common silent data bugs in PySpark pipelines.
Why inner join drops rows
df.join(other, on, how) defaults how to "inner". An inner join keeps only
rows where the key exists in both DataFrames. Any row in the left DataFrame
whose key is absent from the right DataFrame is silently discarded.
This creates a class of bug that is especially hard to catch: the job succeeds, the output file exists, but the row count is quietly wrong.
import duckdb.experimental.spark.all as F
from duckdb.experimental.spark.all import SparkSession
spark = SparkSession.builder.getOrCreate()
orders = spark.read.csv("orders.csv", header=True)
customers = spark.read.csv("customers.csv", header=True)
# BUG: drops orders whose customer_id is not in customers
enriched = orders.join(customers, "cust_id") # how="inner" is the default
print(enriched.count()) # fewer rows than orders - silent data loss
Fix: switch to a left join
Pass "left" as the third argument. The left DataFrame (orders) is preserved
in full; columns from the right side (customers) are NULL where there is no
matching key.
# FIXED: every order row appears; deleted customers get NULL name/email
enriched = orders.join(customers, "cust_id", "left")
print(enriched.count()) # same count as orders - no rows lost
The fix is a single argument, but finding it requires knowing to check row counts before and after every join.
The four PySpark join types you need to know
how |
Keeps left rows with no match? | Keeps right rows with no match? |
|---|---|---|
"inner" (default) |
No | No |
"left" |
Yes - NULLs in right cols | No |
"right" |
No | Yes - NULLs in left cols |
"outer" |
Yes | Yes |
"semi" |
Yes (right cols dropped) | No |
"anti" |
Yes (only non-matching) | No |
Always check row counts around joins
The cheapest safety net is an assertion immediately after any join you expect to preserve cardinality:
before = orders.count()
enriched = orders.join(customers, "cust_id", "left")
after = enriched.count()
assert before == after, f"Row count changed: {before} -> {after}"
In production pipelines, dbt's relationships test and Great Expectations'
expect_table_row_count_to_equal serve the same purpose - they catch orphaned
foreign keys and unexpected row drops before dashboards go wrong.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Spotting silent row drops caused by a default inner join
- Switching to a left join so every left-side row is preserved with NULLs
- Adding row-count assertions to catch cardinality changes after joins
FAQ
What is the difference between a PySpark inner join and left join?
An inner join keeps only rows where the key exists in both DataFrames - unmatched rows are dropped. A left join keeps every row from the left DataFrame and fills right-side columns with NULL where there is no match.
Why is my PySpark join dropping rows?
The default join type is inner, which silently discards any row whose key has no match on the other side. Check your row count before and after the join; if it drops, switch to a left join or investigate which keys are missing.
How do I specify the join type in PySpark?
Pass the join type as the third argument to df.join(): df.join(other, 'key', 'left'). Valid values include 'inner' (default), 'left', 'right', 'outer', 'semi', and 'anti'.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →