
You know left and inner joins. But the secret weapons of SQL pros are the Anti-Join and Semi-Join. Polars supports these natively, and they are blazing fast.
⚡ Quick Fix: Polars Anti-Join and Semi-Join
.join(how=”anti”) returns rows from the left table with no match on the right.
.join(how=”semi”) returns only left-table rows that do have a match — no right-table columns attached.
# Anti-Join: users who never ordered no_purchase = users.join(orders, on="user_id", how="anti") # Semi-Join: users who did order, left columns only paying = users.join(orders, on="user_id", how="semi")
Both outperform .filter(pl.col(“user_id”).is_in(…)) at scale — the full breakdown is below.
The Setup
import polars as pl
# All signed-up users
users = pl.DataFrame({"user_id": [1, 2, 3, 4], "name": ["Alice", "Bob", "Charlie", "David"]})
# Users who made a purchase
orders = pl.DataFrame({"user_id": [2, 3], "amount": [100, 50]})1. The Anti-Join (how="anti")
Goal: “Find users who have NOT made a purchase.” An Anti-Join keeps rows from the left table that do not exist in the right table.
# Find users NOT in orders no_purchase = users.join(orders, on="user_id", how="anti") print(no_purchase)
Output:
shape: (2, 2) ┌─────────┬───────┐ │ user_id ┆ name │ │ --- ┆ --- │ │ i64 ┆ str │ ╞═════════╪═══════╡ │ 1 ┆ Alice │ │ 4 ┆ David │ └─────────┴───────┘
2. The Semi-Join (how="semi")
Goal: “Find users who HAVE made a purchase (but I don’t care about the purchase details).” A Semi-Join works like an Inner Join, but it only keeps columns from the left table. It’s a filter, not a merge.
# Filter users based on presence in orders paying_customers = users.join(orders, on="user_id", how="semi") print(paying_customers)
Output:
shape: (2, 2) ┌─────────┬─────────┐ │ user_id ┆ name │ │ --- ┆ --- │ │ i64 ┆ str │ ╞═════════╪═════════╡ │ 2 ┆ Bob │ │ 3 ┆ Charlie │ └─────────┴─────────┘
These are significantly faster and cleaner than using .filter(pl.col("id").is_in(...)).
Key Takeaways
- SQL professionals utilise Anti-Joins and Semi-Joins, which are supported natively by Polars.
- The Anti-Join (how=’anti’) helps find users who have not made a purchase by excluding rows from the left table that exist in the right one.
- The Semi-Join (how=’semi’) identifies users who have made a purchase, keeping only columns from the left table without merging details.
- Both joins are significantly faster and cleaner than using the filter method with .filter(pl.col(‘id’).is_in(…)).
Polars Anti-Join and Semi-Join: The Production-Grade Pattern
Anti-joins and semi-joins belong in every data pipeline that filters on membership. The critical distinction to lock in: a semi-join is not an inner join with dropped columns — it deduplicates by design, so duplicate user_id values in orders never inflate your result set the way an inner join would. An anti-join carries the same guarantee in the opposite direction. Both joins operate on the left DataFrame’s columns exclusively, which keeps memory overhead low when the right table is wide. In production, reach for these instead of .is_in() any time the right-side dataset grows beyond a few thousand rows — Polars executes the join path through its query optimizer, while .is_in() builds a full set in Python memory first.





