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.

Data Engineerpythonsparkpyspark

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

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

Make an ETL Pipeline IdempotentData projectSpeed Up a Slow SQL Report QueryData projectMake a CSV Importer Resilient to Bad DataData projectData roadmapStep by step to hiredData interview questionsSTAR answersAll Data projectsProjects hub

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 →