Common Code 13 — Joining Data Frames
left_join, inner_join, full_join, anti_join — merging tables by a shared key
How to join dataframes into a new one
Joining data frames
When your data lives in two spreadsheets — fish measurements in one file, site environmental data in another — you need to join them by a shared column (the key). The dplyr join functions do this cleanly and safely.
⬇️ Download the companion R script:
13_joins.R
library(tidyverse)
library(nycflights13) # flights, airlines, airports, weather, planes1 · The four join types
| Join | Keeps rows from | What happens to non-matches |
|---|---|---|
left_join(x, y) |
All of x | y columns filled with NA |
inner_join(x, y) |
Only rows in both | Non-matches dropped silently |
full_join(x, y) |
All of both | NA filled on both sides |
anti_join(x, y) |
x rows with no match in y | Keeps the gaps, not the matches |
Default: use left_join() — it keeps your primary data intact and tells you clearly where information is missing via NA.
2 · left_join() — the workhorse
Add airline names to flight records. Every flight row is kept; the name comes from the airlines table where the carrier code matches.
flights |>
left_join(airlines, by = "carrier") |>
select(flight, name, dep_delay, arr_delay) |>
head()by argument
Writing by = "carrier" makes explicit which column is the key. Without it, dplyr joins on all shared column names — which can silently join on the wrong columns if your two tables share a name like year or id.
3 · inner_join() — matched rows only
Join flights to plane specifications. Any flight whose tailnum is not in the planes table is silently dropped.
flights |>
inner_join(planes, by = "tailnum") |>
select(flight, tailnum, manufacturer, model, dep_delay) |>
head()inner_join drops rows silently
If 5 000 flights use tail numbers not in planes, inner_join() drops all 5 000 without any warning. Always compare nrow() before and after an inner_join() to see how many rows were lost.
4 · full_join() — keep everything
Useful when two datasets each have unique rows the other lacks:
df_a <- tibble(site = c("A","B","C"), density = c(12,8,15))
df_b <- tibble(site = c("B","C","D"), temp_c = c(14,12,18))
full_join(df_a, df_b, by = "site")Site A has no temperature (NA in temp_c); site D has no density (NA in density). The full join makes all gaps visible.
5 · anti_join() — find the gaps
Which flights have a tail number that is not in the planes registry?
flights |>
anti_join(planes, by = "tailnum") |>
count(tailnum, sort = TRUE)anti_join is great for data quality checks
“Which sites in my fish data have no matching entry in my site metadata?” is exactly an anti_join() question. Run it before any analysis to find missing rows early.
6 · Joining on differently named columns
When the key column has different names in the two tables, use by = c("left_name" = "right_name"):
# airports uses "faa"; flights uses "dest"
flights |>
left_join(airports, by = c("dest" = "faa")) |>
select(flight, dest, name, lat, lon) |>
head()7 · Joining on multiple columns
weather is identified by five columns: year, month, day, hour, and origin. List all of them in by:
flights |>
left_join(weather,
by = c("year","month","day","hour","origin")) |>
select(flight, dep_delay, temp, wind_speed, precip) |>
head()8 · The duplicate key problem
If the right-hand table has duplicate key values, every matching row in the left table gets multiplied — one output row per match.
# Safe — check for duplicates before joining
airlines |> count(carrier) |> filter(n > 1) # should be 0
# If duplicates exist, decide what to do:
# distinct() — keep one row per key
# group_by() |> summarise() — aggregate first, then joinAlways check for duplicates before joining. A silent row explosion is one of the hardest bugs to catch after the fact.
9 · Ecological example — fish data + site metadata
fish_counts <- tibble(
site_id = c("S01","S01","S02","S03","S03"),
species = c("brook_trout","sculpin","brook_trout","sculpin","brook_trout"),
count = c(24, 8, 3, 15, 11)
)
site_meta <- tibble(
site_id = c("S01","S02","S03"),
stream = c("Knife River","Baptism River","Brule River"),
area_m2 = c(45, 30, 60)
)
# Merge and calculate density
fish_counts |>
left_join(site_meta, by = "site_id") |>
mutate(density = count / area_m2)Quick reference
| Task | Code |
|---|---|
| Add columns from y, keep all x | left_join(x, y, by = "key") |
| Keep only matched rows | inner_join(x, y, by = "key") |
| Keep all rows from both | full_join(x, y, by = "key") |
| Find rows in x with no match | anti_join(x, y, by = "key") |
| Different key names | by = c("left_col" = "right_col") |
| Join on multiple columns | by = c("year","month","day") |
| Check for duplicate keys | df |> count(key_col) |> filter(n > 1) |
End of Common Code 13 — Joining Data Frames. Next: Common Code 14 — Dates with lubridate.