Joining Data

Reviewed & published by Brayan K

A join combines two tables by matching rows on a shared key column — the way you bring related data, split across separate tables, back together for analysis.

Part of the free R course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

By the end of this lesson you'll use inner_join() and left_join() to combine tables, understand full_join() and anti_join(), and chain a join into a grouped summary.

What You'll Learn in This Lesson

1️⃣ inner_join(): Only Matches

inner_join(a, b, by = "key") keeps only rows whose key appears in both tables. Rows without a partner on either side are dropped.

library(dplyr)

# Two tables sharing a key column "id".
customers <- data.frame(
  id = c(1, 2, 3),
  name = c("Ada", "Bob", "Cara")
)
orders <- data.frame(
  id = c(1, 1, 2, 4),
  item = c("pen", "pad", "ink", "mug")
)

# inner_join keeps only rows with a match in BOTH tables (id 1 and 2).
inner_join(customers, orders, by = "id")
#   id name item
# 1  1  Ada  pen
# 2  1  Ada  pad
# 3  2  Bob  ink

2️⃣ left_join(): Keep the Left Table

left_join() keeps every row of the left table and attaches matching data from the right, filling NA where there's no match. It's the most common join because it never loses your main records.

library(dplyr)

customers <- data.frame(
  id = c(1, 2, 3),
  name = c("Ada", "Bob", "Cara")
)
orders <- data.frame(
  id = c(1, 1, 2),
  item = c("pen", "pad", "ink")
)

# left_join keeps ALL customers; non-matching ones get NA for order columns.
left_join(customers, orders, by = "id")
#   id name item
# 1  1  Ada  pen
# 2  1  Ada  pad
# 3  2  Bob  ink
# 4  3 Cara <NA>   <- Cara has no orders

3️⃣ full_join() and anti_join()

full_join() keeps all rows from both tables; anti_join() is a filtering join that returns rows of the first table with no match in the second — ideal for "what's missing" questions.

library(dplyr)

a <- data.frame(id = c(1, 2), x = c("a1", "a2"))
b <- data.frame(id = c(2, 3), y = c("b2", "b3"))

# full_join keeps EVERYTHING from both, filling gaps with NA.
full_join(a, b, by = "id")
#   id    x    y
# 1  1   a1 <NA>
# 2  2   a2   b2
# 3  3 <NA>   b3

# anti_join keeps rows of 'a' with NO match in 'b' (great for "what's missing").
anti_join(a, b, by = "id")     # -> only id 1

Your turn. Fill in the ___ blank, run it, and compare with the expected output.

library(dplyr)

# 🎯 YOUR TURN — replace each ___ then run it.

products <- data.frame(
  sku = c("A", "B", "C"),
  name = c("Pen", "Pad", "Ink")
)
sales <- data.frame(
  sku = c("A", "A", "C"),
  qty = c(3, 2, 5)
)

# Keep every product, attaching sales where they exist (Pad will have NA qty).
joined <- ___(products, sales, by = "sku")   # 👉 replace ___ with left_join

print(joined)

# ✅ Expected output:
#   sku name qty
# 1   A  Pen   3
# 2   A  Pen   2
# 3   B  Pad  NA
# 4   C  Ink   5

Mini-Challenge: Revenue per Customer

Write the full pipeline from the outline, run it, and check it against the example output. Join then group then summarise is one of the most common real analyses.

# 🎯 MINI-CHALLENGE: revenue per customer.
#
# Tables:
#   customers <- data.frame(id = c(1,2,3), name = c("Mia","Leo","Zoe"))
#   orders    <- data.frame(id = c(1,1,2), amount = c(100, 50, 200))
#
# In one pipeline:
# 1. inner_join customers and orders by "id".
# 2. group_by(name).
# 3. summarise(total = sum(amount)).
# 4. arrange() by total, highest first.
#
# ✅ Example output (a tibble):
#   name  total
# 1 Mia     150
# 2 Leo     200  -> after arrange, Leo (200) is first

# your code here

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Joins

JoinKeeps
inner_join(a, b)rows matched in both
left_join(a, b)all of a + matches from b
right_join(a, b)all of b + matches from a
full_join(a, b)everything from both
anti_join(a, b)a rows with no match in b
by = "id"the key column(s)

🎉 Lesson Complete!

Practice quiz

Which join keeps only rows with a match in BOTH tables?

  • full_join()
  • inner_join()
  • left_join()
  • anti_join()

Answer: inner_join(). inner_join() drops any row without a partner on either side.

What does left_join() do with a left-table row that has no match?

  • Drops the row
  • Duplicates it
  • Keeps it and fills NA for right columns
  • Raises an error

Answer: Keeps it and fills NA for right columns. left_join() keeps every left row, filling NA where the right table has no match.

Which join keeps every row from both tables?

  • full_join()
  • inner_join()
  • semi_join()
  • anti_join()

Answer: full_join(). full_join() keeps everything from both, filling gaps with NA.

What does anti_join(a, b) return?

  • Rows matched in both
  • All rows of b
  • Rows of a with no match in b
  • The intersection of columns

Answer: Rows of a with no match in b. anti_join is a filtering join returning a's rows that have NO match in b.

How do you specify the key column for a join?

  • key = "id"
  • on = "id"
  • using("id")
  • by = "id"

Answer: by = "id". The by argument names the key, e.g. by = "id".

How do you join when key columns have different names?

  • by = c("left" = "right")
  • by = "left, right"
  • on = left == right
  • key = both

Answer: by = c("left" = "right"). Use a named vector: by = c("left_name" = "right_name").

Which filtering join keeps only rows of a that DO have a match in b?

  • anti_join()
  • right_join()
  • semi_join()
  • full_join()

Answer: semi_join(). semi_join() keeps matching rows of a without adding columns from b.

Why might a join cause the row count to explode?

  • Missing by argument
  • Duplicate keys on both sides (many-to-many)
  • Using left_join
  • NA values present

Answer: Duplicate keys on both sides (many-to-many). Duplicate keys on both sides multiply rows in a many-to-many join.

Which join is most common because it never loses your main records?

  • inner_join()
  • anti_join()
  • left_join()
  • full_join()

Answer: left_join(). left_join() preserves all rows of the primary (left) table.

What suffixes appear when both tables share a non-key column name?

  • .x and .y
  • .a and .b
  • .1 and .2
  • .left and .right

Answer: .x and .y. dplyr disambiguates clashing columns with .x and .y by default.

Continue this course

Frequently asked questions

What is a join and why do I need one?

A join combines two tables by matching rows on a shared key column — like attaching customer names to an orders table using a customer id. Real data is usually split across multiple tables to avoid repetition (one customers table, one orders table), and joins put the pieces back together for analysis. dplyr provides inner_join(), left_join(), right_join(), and full_join(), each differing in which non-matching rows they keep.

What's the difference between inner_join and left_join?

inner_join() keeps only the rows that have a match in BOTH tables — anything without a partner is dropped. left_join() keeps ALL rows from the left (first) table, attaching matching data from the right where it exists and filling NA where it doesn't. Use inner_join when you only care about matched records, and left_join when you must preserve every row of your main table — for example keeping all customers even those with no orders.

How does R know which columns to join on?

You tell it with the by argument: by = "id" matches rows where the id column is equal in both tables. If the key columns have different names, use by = c("left_name" = "right_name"). If you omit by, dplyr does a 'natural join' on all columns the two tables share and prints a message saying which ones — convenient, but being explicit with by avoids surprises and makes your code clearer.

What does anti_join do, and when is it useful?

anti_join(a, b) returns the rows of table a that have NO match in table b — it's a filtering join, not a combining one, so it doesn't add columns from b. It's perfect for 'what's missing' questions: which customers have never placed an order, which products were never sold, which users in one list aren't in another. Its companion semi_join() does the opposite, keeping only the rows of a that DO have a match.