[1] 5485 19
[1] 4250 7
Keys, mutating joins, and filtering joins with real Bigfoot report data
2026-07-05
Note
✅ Transition
Real data almost never lives in a single table. Today’s dataset comes in two: one file has the Bigfoot report text (where, when, what), a second has the coordinates. Neither alone lets you map a sighting. Joins stitch them together on a shared key — and the result is exactly the file we’ll map next week. 🦶
left_join(), inner_join(), full_join()semi_join(), anti_join()Tools today:
tidyverse (dplyr)Data (two tables):
bfro_reports.csv — the report text + classbfro_locations.csv — the coordinatesReference:
common_code/13_joinsTip
Day 1: keys + left_join. Day 2: the join family + filtering joins.
This lecture runs in four chunks over two days. After each chunk you switch to the activity and type the code yourself.
For every join, do three things:
Note
✅ Why bother?
*_join() muscle memory.We will cover: why data comes in multiple tables, and what a key is.
Tip
🖐 After this chunk: Activity Parts 1–3 (load both tables, find the key).
reports_df — one row per sighting report: year, season, state, county, class, and the written accountlocations_df — one row per report that got geocoded: a latitude and longitude# A tibble: 3 × 4
REPORT_NUMBER YEAR STATE REPORT_CLASS
<dbl> <chr> <chr> <chr>
1 30680 2010 Alabama Class B
2 1261 Early 1990's Alaska Class A
3 6496 1974 Rhode Island Class A
# A tibble: 3 × 3
number latitude longitude
<dbl> <dbl> <dbl>
1 637 61.5 -143.
2 2917 55.2 -133.
3 7963 55.2 -133.
A key is the column that uniquely identifies a row and links the tables.
reports_df it’s REPORT_NUMBERlocations_df it’s numberNote
📖 Vocabulary
The key must be unique in the table you’re looking things up in (locations_df).
distinct() — keep one row per report numberas.integer() — REPORT_NUMBER was stored like 30680.0; number is 30680. Keys must be the same type to matchWarning
⚠️ Watch out! A join silently fails to match if the keys are different types (text vs number) or formats — clean them first.
Load both tables, find the key in each, and clean it. Predict how many reports have coordinates before we join.
left_join() (Day 1)We will cover: joining the two tables when the key columns are named differently.
Tip
🖐 After this chunk: Activity Parts 4–6 (run the left join, read the result).
left_join() — Keep Every ReportNote
🔮 Predict first: reports_df has ~5,000 reports; only ~4,000 were geocoded. After a left_join, how many rows will we have, and how many will have a latitude?
by = c("REPORT_NUMBER" = "number") — “match my REPORT_NUMBER to their number”left_join keeps every row on the left (all reports)NA coordinates — they simply weren’t geocoded📖 R4DS §19.3 — mutating joins
If I forget by = ..., dplyr looks for a shared column name — and both tables happen to have an index column:
That matches row 1 to row 1, row 2 to row 2 — nonsense! index is just a row number, not a report ID. You get coordinates glued to the wrong reports, with no error.
The fix — always name the real key:
Important
✅ Why show a silent wrong join?
When dplyr prints “Joining with by = ...” and you didn’t choose that column — stop. Auto-joining on an accidental shared name (index, id, x) is a classic silent bug. Name your key every time.
Day 1 recap — you can now:
left_join() to keep every row on the leftDay 2 — the rest of the family:
inner_join vs left_join vs full_joinsemi_join and anti_join to filter instead of add columnsWe will cover: inner_join, left_join, and full_join, and how to choose.
Tip
🖐 After this chunk: Activity Parts 7–9 (compare the three joins).
Note
🔮 Predict first: Which will have more rows — an inner_join (only matches) or a left_join (all reports)? By roughly how many?
# Same key, three different rules --------------------
inner <- inner_join(reports_df, locations_df, by = c("REPORT_NUMBER" = "number"))
left <- left_join (reports_df, locations_df, by = c("REPORT_NUMBER" = "number"))
full <- full_join (reports_df, locations_df, by = c("REPORT_NUMBER" = "number"))
c(inner = nrow(inner), left = nrow(left), full = nrow(full))inner left full
4056 5022 5216
| Join | Keeps |
|---|---|
inner_join |
only rows that match in both |
left_join |
all rows from the left (reports) |
full_join |
all rows from both tables |
inner drops the reports with no coordinates; full even keeps locations with no report.
left_join — the default. Keep everything on your main table, add what you can. (Use this for the map: keep all reports.)inner_join — only rows with a match. (Use when missing data would ruin the analysis.)full_join — keep everything from both. (Use to audit what’s missing on each side.)Note
✅ Key idea
Start from the question: “Which rows do I refuse to lose?” That answer picks your join. For mapping we refuse to lose reports, so left_join.
📖 R4DS §19.3–19.4
Run all three joins, compare the row counts, and decide which one you’d use to build a map. Predict each count first.
We will cover: semi_join and anti_join — joins that filter rather than add columns.
Tip
🖐 After this chunk: Activity Parts 10–12 (find the missing reports, build the map table).
anti_join() — What Didn’t Match?Note
🔮 Predict first: How many reports have no coordinates? (Hint: total reports minus the number that got a latitude in Chunk 2.)
[1] 966
# A tibble: 4 × 2
STATE n
<chr> <int>
1 California 140
2 Pennsylvania 67
3 Oregon 63
4 Washington 61
anti_join keeps the left rows with no match — perfect for “what’s missing?”📖 R4DS §19.5 — filtering joins
semi_join() — Keep Only What Matchedsemi_join keeps left rows that have a match — again, no new columnssemi/anti as a filter driven by another tablesemi_join result = the rows an inner_join would keep, but without the extra columns# Reports + coordinates, ready to map next week ------
bigfoot_mappable <- reports_df %>%
left_join(locations_df, by = c("REPORT_NUMBER" = "number")) %>%
filter(!is.na(latitude), !is.na(longitude))
bigfoot_mappable %>%
select(REPORT_NUMBER, STATE, SEASON, REPORT_CLASS, latitude, longitude) %>%
head()# A tibble: 6 × 6
REPORT_NUMBER STATE SEASON REPORT_CLASS latitude longitude
<dbl> <chr> <chr> <chr> <dbl> <dbl>
1 6496 Rhode Island Fall Class A 41.4 -71.5
2 9765 Oklahoma Fall Class A 35.3 -99.2
3 4983 Ohio Summer Class A 39.4 -81.7
4 31940 New York Fall Class A 41.3 -73.7
5 5692 Nevada Fall Class B 39.6 -120.
6 55269 New Hampshire Summer Class A 43.4 -72.3
This joined table — report text plus coordinates — is essentially bfro_reports_geocoded.csv, the file you’ll turn into a map in Lecture 13.
You just built a real dataset from two raw ones. That’s the everyday work of data science.
Use anti_join to find the un-geocoded reports, semi_join to keep the mappable ones, and build the combined table. Predict the counts first.
Concepts:
by = c("a" = "b")left (all left), inner (matches), full (all)semi (has a match), anti (no match)by = ... you didn’t choose is a bugR skills:
left_join(), inner_join(), full_join()semi_join(), anti_join()distinct() + as.integer() for key hygieneReferences:
common_code/13_joinsUp next — Lecture 13:
sf and geom_sf()