8  Reshape and Combine Forestry Datasets Safely

NoteData for this chapter

This chapter uses the BC silviculture (bc_disturbance_reforestation.xlsx), Ontario forest statistics (on_forest_statistics_1.xlsx), BC air-quality (airdata3.csv), and ECCC weather (inter_air_van_weather.csv) files. Click each file name to download it directly, then save it in your data/ folder. For sources and details, see the Datasets Used in This Book page.

Chapter goals

By the end of this chapter you will be able to:

  • Explain tidy data: one row per observation, one column per variable, one cell per value.
  • Use pivot_longer() to reshape a wide tibble (one column per group) into long format (one row per observation).
  • Use pivot_wider() as the inverse (you met this in Chapter 7) and choose between long and wide thoughtfully.
  • Combine two tibbles side by side with the four join verbs: left_join(), inner_join(), right_join(), full_join().
  • Use the filtering joins semi_join() and anti_join() to keep or drop rows based on whether they match a reference.
  • Combine two tibbles end to end with bind_rows() (stack) and bind_cols() (side-by-side without matching).
  • Diagnose join problems: missing matches, duplicate keys, mismatched data types.
  • Build a multi-source forestry analysis that combines the BC silviculture, Ontario forest stats, BC air-quality, and ECCC weather files.

Expected output for this chapter

TipWhat you will hand in
  1. A rendered Quarto HTML report (frst232_ch08_learner.html) containing:
    • A pivot_longer() example reshaping a wide BC summary into long format.
    • All four join types demonstrated on a small worked example, with a one-sentence explanation of what each kept or dropped.
    • A bind_rows() example combining the BC silviculture and Ontario forest stats files into a single national tibble.
    • A many-to-one join of hourly air quality to daily weather, with the pre- and post-join row counts shown.
    • A short join diagnostic table showing key match counts.
  2. The corresponding .qmd source file.
  3. A short group-lab worksheet (or screenshot) submitted to the course site.

8.1 Where we are

Through Chapter 7 you cleaned, summarised, and reshaped one dataset at a time. Chapter 8 is about combining multiple datasets — joining a main table to a reference table, stacking files from different provinces, reshaping summary tables for plotting.

These are the verbs that turn a folder of files into a single analysis.

8.2 Tidy data, briefly

Three rules:

  1. One row per observation.
  2. One column per variable.
  3. One cell per value.

Most cleaning headaches come from violations of these rules. The BC silviculture file (Ch 1) is tidy — each row is a fiscal year, each column is a measured quantity. The Ontario file (Ch 2) is also tidy — each row is a region × ownership × seral × forest-type × age combination.

A file where each column is a year (e.g., ha_2018, ha_2019, ha_2020) is not tidy — the column name encodes a variable (year). You convert it to tidy with pivot_longer().

8.3 Excel-to-R bridge — reshape and combine

Excel pattern dplyr / tidyr verb
VLOOKUP or XLOOKUP left_join()
Copy-paste-append from two sheets bind_rows()
Copy-paste-side-by-side from two sheets (same row count) bind_cols() (rarely used)
Unpivot in Power Query pivot_longer()
Pivot in Power Query pivot_wider()
“Keep only matched rows” inner_join()
“Show me rows in A that have no match in B” anti_join()
“Show me rows in A that DO have a match in B (but don’t join)” semi_join()
Compare two sheets to find new entries anti_join(new, old, by = "key")
Find duplicates count(key) |> filter(n > 1)

8.4 pivot_longer() — the inverse of pivot_wider()

In Chapter 7 you used pivot_wider() to turn a summary tibble into a cross-tab. pivot_longer() is the reverse: take a wide table and convert it to long format.

8.4.1 A worked example with the BC silviculture file

bc <- read_excel(here("data", "bc_disturbance_reforestation.xlsx"),
                 sheet = "Data")

# Build a small wide summary: rows = fiscal year, columns = disturbance types
bc_wide <- bc |>
  select(Fiscal_Year, Harvested_ha, Natural_Disturbance_ha, Total_Disturbance_ha)
head(bc_wide)
# A tibble: 6 × 4
  Fiscal_Year Harvested_ha Natural_Disturbance_ha Total_Disturbance_ha
        <dbl>        <dbl>                  <dbl>                <dbl>
1        1987      242182                  20875               263057 
2        1988      251557.                  9641.              261199.
3        1989      215878.                 10712.              226590.
4        1990      192692.                 28301.              220992.
5        1991      213217.                 14424.              227640.
6        1992      234086.                  5635.              239720.

This is wide format: each disturbance type is its own column. To plot all three on the same chart (Chapter 9), you need long format:

bc_long <- bc_wide |>
  pivot_longer(
    cols      = c(Harvested_ha, Natural_Disturbance_ha, Total_Disturbance_ha),
    names_to  = "disturbance_type",
    values_to = "area_ha"
  )
head(bc_long, 9)
# A tibble: 9 × 3
  Fiscal_Year disturbance_type       area_ha
        <dbl> <chr>                    <dbl>
1        1987 Harvested_ha           242182 
2        1987 Natural_Disturbance_ha  20875 
3        1987 Total_Disturbance_ha   263057 
4        1988 Harvested_ha           251557.
5        1988 Natural_Disturbance_ha   9641.
6        1988 Total_Disturbance_ha   261199.
7        1989 Harvested_ha           215878.
8        1989 Natural_Disturbance_ha  10712.
9        1989 Total_Disturbance_ha   226590.

What we did:

  • cols lists which columns to pivot.
  • names_to is the name of the new column that will hold the original column names.
  • values_to is the name of the new column that will hold the values.

Result: 3 columns × 37 rows became 3 columns × 111 rows. The data is identical, the shape is different.

TipWhen to use long vs wide
  • Long format is what ggplot2, dplyr, and most statistics functions want. Use it for analysis.
  • Wide format is what humans want to read. Use it for tables in a report and for cross-tabs.

Convert between them with pivot_longer() and pivot_wider(). They are inverses: data |> pivot_longer(...) |> pivot_wider(...) returns the original (modulo column ordering).

TipWhich verb should I use?

Reshaping changes the shape of one table; joining combines two tables. When you meet a new task, match it here first:

Your task Verb
Wide → long (fold many columns into key/value rows) pivot_longer()
Long → wide (spread a key column across new columns) pivot_wider()
Add columns from another table by a matching key a join (left_join(), …)
Stack two tables with the same columns bind_rows()
Glue two tables side by side (same row order) bind_cols()

8.5 The four mutating joins

Use this after your own attempt. It should check your reasoning, not hand you the answer:

“I plan to left_join([A], [B], by = "[key]") to add [columns]. Check whether left_join is the right join type, whether my key is unique in [B], and which pre/post-join row-count checks I should run so the join does not silently fan out. Do not give me the merged table.”

You met left_join() in Chapter 6. Now the other three.

8.5.1 Set up a small worked example

# Five fictitious silviculture plots
plots <- tribble(
  ~plot_id, ~district,        ~area_ha,
  "P001",   "Vancouver",      12.0,
  "P002",   "Vancouver",       8.5,
  "P003",   "Squamish",       21.2,
  "P004",   "Squamish",       15.0,
  "P005",   "Nanaimo",         9.7
)

# Reference table — district → biogeoclimatic zone
districts <- tribble(
  ~district,    ~bec_zone,
  "Vancouver",  "CWHdm",
  "Squamish",   "CWHvm",
  "Sunshine Coast", "CWHvh"      # district NOT in plots
)

plots
# A tibble: 5 × 3
  plot_id district  area_ha
  <chr>   <chr>       <dbl>
1 P001    Vancouver    12  
2 P002    Vancouver     8.5
3 P003    Squamish     21.2
4 P004    Squamish     15  
5 P005    Nanaimo       9.7
districts
# A tibble: 3 × 2
  district       bec_zone
  <chr>          <chr>   
1 Vancouver      CWHdm   
2 Squamish       CWHvm   
3 Sunshine Coast CWHvh   

8.5.2 left_join() — keep all rows of the left tibble

left_join(plots, districts, by = "district")
# A tibble: 5 × 4
  plot_id district  area_ha bec_zone
  <chr>   <chr>       <dbl> <chr>   
1 P001    Vancouver    12   CWHdm   
2 P002    Vancouver     8.5 CWHdm   
3 P003    Squamish     21.2 CWHvm   
4 P004    Squamish     15   CWHvm   
5 P005    Nanaimo       9.7 <NA>    

Every row of plots is kept. bec_zone is filled in where the district matches; NA where it does not (Nanaimo).

8.5.3 inner_join() — keep only matched rows

inner_join(plots, districts, by = "district")
# A tibble: 4 × 4
  plot_id district  area_ha bec_zone
  <chr>   <chr>       <dbl> <chr>   
1 P001    Vancouver    12   CWHdm   
2 P002    Vancouver     8.5 CWHdm   
3 P003    Squamish     21.2 CWHvm   
4 P004    Squamish     15   CWHvm   

Only rows present in both tibbles survive. Nanaimo drops (no match in districts); Sunshine Coast drops (no match in plots).

8.5.4 right_join() — keep all rows of the right tibble

right_join(plots, districts, by = "district")
# A tibble: 5 × 4
  plot_id district       area_ha bec_zone
  <chr>   <chr>            <dbl> <chr>   
1 P001    Vancouver         12   CWHdm   
2 P002    Vancouver          8.5 CWHdm   
3 P003    Squamish          21.2 CWHvm   
4 P004    Squamish          15   CWHvm   
5 <NA>    Sunshine Coast    NA   CWHvh   

Sunshine Coast appears (with NA for the plot columns). Equivalent to left_join(districts, plots, by = "district").

8.5.5 full_join() — keep everything

full_join(plots, districts, by = "district")
# A tibble: 6 × 4
  plot_id district       area_ha bec_zone
  <chr>   <chr>            <dbl> <chr>   
1 P001    Vancouver         12   CWHdm   
2 P002    Vancouver          8.5 CWHdm   
3 P003    Squamish          21.2 CWHvm   
4 P004    Squamish          15   CWHvm   
5 P005    Nanaimo            9.7 <NA>    
6 <NA>    Sunshine Coast    NA   CWHvh   

Both unmatched sides appear: Nanaimo with NA bec_zone, Sunshine Coast with NA plot columns.

8.5.6 A picture of all four

Join Keeps
left_join(A, B) All of A + matched from B
inner_join(A, B) Only matched rows (both sides)
right_join(A, B) All of B + matched from A
full_join(A, B) All of A and all of B + matched

For most lookups, left_join is the default. Use it unless you have a specific reason to drop unmatched rows.

8.6 The filtering joins

These two never add columns — they only keep or drop rows.

8.6.1 semi_join() — “keep rows in A that have a match in B”

semi_join(plots, districts, by = "district")
# A tibble: 4 × 3
  plot_id district  area_ha
  <chr>   <chr>       <dbl>
1 P001    Vancouver    12  
2 P002    Vancouver     8.5
3 P003    Squamish     21.2
4 P004    Squamish     15  

Returns plots in districts that exist in the reference table. No new columns are added; the join is used as a filter.

8.6.2 anti_join() — “keep rows in A that have NO match in B”

anti_join(plots, districts, by = "district")
# A tibble: 1 × 3
  plot_id district area_ha
  <chr>   <chr>      <dbl>
1 P005    Nanaimo      9.7

Returns plots in districts that do not exist in the reference table — exactly the rows you might need to chase down or add to your reference.

This pair is invaluable for data audits. “Which plots have no district info? Which districts in my reference table are unused?”

8.7 Joining on multiple keys

When a single column does not uniquely identify rows, join on multiple columns:

left_join(measurements, reference, by = c("plot_id", "year"))

When the column names differ across the two tibbles:

left_join(measurements, reference,
          by = c("plot_id" = "plot", "year" = "yr"))

by = c("col_A" = "col_B") reads “match column col_A in measurements to column col_B in reference”.

8.8 bind_rows() — stack tibbles end to end

When you have two tibbles with the same (or compatible) columns, stack them vertically:

# Pretend we received the BC file as two separate halves
bc_pre2005 <- bc |> filter(Fiscal_Year < 2005)
bc_post2005 <- bc |> filter(Fiscal_Year >= 2005)

nrow(bc_pre2005); nrow(bc_post2005)
[1] 18
[1] 19
# Stack them
bc_recombined <- bind_rows(bc_pre2005, bc_post2005)
nrow(bc_recombined)
[1] 37
identical(arrange(bc, Fiscal_Year), arrange(bc_recombined, Fiscal_Year))
[1] TRUE

bind_rows() matches columns by name — column order does not matter. Missing columns in one tibble become NA in the result.

8.8.1 Tagging the source

When stacking files from different provinces, add a column recording where each row came from:

bc_tagged <- bc |>
  select(Fiscal_Year, Harvested_ha) |>
  mutate(province = "BC")

# Build a tiny Ontario-style sample
ontario_sample <- tribble(
  ~Fiscal_Year, ~Harvested_ha,
  2018,        160000,
  2019,        158000,
  2020,        155000
) |>
  mutate(province = "ON")

combined <- bind_rows(bc_tagged, ontario_sample)
combined |> tail(6)
# A tibble: 6 × 3
  Fiscal_Year Harvested_ha province
        <dbl>        <dbl> <chr>   
1        2021      144033. BC      
2        2022      119100. BC      
3        2023       78961. BC      
4        2018      160000  ON      
5        2019      158000  ON      
6        2020      155000  ON      

The province column now lets you group_by(province) and compute per-province summaries — exactly the kind of analysis that requires combining multiple files.

8.9 bind_cols() — stack side by side (rarely the right answer)

bind_cols() glues tibbles horizontally:

bind_cols(tibble_a, tibble_b)

This requires that both tibbles have the same number of rows in the same order. There is no key matching. If the rows are not aligned, you silently get garbage.

Warningbind_cols() vs left_join()

bind_cols() glues by row position; left_join() matches by key. Always prefer left_join() for combining columns from two sources — even if you “know” the rows are in the same order. Order assumptions silently break the day someone re-sorts one of the files.

8.10 Join diagnostics — what every join should print

Before trusting a join, run two checks:

# 1. How many rows did the join produce vs the original?
nrow(plots)
nrow(left_join(plots, districts, by = "district"))

# 2. How many rows in A failed to match in B?
anti_join(plots, districts, by = "district") |> nrow()

If left_join returns more rows than the left tibble, your reference table has duplicate keys — and your join is silently fanning out (one row in A matching several in B).

If anti_join returns rows you did not expect, your reference table is missing entries — those plots will have NA in the joined columns.

Both are common bugs. Both are caught in seconds by the two-line diagnostic.

8.11 A real-world many-to-one join: air quality + weather

The join examples so far used small tribbles. Here is the real thing — and the single most common join shape in natural-resources work: many rows in one table matching one row in another.

We have hourly ozone readings from the Vancouver International Airport station (many rows per day) and daily weather from the same airport (one row per day). Joining them attaches each hour’s reading to that day’s weather.

# Hourly air quality at the YVR station (one row per hour)
air_yvr <- read_csv(here("data", "airdata3.csv"),
                    show_col_types = FALSE) |>
  filter(location == "Vancouver International Airport #2",
         !is.na(O3)) |>
  mutate(date = mdy(Date)) |>
  select(location, date, time = Time, O3)
New names:
• `` -> `...1`
# Daily weather at the same airport (one row per day).
# The column names arrive messy, so we rename as we select.
weather <- read_csv(here("data", "inter_air_van_weather.csv"),
                    show_col_types = FALSE) |>
  mutate(date = mdy(`Date/Time`)) |>
  select(date,
         mean_temp    = `Mean Temp (°C)`,
         total_precip = `Total Precip (mm)`)

# Pre-join row counts — always check these first
nrow(air_yvr)    # hourly: many rows
[1] 727
nrow(weather)    # daily: one row per day
[1] 366
# Many-to-one left join: each hour gets its day's weather
air_weather <- air_yvr |>
  left_join(weather, by = "date")

nrow(air_weather)   # post-join row count
[1] 727

Because each hourly reading matches exactly one daily weather row, the join is many-to-one and the row count is unchanged (727 rows in, the same number out). That is the signature of a safe join. If the number had grown, the weather table would have had duplicate dates — the silent fan-out bug from the previous section.

# Is the weather key unique? (If not, the join can fan out.)
weather |> count(date) |> filter(n > 1) |> nrow()      # expect 0
[1] 0
# Did every air reading find a weather match?
air_yvr |> anti_join(weather, by = "date") |> nrow()   # expect 0
[1] 0

Forestry relevance. Growing-degree-days, frost dates, and precipitation totals are the standard climate inputs to BC growth-and-yield models. Joining hourly observations to daily climate is the same operation as joining tree records to plot-level weather summaries — the technique transfers directly.

8.12 A complete worked example — multi-source forestry analysis

Combine three files into a single analysis: the BC silviculture file from Chapter 1, the Ontario forest stats file from Chapter 2, and the BC air-quality file from Chapter 6.

# Read all three
bc <- read_excel(here("data", "bc_disturbance_reforestation.xlsx"),
                 sheet = "Data") |>
  mutate(province = "BC", source = "silviculture")

ontario <- read_excel(here("data", "on_forest_statistics_1.xlsx"),
                      sheet = "Data") |>
  mutate(province = "ON", source = "forest_stats")

# Stack the two harvest-area summaries from different schemas
# (we need to pick comparable columns first)
bc_harvest <- bc |>
  select(province, year = Fiscal_Year, area_ha = Harvested_ha)

ontario_harvest <- ontario |>
  group_by(year = 2021) |>
  summarise(area_ha = sum(SumOfTotalHa, na.rm = TRUE)) |>
  mutate(province = "ON")

combined_harvest <- bind_rows(bc_harvest, ontario_harvest)
combined_harvest |> tail(5)

This is the shape of a working analyst’s day: take three files, line up the columns, stack them, and produce a single comparable summary. Every step is in code; no copy-paste; fully reproducible.

8.13 Active learning

8.13.1 Activity 1 — Pivot longer

Take bc |> select(Fiscal_Year, Harvested_ha, Natural_Disturbance_ha) and pivot it longer. How many rows does the result have? (Hint: nrow(bc) × 2.)

8.13.2 Activity 2 — All four joins

Using the plots and districts tribbles from the chapter, run all four join types and report the row count of each. Confirm the table in “A picture of all four” describes what you see.

8.13.3 Activity 3 — Filtering joins

Use anti_join to find plots with no biogeoclimatic-zone match. Then use semi_join to keep only plots that do have a match. What is the relationship between the two row counts?

8.13.4 Activity 4 — Bind rows with tags

Take the first 5 rows and last 5 rows of bc. Tag them with a half column (“first” / “last”). Stack them with bind_rows. Group by half and summarise the mean reforestation.

8.13.5 Activity 5 — Detect duplicate keys

Add an extra row to districts that duplicates Vancouver but gives a different bec_zone. Run a left_join. What does the row count of the result tell you?

8.14 Practice Demo Lab

ImportantPractice demo only

This is a guided practice lab in the OER book, designed to help you learn the workflow before completing the official lab assignment on Canvas. The Canvas lab is the graded assignment. Use this activity to practice, compare your work with the reference solution, and learn how to write an AI verification prompt.

This demo lab has six parts — work them in order:

  1. Your attempt. Work through the tasks below.

  2. Reference solution. Compare against the worked examples earlier in this chapter (your public reference). The graded Canvas lab has its own private answer key — do not copy demo solutions into a Canvas submission.

  3. Compare your work with the reference. Where did it match? Where did it differ? What caused the difference, and how did you fix it?

  4. Write your own AI verification prompt. Ask an AI to check your reasoning, code, and outputs — not to produce the answer for you.

  5. Model AI verification prompt.

    “I am doing a FRST 232 practice demo lab. My dataset is [file name] (columns [columns]). I attempted [paste your steps or code] and got [paste the output]. Explain what my work does line by line, tell me whether it answers the task, and list the checks I can run to verify it against the chapter’s worked example. Do not give me the final answer.”

  6. Reflection: what changed after checking? In two or three sentences — did your attempt hold up? what did you fix? what will you check first next time?

NoteLab: Combine three forestry sources

Work in groups of 3 or 4. The lab builds one combined tibble from the BC silviculture, Ontario forest stats, and BC air-quality files.

Task 1 — Inventory. Each member loads all three files. As a group, list which columns are comparable across files (e.g., year, area_ha).

Task 2 — Select comparable columns. Write select() calls that produce a uniform schema (e.g., province, year, area_ha) for each of the BC and Ontario files. The air-quality file is the odd one out; pick a different summary axis for it.

Task 3 — Stack with bind_rows(). Combine the BC and Ontario harvest summaries into one tibble. Tag each row with the province.

Task 4 — Diagnose. Run count(province) to confirm both provinces appear. Run colSums(is.na(combined)) to check for unexpected NAs after the bind.

Task 5 — Reflect. Discuss: what did you have to decide during the column-matching that the code itself cannot decide for you? (Hint: units, time aggregation, scope.)

Task 6 — Submit. Each group submits: - the combined tibble (printed as a kable table in the qmd), - a one-paragraph note describing the decisions made in Task 5, - all group members’ names.

TipReference solution — download

Open the Chapter 8 reference solution (rendered HTML) — the completed, rendered solution for this demo lab. Open it after your own attempt and use it to check your work.

This is the public practice solution. The graded lab on Canvas has its own private answer key — do not copy this into a Canvas submission.

8.15 Exercises

  1. Pivot wider then longer. Take bc_long (built in the chapter), pivot it wider, then pivot it longer again. Confirm you get back the original.

  2. Inner join row count. Use the plots and districts tribbles. What is nrow(inner_join(plots, districts, by = "district"))? Confirm by counting matched districts manually.

  3. Find unmatched districts. Add three more rows to plots with district names that do not exist in districts. Use anti_join() to find them.

  4. Join on two keys. Build a tiny measurements tibble with plot_id, year, and dbh_cm. Build a species tibble with plot_id, year, and species_code. Join on both keys at once.

  5. Bind rows from a folder. Create two BC silviculture sub-extracts: one for 1987-2004, one for 2005-2023. Stack them with bind_rows. Confirm the result is identical to the original full file.

  6. Tag and combine. Build a province column for both bc and a small ontario sample (the totals from your Chapter 2 work). Combine and compute mean harvest per province.

  7. Diagnostic before trust. For your final combined tibble, run colSums(is.na(combined)). Are there unexpected NAs? Trace each unexpected column back to which file lacks that variable.

  8. Optional, harder. Use pivot_longer() followed by pivot_wider() to transpose a small summary tibble (rows become columns and vice versa). Try it on a 3 × 3 summary you build by hand.

8.16 Optional, advanced

8.16.1 left_join with multiple matches — fan-out

If the right tibble has duplicate keys, every match fans out into multiple rows:

districts_dup <- tribble(
  ~district,    ~bec_zone,
  "Vancouver",  "CWHdm",
  "Vancouver",  "CWHxm"           # duplicate key!
)

left_join(plots, districts_dup, by = "district")
# Now Vancouver plots appear TWICE in the result.

This is rarely what you want for a lookup. The fix is to deduplicate the reference table first:

districts_dedup <- districts_dup |> distinct(district, .keep_all = TRUE)

8.16.2 relationship argument in dplyr ≥ 1.1

dplyr 1.1 introduced relationship = "one-to-one" (or "one-to-many", etc.) on joins. Use it to make join cardinality assumptions explicit — the code errors loudly if the actual data violates the assumption.

left_join(plots, districts, by = "district",
          relationship = "many-to-one")

If districts accidentally has duplicate keys, the join now errors instead of silently fanning out. Adopt this for any production analysis.

8.17 A glimpse ahead: From combined data to charts

Now that you can build a clean, combined tibble, the next step is to visualise it. Chapter 9 covers ggplot2:

Excel chart ggplot2 geom
Column / bar chart geom_col() / geom_bar()
Line chart geom_line()
Scatter plot geom_point()
Histogram geom_histogram()
Box plot geom_boxplot()
Small multiples (multiple charts side by side) facet_wrap()

The chapter walks through the grammar of graphics: aesthetics (x, y, color, size, shape) mapped to data columns, geoms that draw the shapes, and themes that polish the look.

The chart you will build at the end of Chapter 9 is one that tells a story from the cleaned and combined data you produced in this chapter.

8.18 Self-assessment quiz

Click the answer you think is correct.

1. The three rules of tidy data are —
  • one row per file, one column per chapter, one cell per number
  • one row per observation, one column per variable, one cell per value
  • one row per year, one column per province, one cell per measurement
  • there are no rules
These three rules underpin every tidyverse verb.
2. pivot_longer() turns —
  • a long tibble into a wide tibble
  • a wide tibble (one column per group) into a long tibble (one row per group × observation)
  • a tibble into a chart
  • a CSV into an Excel file
It is the inverse of pivot_wider().
3. left_join(A, B, by = "key") returns —
  • Only rows that match in both A and B
  • All rows of A, with B's columns filled in where the key matches and NA where it does not
  • All rows of B with A's columns
  • A random sample
A is the "left" tibble — it is preserved. B's matched columns are appended.
4. inner_join(A, B) returns —
  • All rows of A
  • Only rows where the key matches in both tibbles
  • All rows of B
  • An error
Inner join drops unmatched rows from both sides.
5. full_join(A, B) returns —
  • Only rows in both
  • All rows from A and all rows from B, with NA where one side has no match
  • A vector of TRUE/FALSE
  • A random subset
Full join preserves everything.
6. anti_join(A, B, by = "key") returns —
  • All rows of A and B combined
  • Only rows of A whose key has NO match in B (and adds no columns)
  • Only matched rows
  • A summary
Useful for data audits — "which entries in my data have no entry in the reference table?"
7. semi_join(A, B, by = "key") returns —
  • All rows of A plus all of B's columns
  • Only rows of A whose key DOES match in B (and adds no columns)
  • An error
  • A logical vector
A filtering join — keep A's rows that have a partner in B, without taking B's columns.
8. To stack two tibbles end to end (same columns), you use —
  • bind_cols()
  • bind_rows()
  • left_join()
  • pivot_wider()
bind_rows() matches columns by name and stacks rows.
9. bind_cols(A, B) requires —
  • Matching key columns
  • That both tibbles have the same number of rows in the same order
  • That both tibbles share at least one column
  • Nothing — it always works
bind_cols() glues by row position with no key matching. Risky — almost always prefer left_join().
10. left_join returned more rows than the left tibble has. This means —
  • The join worked correctly
  • The right tibble has duplicate keys, and the join silently fanned out
  • There is a bug in dplyr
  • R is broken
When the right tibble has duplicate keys, each row in the left tibble matches multiple rows in the right — fan-out. The fix is to deduplicate the reference table.
11. To join on TWO key columns at once —
  • Run two separate joins
  • left_join(A, B, by = c("plot_id", "year"))
  • pivot_wider() first
  • It is not possible
by can take a vector of multiple column names.
12. To join on differently-named keys: column "plot_id" in A and "plot" in B —
  • left_join(A, B, by = "plot_id")
  • left_join(A, B, by = c("plot_id" = "plot"))
  • Rename one of them first
  • It is not possible
Named character vector: c("name_in_A" = "name_in_B").
13. pivot_longer(cols = c(a, b, c), names_to = "var", values_to = "val") on a tibble of 10 rows produces —
  • 10 rows
  • 30 rows (10 × 3 columns pivoted)
  • 3 rows
  • An error
Each row contributes one new row per pivoted column.
14. Tidy ggplot2 plotting prefers data in —
  • Wide format
  • Long format
  • Either, depending
  • CSV format
ggplot2's grammar maps columns to aesthetics. One row per observation maps cleanly. Wide data usually needs a pivot_longer before plotting.
15. The Excel function VLOOKUP is closest to —
  • filter()
  • left_join()
  • pivot_wider()
  • bind_rows()
VLOOKUP attaches a value from a reference table to your main data based on a key — exactly what left_join() does.
16. The "copy-paste-append" pattern in Excel (stacking two sheets of the same shape) is closest to —
  • left_join()
  • bind_rows()
  • pivot_longer()
  • summarise()
bind_rows() stacks tibbles by name-matched columns.
17. A "data audit" question — *"which entries in my data have no entry in the reference table?"* — is answered by —
  • inner_join()
  • left_join()
  • anti_join(main_data, reference, by = "key")
  • summarise()
anti_join returns exactly the rows you cannot match.
18. After left_join(), you see NAs in the right-tibble columns. What does that mean?
  • The data is corrupted
  • Some rows in the left tibble had no matching key in the right tibble
  • The wrong column was joined
  • Nothing
An expected outcome — left_join preserves all left rows and inserts NA for unmatched ones. Use anti_join to enumerate which rows had no match.
19. The "stack files from multiple provinces into one analysis" workflow uses —
  • A join
  • bind_rows() with a province column added to each file before binding
  • pivot_wider()
  • summarise()
Same schema, different sources → stack with bind_rows(). Always add a tagging column first so you can group by source later.
20. Reflection. Of the verbs in this chapter, which one most changes how you would approach a multi-file analysis you have done in Excel before? Why?
Common reflections: left_join replaces all my VLOOKUPs — no silent first-match bugs · anti_join is a magic word for "what is missing?" — Excel had no clean equivalent · bind_rows means I can stop maintaining a 12-sheet "master" workbook by hand · pivot_longer reframes what "data" means — I used to live in wide; now I see why long is the analyst's format.

8.19 AI as a debugging companion

TipUseful prompts for this chapter
  • “My left_join returned 50 rows but the left tibble has 30 rows. What does that mean and how do I fix it?”

  • “How do I find which rows in tibble A have no matching key in tibble B? Show me with anti_join.”

  • “My pivot_longer call returns an error: ‘columns must be numeric’. What is wrong?”

8.20 Reading

Instructor notes

  • Spend most time on left_join + anti_join — together they cover 80% of real join needs. The other join types are easier once those two are solid.
  • The join diagnostics habit is the most transferable skill in this chapter. Insist learners run row counts before trusting any join.
  • bind_cols() is mentioned for completeness but almost always wrong. Highlight the warning callout.

8.20.1 Materials provided

File Purpose
frst232_ch08_learner.qmd Quarto starter with TODO chunks.
frst232_ch08_solutions.qmd Completed solutions. Do not distribute.
quiz_ch08.html Standalone interactive quiz.

8.20.2 Sample numbers

Quantity Approximate value
nrow(bc_wide) 37
nrow(bc_long) after pivot_longer on 3 cols 111
Plot–district left_join row count 5
Plot–district inner_join row count 4
Plot–district full_join row count 6
Plot–district anti_join row count (plots→districts) 1 (Nanaimo)

8.20.3 Common stumbling points

  • Confusing left_join and bind_rows. Walk through side-by-side (more columns) vs end-to-end (more rows).
  • Forgetting by = "...". dplyr will try to guess the join key from common column names — sometimes wrong. Insist on explicit by for every join.
  • Fan-out from duplicate keys. Teach the row-count check before trusting any join.
  • bind_rows column-name mismatch. Same data with different column names will produce extra columns full of NA. Use rename() first to harmonise.