library(tidyverse)Data Skills for Psychology — Module 7
These slides are adapted from Applied Data Skills Ch. 5: Data Relations.
By the end of this class, you will be able to:
left_join(), right_join(), inner_join(), and full_join()bind_rows() and bind_cols()Make sure you have the tidyverse loaded:
Tip
Grab the Data transformation cheat sheet — it has a great visual summary of all the joins on the back page.
Almost every real-world analysis starts by combining data from multiple sources:
The *_join() family lets you stitch tables together using a shared identifier (like a participant ID).
We’ll use two tiny demo tables. First, a patients table:
patients (5 rows)
| id | city | postcode |
|---|---|---|
| 1 | Port Ellen | PA42 7DU |
| 2 | Dufftown | AB55 4DH |
| 3 | NA | NA |
| 4 | Aberlour | AB38 7RY |
| 5 | Tobermory | PA75 6NR |
visits (9 rows)
| id | visits |
|---|---|
| 2 | 2 |
| 3 | 1 |
| 4 | 4 |
| 4 | 0 |
| 5 | 9 |
| 5 | 11 |
| 6 | 5 |
| 6 | 0 |
| 7 | 3 |
Notice: some IDs appear in only one table, and some IDs appear multiple times in visits.
A mutating join adds new columns from one table onto another, matching rows by a key.
The general syntax is the same for all four:
x = the first (left) tabley = the second (right) tableby = which column(s) to match onleft_join()Keeps all rows from the left table and adds matching info from the right.
Result: all 5 patients are kept. Patients with multiple visits get duplicated rows. Patient 1 (no visits) gets NA in the visits column.
right_join()Keeps all rows from the right table and adds matching info from the left.
Result: all 9 visits are kept. IDs in visits that don’t appear in patients (ids 6, 7) get NA for city and postcode.
Note
right_join(x, y) returns the same rows as left_join(y, x) — just with the columns in a different order.
inner_join()Keeps only rows that appear in both tables.
Result: only IDs 2, 3, 4, 5 remain. Patient 1 (no visits) is dropped. Visits for IDs 6 and 7 (not in patients) are also dropped.
full_join()Keeps everything from both tables, filling in NA where there’s no match.
Result: every ID from either table appears. Patient 1 has NA for visits. Visits 6 and 7 have NA for city and postcode.
| Join | What you keep |
|---|---|
left_join(x, y) |
All rows from x; matched info from y |
right_join(x, y) |
All rows from y; matched info from x |
inner_join(x, y) |
Only rows in both |
full_join(x, y) |
Rows from either |
A good first question to ask: which table is the “ground truth” I want to preserve?
Choose the right join for each scenario
You have registrations (everyone signed up with a doctor) and jabs (everyone who got a flu shot). You want to analyze outcomes for people who are both registered and vaccinated.
You have boards (all NHS regions and their populations) and attendances (clinic visits, only some boards). You want every NHS board, with attendance filled in where available.
You have fuel_support and disability_adaptations. You want everyone in either list, combining both sets of variables.
Think about it — which join, and why?
Joins are two-table verbs. To combine three+ tables, just chain them.
Each left_join step adds new columns onto whatever came before.
Warning
Joins are easy to write but surprisingly easy to get wrong. Before moving on, ask:
If you build up a multi-step pipe, check the result at each step.
Useful inspection functions: glimpse(), summary(), nrow().
Joins match rows by a key column.
Binding just stacks tables — rows on top of rows, or columns side by side. There’s no matching logic.
bind_rows() → stack rows (must have same column names)bind_cols() → stack columns (must have same number of rows, in the same order)bind_rows()Add more patients to the existing table:
Columns don’t have to be in the same order — they just need the same names. Missing columns get filled with NA.
bind_cols()When the tables are in the same row order, you can paste columns side by side:
Warning
bind_cols() is dangerous! It assumes rows are aligned. If the order is off, your data will be silently misaligned. Whenever possible, use a mutating join with a shared ID instead.
Set operations compare two tables and return rows that:
intersect()union() (deduplicated)setdiff()Unlike joins, set operations compare entire rows, not just key columns. The tables need the same column names.
intersect() and union()new_patients <- tibble(
id = 5:9,
postcode = c("PA75 6NR", "FK1 4RS", "PA42 7EA",
"G81 4SJ", "KW15 1SE"),
city = c("Tobermory", "Falkirk", "Ardbeg",
"Doogal", "Kirkwall")
)
# rows that appear in BOTH tables
intersect(patients, new_patients)
# rows in EITHER table, with duplicates removed
union(patients, new_patients)Note
union() is like bind_rows() but it removes exact duplicate rows.
setdiff()Rows in the first table that are not in the second:
Order matters! setdiff(A, B) and setdiff(B, A) answer different questions.
Warning
There are also base::intersect(), base::union(), and base::setdiff() that behave differently. Always load the tidyverse so the dplyr:: versions are used.
Pick the right tool
Two yearly survey extracts (survey_2023, survey_2024) — you want one combined dataset, keeping duplicates if a respondent appears in both years.
Two extracts of an equality register — you want a combined dataset with duplicates removed.
A master staff list and an HR export — you want to find staff in the master list that are missing from the export.
A patients table and a predictions table with the same number of rows in the same order — you want to glue the prediction column on.
Imagine you have:
grades1 — exam scores (one row per student, has an id and Points)grades2 — essay scores (different students)schedule — lookup table mapping Points → letter GradeYou want:
# add letter grades to each table
exam_all <- left_join(grades1, schedule, by = "Points")
essay_all <- left_join(grades2, schedule, by = "Points")
# only students with a non-missing grade
exam_grades <- inner_join(grades1, schedule, by = "Points")
essay_grades <- inner_join(grades2, schedule, by = "Points")
# students with BOTH an exam and an essay grade
completes <- inner_join(exam_grades, essay_grades,
by = "id",
suffix = c("_exam", "_essay")) |>
select(id, Grade_exam, Grade_essay)Notice how suffix lets you rename columns that share a name between the two tables.
Mutating joins add columns: left_join, right_join, inner_join, full_join
Binding stacks tables: bind_rows (more rows), bind_cols (more columns — be careful!)
Set operations compare whole rows: intersect, union, setdiff
Always check your result: did the row count change as expected? Any unexpected NAs?