Free Interactive Course · Spark + Scala

Joins

Combining two DataFrames on a key — the operation behind every enrichment, lookup, and star schema. All the join types (inner, left, right, full, semi, anti, cross), joining on one or many keys, broadcast joins for small tables, and the classic duplicate-column headache. The join types run live below.

The dataset

This module joins the two Foundations DataFrames — people (the dimension) and orders (the facts). Note that Noah (id 5) has no orders:

peopleid, name, country, age, signup_date
ordersid, user_id, product, category, amount, qty, status, ordered_at
11.1

Inner join — the default

The default join keeps only rows where the key matches on both sides. In Scala you pass the two DataFrames and a join condition; here we match each order's user_id to a person's id to attach the buyer's name.

Signature
def join(right: Dataset[_], joinExprs: Column): DataFrame               // defaults to inner
def join(right: Dataset[_], joinExprs: Column, joinType: String): DataFrame
// joinType: inner | left | right | full | left_semi | left_anti | cross
Scala
people.join(orders, orders("user_id") === people("id"))
  .select($"name", $"product", $"amount")
  .orderBy($"name")
  .show(false)
Run the equivalent Spark SQL — edit it and press Run
SQL
⌘/Ctrl + Enter
An inner join drops unmatched rows on both sides — Noah has no orders, so he vanishes, and any order with an unknown user_id would too. That silent row loss is the #1 join surprise; if either side must be preserved, reach for a left/right/full outer join. When the key columns share a name you can shortcut with people.join(orders, Seq("id")), which also collapses the duplicate key column.
11.2

left, right & full outer joins

Outer joins preserve unmatched rows, filling the other side with nulls. left keeps every left row, right keeps every right row, and full (outer) keeps everything from both. A left join is how you keep all customers even those who never ordered.

Signature
people.join(orders, cond, "left")     // all people, nulls where no order
people.join(orders, cond, "right")    // all orders, nulls where no person
people.join(orders, cond, "full")     // everything from both sides
Scala
people.join(orders, orders("user_id") === people("id"), "left")
  .select($"name", $"product", $"amount")
  .orderBy($"name")
  .show(false)
Run the equivalent Spark SQL — edit it and press Run
SQL
⌘/Ctrl + Enter
Noah now survives with null product/amount — that null is your signal for "no match". To find only the unmatched rows (customers who never ordered), add WHERE o.user_id IS NULL after a left join — the classic "anti-join via left join" pattern (though left_anti below does it directly). A full outer join is the tool for reconciling two datasets and spotting rows present in one but not the other.
11.3

left_semi & left_anti — filtering joins

These joins filter the left side by existence and return only left columns — no columns from the right, no row multiplication. left_semi keeps left rows that have a match; left_anti keeps left rows that don't. Think of them as IN and NOT IN against another table.

Signature
people.join(orders, cond, "left_semi")   // people who HAVE ordered (like WHERE EXISTS)
people.join(orders, cond, "left_anti")   // people who have NOT ordered (like NOT EXISTS)
Scala
// customers who have never placed an order
people.join(orders, orders("user_id") === people("id"), "left_anti")
  .select($"name", $"country")
  .show(false)
Run the equivalent Spark SQL — edit it and press Run
SQL
⌘/Ctrl + Enter
The big win over a regular join + distinct: a semi/anti join never duplicates left rows even when the right side has many matches, and it can stop scanning as soon as it finds one match. Use left_semi instead of join(...).select(leftCols).distinct(), and left_anti instead of NOT IN (which also mishandles nulls). Swap the earlier NOT EXISTS for EXISTS in the editor to see the semi-join result.
11.4

Joining on multiple keys & expressions

The join condition is just a Column expression, so you can combine several equalities with &&, or add range conditions — not only equality. Here we enrich orders with the buyer's name but only for high-value orders (amount ≥ 25), all in the join condition.

Signature
// multiple keys:
a.join(b, a("k1") === b("k1") && a("k2") === b("k2"))
// non-equi condition (range / inequality) — a theta join:
a.join(b, a("id") === b("fk") && b("amount") >= 25)
Scala
people.join(
  orders,
  orders("user_id") === people("id") && orders("amount") >= 25
).select($"name", $"product", $"amount")
 .orderBy($"amount".desc)
 .show(false)
Run the equivalent Spark SQL — edit it and press Run
SQL
⌘/Ctrl + Enter
Putting a filter in the join condition versus in a WHERE gives the same result for an inner join — but for an outer join they differ crucially: a condition in the ON clause still preserves unmatched left rows, while the same condition in WHERE silently turns your left join back into an inner join by filtering out the null rows. Non-equi (range) joins can't use hash joins and are far more expensive — keep at least one equality key when you can.
11.5

Broadcast joins — small side, no shuffle

When one side is small (a lookup or dimension table), wrap it in broadcast(...). Spark ships a full copy to every executor so the big side never has to shuffle across the network — often a 10× speedup. This is the single most impactful manual join optimization.

Signature
import org.apache.spark.sql.functions.broadcast
bigDf.join(broadcast(smallDf), "id")   // hint: broadcast the small side
// auto-broadcast kicks in under spark.sql.autoBroadcastJoinThreshold (default 10MB)
Scala
import org.apache.spark.sql.functions.broadcast

// people is tiny → broadcast it to avoid shuffling orders
orders.join(broadcast(people), orders("user_id") === people("id"))
  .select($"product", $"name")
  .orderBy($"product")
  .show(false)
Run the equivalent Spark SQL — edit it and press Run
SQL
⌘/Ctrl + Enter
Result is identical to a normal join — broadcast only changes the physical strategy (confirm it in .explain(): look for BroadcastHashJoin). Spark auto-broadcasts any side under spark.sql.autoBroadcastJoinThreshold (10MB default), but the hint forces it when Spark's size estimate is wrong. Don't broadcast a large table — it OOMs the driver collecting it and every executor holding it. See the repartition vs coalesce post for shuffle-side tuning.
11.6

Resolving duplicate columns after a join

Join two DataFrames that share a column name and the result has two columns with the same name — referencing it then throws an ambiguity error. Three ways out, in order of preference.

Signature
// 1. join on the Seq of names → Spark keeps ONE key column
a.join(b, Seq("id"))
// 2. rename before the join
b.withColumnRenamed("id", "b_id")
// 3. qualify with the source DataFrame after the join
joined.select(a("id"), b("amount"))
Scala
// BAD: both frames have `id` → ambiguous reference
// people.join(orders, people("id") === orders("id"))  // then $"id" is ambiguous

// GOOD: join on the shared name so only one `id` survives
people.join(orders.withColumnRenamed("id", "order_id"),
            orders("user_id") === people("id"))
  .select($"id", $"name", $"order_id", $"product")
  .orderBy($"order_id").show(3, false)
Run the equivalent Spark SQL — edit it and press Run
SQL
⌘/Ctrl + Enter
The cleanest fix when the key name matches is join(other, Seq("id")) — Spark coalesces the two key columns into one. When the clash is on a non-key column (both have status, say), qualify with the originating DataFrame handle — joined.select(people("status"), orders("status").as("order_status")) — since after the join a bare $"status" can't tell them apart.
P
PriyaFounder · Kettle & Co.now
Who's quiet? 🤔 — give me every customer who has never placed an order. Just their name and country.
🎯 Your mission Return the name and country of every person in people with no matching row in orders (a left-anti join).
SQL
⌘/Ctrl + Enter

Frequently asked questions

What are the join types in Spark?
Spark supports inner (default, matches only), left/right/full outer (preserve unmatched rows on one or both sides with nulls), left_semi (keep left rows that have a match, return left columns only), left_anti (keep left rows with no match), and cross (Cartesian product). You pass the type as the third argument to join.
What is the difference between a semi join and an inner join in Spark?
An inner join returns columns from both sides and duplicates left rows when the right side has multiple matches. A left_semi join returns only the left columns and never duplicates rows — it just filters the left DataFrame to rows that have at least one match, like WHERE EXISTS.
What is a broadcast join in Spark and when should I use one?
A broadcast join ships a full copy of the smaller DataFrame to every executor so the large side never shuffles, which is dramatically faster. Use broadcast(smallDf) when one side fits comfortably in memory (Spark auto-broadcasts under 10MB by default). Never broadcast a large table — it will OOM the driver and executors.
How do I fix ambiguous column names after a join in Spark?
Either join on the shared column name with a.join(b, Seq("id")) so Spark keeps a single key column, rename one side's column before joining with withColumnRenamed, or qualify the column after the join using the source DataFrame handle, e.g. joined.select(a("id"), b("amount")).

Ready for the full data stack?

These Spark functions are one piece. When you want pipelines, Kafka, lakehouse table formats, and a full end-to-end project, the Data Engineering course is free too.

Explore Data Engineering →

Comments

0

Join the conversation. Sign in to leave a comment — questions and feedback welcome.