21. Semi and anti joins — filtering with a join

📖 Reading · 4 min
💡 Every code box below is live — edit it and hit Run.

Sometimes you don't want to combine two tables — you want to use one to filter the other. That's what semi and anti joins do. They return columns from the left side only; the right side is used purely as a membership test.

Left semi — "keep left rows that have a match"

# customers who have placed at least one order
customers.join(orders, "customer_id", "left_semi").select(
    "customer_id", "name"
).count()

A semi join is like filter(exists in other) — but it never duplicates left rows the way an inner join would, and it returns no columns from the right. It answers "which of these have a match?"

Left anti — "keep left rows that have NO match"

The opposite, and incredibly handy for finding gaps:

# customers who have never ordered — c021 and c022
customers.join(orders, "customer_id", "left_anti").select(
    "customer_id", "name", "country"
).show()

Point it the other way to find orphans — orders whose customer doesn't exist:

orders.dropDuplicates().join(customers, "customer_id", "left_anti").select(
    "order_id", "customer_id"
).show()

Why not just left-join and filter nulls?

You can — that's the previous lesson. But anti join says your intent directly ("rows with no match"), doesn't drag in the right-side columns, and lets Spark pick an efficient plan. For "does it exist / not exist" questions, reach for semi / anti.

Your turn

Find the customers who have never placed an order. Anti-join customers against orders on customer_id and return customer_id and name. Assign to result.

result = customers  # <- left_anti join orders on customer_id, select customer_id & name
result.show()
result = customers.join(orders, "customer_id", "left_anti").select(
    "customer_id", "name"
)