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 joinssemi_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
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.
The corresponding .qmd source file.
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:
One row per observation.
One column per variable.
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.4pivot_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 typesbc_wide <- bc |>select(Fiscal_Year, Harvested_ha, Natural_Disturbance_ha, Total_Disturbance_ha)head(bc_wide)
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
NoteAI prompt to check your work
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 plotsplots <-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 zonedistricts <-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.2left_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.3inner_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.4right_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.5full_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.1semi_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.2anti_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:
by = c("col_A" = "col_B") reads “match column col_A in measurements to column col_B in reference”.
8.8bind_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 halvesbc_pre2005 <- bc |>filter(Fiscal_Year <2005)bc_post2005 <- bc |>filter(Fiscal_Year >=2005)nrow(bc_pre2005); nrow(bc_post2005)
# 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.9bind_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 firstnrow(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 weatherair_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 threebc <-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:
Your attempt. Work through the tasks below.
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.
Compare your work with the reference. Where did it match? Where did it differ? What caused the difference, and how did you fix it?
Write your own AI verification prompt. Ask an AI to check your reasoning, code, and outputs — not to produce the answer for you.
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.”
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.
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
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.
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.
Find unmatched districts. Add three more rows to plots with district names that do not exist in districts. Use anti_join() to find them.
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.
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.
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.
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.
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.1left_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:
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
R for Data Science — chapter 19 Joins, chapter 5 Data tidying. Free at https://r4ds.hadley.nz/.