library(tidyverse)
library(nycflights13)14 Relational data
Objectives
- Recognize relational data and keys. A relational dataset organizes information across multiple tables; each table has a primary key that uniquely identifies each row, and a foreign key links a row in one table to a row in another. Learn to identify keys, verify their uniqueness, and create a surrogate key with
row_number()when no natural key exists. - Use mutating joins to combine tables. Mutating joins (
left_join(),inner_join(),right_join(),full_join()) add new columns from a matching table by matching rows on keys. Understand how a left join keeps every row of the left table, and how duplicates arise in a many-to-many join. - Specify join keys explicitly with
join_by(). The default join behavior uses every common variable name as the key, which is convenient right up until two tables happen to share a column name that means two different things. Learn to specify keys manually, including keys with different names on each side. - Use filtering joins to filter rows. Filtering joins (
semi_join(),anti_join()) keep or drop rows in one table based on whether a match exists in another; unlike mutating joins, they never add columns and never duplicate rows. - Avoid pitfalls with joins. Recognize issues such as missing matches, duplicate keys, many-to-many relationships that inflate row counts, and the surprising way a
NAin a join key is treated.
Notes
What is relational data?
Data rarely lives in a single flat table. When information naturally splits into multiple tables, you have relational data. The nycflights13 package is a good example: separate tables describe individual flights, airlines, planes, airports, and weather. To connect them, each table has a primary key, a variable (or set of variables) that uniquely identifies each row, and other tables contain foreign keys that reference those primary keys. Keys let you work with smaller, more coherent tables instead of duplicating the same information across many columns.
To reliably join tables, check that a candidate key is actually unique within its table, using count() and filter(n > 1) to surface any duplicates. When no single column is unique on its own, a compound key made of several columns together, such as airport and hour for weather observations, can serve the same purpose. If a table genuinely has no natural key at all, row_number() creates a simple surrogate key, useful mainly for referring to a specific row unambiguously (in a bug report, for instance) rather than for anything relational.
Mutating joins
A mutating join combines variables from two tables by matching rows on their keys, and the four common mutating joins differ only in which rows survive.
- Inner join (
inner_join(x, y)): keeps only rows where the key appears in bothxandy. - Left join (
left_join(x, y)): keeps every row ofx, filling unmatched rows’ new columns withNA. This is the most common join, since it augments your existing data without silently dropping any of it. - Right join (
right_join(x, y)): keeps every row ofy, matching as many rows ofxas possible. - Full join (
full_join(x, y)): keeps every row from both tables, fillingNAwherever there’s no match.
flights |> left_join(airlines, by = "carrier")# A tibble: 336,776 × 20
year month day dep_time sched_dep_time dep_delay arr_time sched_arr_time
<int> <int> <int> <int> <int> <dbl> <int> <int>
1 2013 1 1 517 515 2 830 819
2 2013 1 1 533 529 4 850 830
3 2013 1 1 542 540 2 923 850
4 2013 1 1 544 545 -1 1004 1022
5 2013 1 1 554 600 -6 812 837
6 2013 1 1 554 558 -4 740 728
7 2013 1 1 555 600 -5 913 854
8 2013 1 1 557 600 -3 709 723
9 2013 1 1 557 600 -3 838 846
10 2013 1 1 558 600 -2 753 745
# ℹ 336,766 more rows
# ℹ 12 more variables: arr_delay <dbl>, carrier <chr>, flight <int>,
# tailnum <chr>, origin <chr>, dest <chr>, air_time <dbl>, distance <dbl>,
# hour <dbl>, minute <dbl>, time_hour <dttm>, name <chr>
Because a mutating join adds columns the same way mutate() does, it’s easy not to notice new columns arriving if the table already has many; select() down to the relevant keys and columns before joining if you want to see exactly what changed.
Specifying join keys
By default, dplyr joins on every variable name the two tables have in common, which is convenient exactly until two tables share a column name that doesn’t mean the same thing in both places (see Example 13.1). join_by() lets you name the key (or keys) explicitly, including an equi join where the matching columns have different names on each side:
flights |> left_join(airports, join_by(dest == faa))# A tibble: 336,776 × 26
year month day dep_time sched_dep_time dep_delay arr_time sched_arr_time
<int> <int> <int> <int> <int> <dbl> <int> <int>
1 2013 1 1 517 515 2 830 819
2 2013 1 1 533 529 4 850 830
3 2013 1 1 542 540 2 923 850
4 2013 1 1 544 545 -1 1004 1022
5 2013 1 1 554 600 -6 812 837
6 2013 1 1 554 558 -4 740 728
7 2013 1 1 555 600 -5 913 854
8 2013 1 1 557 600 -3 709 723
9 2013 1 1 557 600 -3 838 846
10 2013 1 1 558 600 -2 753 745
# ℹ 336,766 more rows
# ℹ 18 more variables: arr_delay <dbl>, carrier <chr>, flight <int>,
# tailnum <chr>, origin <chr>, dest <chr>, air_time <dbl>, distance <dbl>,
# hour <dbl>, minute <dbl>, time_hour <dttm>, name <chr>, lat <dbl>,
# lon <dbl>, alt <dbl>, tz <dbl>, dst <chr>, tzone <chr>
The older syntax by = "carrier" or by = c("playerID" = "id") still works and does the same thing; join_by() is simply the more explicit, more readable modern spelling, and the one you’ll need for the non-equi joins mentioned below.
Filtering joins
A filtering join keeps or drops rows from the first table based solely on whether a match exists in the second, and, unlike a mutating join, never adds a single column and never duplicates a row (see Example 13.3).
- Semi join (
semi_join(x, y)): keeps rows inxthat have at least one match iny. Use it to restrictxto cases that also appear somewhere in another table. - Anti join (
anti_join(x, y)): keeps rows inxthat have no match iny. Use it to find observations inxthat are missing fromy, exactly the technique previewed for spotting implicit missing values back in Session 13.
# flights whose plane has no recorded details in the planes table
flights |> anti_join(planes, by = "tailnum") |> distinct(tailnum) |> head()# A tibble: 6 × 1
tailnum
<chr>
1 N3ALAA
2 N3DUAA
3 N542MQ
4 N730MQ
5 N9EAMQ
6 N532UA
Handling missing matches and duplicate keys
Missing matches are inevitable in real data. Left-joining plane characteristics onto flight records leaves some planes’ details unknown, filled with NA, and it’s worth deciding whether that reflects data that genuinely doesn’t exist or a foreign key that’s simply wrong somewhere upstream.
Duplicate keys are the bigger risk, since a join multiplies rows whenever a key isn’t unique on at least one side (see Example 13.2). Check for duplicates before joining, and decide whether you actually want every combination (setting relationship = "many-to-many" to say so explicitly) or whether one table needs to be summarized down to one row per key first.
A glance at more advanced joins
Every join so far has matched rows on exact equality. dplyr’s join_by() also supports non-equi joins: inequality conditions (join_by(x >= y)) for matching ranges, cross_join() for every possible pairing between two tables, rolling joins (join_by(closest(x <= y))) for matching each row to its nearest counterpart rather than an exact one, and overlap joins (join_by(between(x, y_lower, y_upper)) and similar) for matching a value against a range. These come up most often when matching events to time windows or matching on approximate rather than exact criteria; the four joins covered above handle the large majority of everyday relational work.
Fringe cases and common pitfalls
The default join can silently match on a shared column name that means two different things.
Both flights and planes happen to have a column called year, but in flights it means “year the flight occurred” (every value is 2013), while in planes it means “year the plane was manufactured” (ranging from 1956 to 2013). Joining on every shared column name by default pulls year in as part of the key without asking:
small_flights <- flights |> select(year, tailnum) |> slice_head(n = 5)
# accidental: joins on tailnum AND year, so it only "matches" a plane
# manufactured in exactly 2013, which is almost never true
small_flights |> left_join(planes, by = c("tailnum", "year")) |> select(tailnum, manufacturer)# A tibble: 5 × 2
tailnum manufacturer
<chr> <chr>
1 N14228 <NA>
2 N24211 <NA>
3 N619AA <NA>
4 N804JB <NA>
5 N668DN <NA>
# correct: join on tailnum alone
small_flights |> left_join(planes, by = "tailnum") |> select(tailnum, manufacturer)# A tibble: 5 × 2
tailnum manufacturer
<chr> <chr>
1 N14228 BOEING
2 N24211 BOEING
3 N619AA BOEING
4 N804JB AIRBUS
5 N668DN BOEING
The first join looks like it worked, since it runs without an error or a warning, but nearly every manufacturer comes back NA, because almost no plane was manufactured the same year as any given 2013 flight. join_by() (or an explicit by =) removes the ambiguity entirely by naming exactly which column, or columns, should actually be compared. Whenever two tables share a column name, pause and check whether it means the same thing in both places before letting a natural join use it.
An unexpected many-to-many relationship inflates your row count, but at least it warns you.
x <- tibble(id = c(1, 1, 2), val_x = c("a", "b", "c"))
y <- tibble(id = c(1, 1, 2), val_y = c("m", "n", "o"))
left_join(x, y, by = "id")Warning in left_join(x, y, by = "id"): Detected an unexpected many-to-many relationship between `x` and `y`.
ℹ Row 1 of `x` matches multiple rows in `y`.
ℹ Row 1 of `y` matches multiple rows in `x`.
ℹ If a many-to-many relationship is expected, set `relationship =
"many-to-many"` to silence this warning.
# A tibble: 5 × 3
id val_x val_y
<dbl> <chr> <chr>
1 1 a m
2 1 a n
3 1 b m
4 1 b n
5 2 c o
id = 1 appears twice in both x and y, so the join produces every combination of the two, four rows for id = 1 alone, growing a 3-row table into a 5-row result. dplyr detects that this many-to-many situation wasn’t declared up front and warns you, which is exactly the kind of warning worth reading rather than dismissing, since a quietly duplicated dataset can throw off every downstream count and average. If the many-to-many relationship is genuinely intended, relationship = "many-to-many" silences the warning; if it isn’t, the fix is almost always to deduplicate one side of the join first.
Filtering joins never add columns, even though it’s tempting to expect them to.
names(semi_join(small_flights, planes, by = "tailnum"))[1] "year" "tailnum"
The result of semi_join() has exactly the same columns small_flights started with, nothing from planes at all, because a filtering join only ever asks “does a match exist?” and uses the answer to keep or drop rows. If you actually want columns from the second table, you need a mutating join (typically inner_join(), if you also want to drop the rows without a match); reach for semi_join()/anti_join() only when the columns you already have are all you need, and you’re just trying to filter based on another table’s contents.
A NA in a join key matches another NA, unlike in SQL.
a <- tibble(key = c(1, NA, 3), val_a = c("x", "y", "z"))
b <- tibble(key = c(1, NA, 4), val_b = c("p", "q", "r"))
inner_join(a, b, by = "key")# A tibble: 2 × 3
key val_a val_b
<dbl> <chr> <chr>
1 1 x p
2 NA y q
Anyone with a SQL background may expect two NULLs to never match each other, since SQL treats NULL as “unknown,” and two unknowns can’t be asserted equal. dplyr’s joins work differently: they treat NA as an ordinary value for matching purposes, so a row with key = NA in a does match a row with key = NA in b. This is rarely what you actually want if NA represents “missing” rather than a genuine shared category, so filtering out NA keys before joining (filter(!is.na(key))) is worth doing explicitly whenever a missing key showing up as a false match would be a real problem.
Recap
| Term | Definition |
|---|---|
| Primary key | A variable (or set of variables) that uniquely identifies each row in a table. |
| Foreign key | A variable in one table that refers to a primary key in another table. |
| Surrogate key | An artificial identifier (typically from row_number()) added when no natural key exists. |
| Mutating join | Adds columns from a second table by matching keys (inner_join(), left_join(), right_join(), full_join()). |
join_by() |
Specifies join keys explicitly, including keys with different names on each side or non-equality conditions. |
| Filtering join | Keeps or drops rows from one table based on whether a match exists in another (semi_join(), anti_join()); never adds columns or duplicates rows. |
| Many-to-many relationship | When a key isn’t unique on either side of a join, so matching rows are duplicated for every combination; triggers a warning unless declared with relationship = "many-to-many". |
NA in a join key |
Matches another NA in dplyr’s joins, unlike SQL’s NULL, which never matches another NULL. |
Check your understanding
- What is the difference between a primary key and a foreign key? What is a surrogate key, and when would you need one?
- Two tables both have a column called
id, but in one table it identifies a customer and in the other it identifies an order. What would happen if you joined them with the defaultbybehavior, and how wouldjoin_by()help? - A
left_join()you expected to simply add a couple of columns instead produces a table with far more rows than you started with, along with a warning. What almost certainly happened? - Explain why
semi_join(orders, customers, by = "customer_id")cannot be used to add a customer’s name to the orders table, even though both tables share acustomer_idcolumn. - You join two tables on a key that can be
NAin both tables, expecting rows with an unknown key to simply be excluded from the match. What actually happens in dplyr, and how would you get the behavior you expected?
A primary key uniquely identifies each row within its own table; a foreign key is a column in a different table that refers back to that primary key, linking the two tables together. A surrogate key is an artificial identifier, typically created with
row_number(), added when a table has no natural column (or combination of columns) that’s already unique; it’s mainly useful for referring to a specific row unambiguously, not for representing any real-world relationship.By default, the join would use
idas the key, silently comparing a customer identifier to an order identifier as though they meant the same thing, which would produce meaningless or empty matches without any error.join_by()(or an explicitby =) lets you name the actual keys you want compared, or simply avoid usingidas the join key at all if the two columns aren’t meant to be compared.The join key almost certainly isn’t unique on at least one side, creating a many-to-many relationship: every duplicate key on one side gets matched against every duplicate key on the other, multiplying rows far beyond a simple one-to-one or one-to-many join. Checking each table’s key with
count()andfilter(n > 1)before joining would reveal the duplicates.semi_join()is a filtering join: it only ever keeps or drops rows from the first table based on whether a match exists in the second, and never brings in any columns from the second table at all. To add a customer’s name to the orders table, you need a mutating join, such asleft_join(orders, customers, by = "customer_id").dplyr treats
NAas an ordinary matchable value in a join key, so rows withNAin both tables’ key columns will match each other, the opposite of SQL’sNULL, which never matches anotherNULL. To exclude rows with a missing key from matching, filter them out of one or both tables first, for example withfilter(!is.na(key)), before joining.