Chapter 2 – Data Wrangling with {dplyr} and {tidyr}
Ewen Gallic
September 2, 2026
Disclaimers
The content of this presentation was adapted from the teaching material provided by Ségal Le Guern Herry who kindly shared his slide deck.
I used Gemini 3.6 Flash to help producing the slides (assistance in organizing content and producing clearer sentences).
Tidying Data
What is dplyr?
dplyr is a grammar of data manipulation. It provides a clean, consistent set of “verbs” for common data wrangling tasks.
It is part of a core ecosystem called tidyverse (you are encouraged to read the tidyverse style guide for clean code formatting).
Why should you use dplyr?
It is highly readable, expressive, and optimized for data manipulation workflows.
Handling Ultra-High-Dimensional with R
For ultra-high-dimensional datasets with tens of thousands of columns or gigabytes of memory overhead, high-performance packages like {data.table} offer an alternative approach.
Cheat Sheet
If you are willing to decorate your room, do not hesitate to print this awesome cheat sheet.
Core Syntax of dplyr
The functions in {dplyr} are designed for tabular data, they are built specifically to manipulate data.frame and tibble objects (more modern tables).
Every action is defined by a descriptive function, named a verb.
Example: mutate() creates, modifies, or deletes columns.
{dplyr} adopts a consistent function syntax:
\[
\text{verb}(\underbrace{.data}_{\text{1st argument}},\, \underbrace{other\, arguments}_{\text{2nd to } n\text{-th arguments}})
\]
Key Property of dplyr Verbs
The first argument is always a data frame, and the function always returns a new data frame. This structure makes chaining with pipes (|>) intuitive (see next slide).
The Pipe Operator (|> or %>%)
The pipe operator passes the output of the left side as the first argument to the function on the right side.
flowchart LR
A["<b>Crude Oil</b><br/><br/>Raw Dataset"]
B["<b>Refinery Station 1</b><br/><br/><code>filter(...)</code>"]
X["<b>More stations...</b><br/><br/><code>verb(...)</code>"]
C["<b>Refinery Station N</b><br/><br/><code>mutate(...)</code>"]
D["<b>Premium Fuel</b><br/><br/>Clean Data"]
A -->|"|>"| B
B -->|"|>"| X
X -->|"|>"| C
C -->|"|>"| D
style A fill:#ffe5cc,stroke:#cc6600
style B fill:#e6f0ff,stroke:#3366cc
style X fill:#f5f5f5,stroke:#888888,stroke-dasharray: 5 5
style C fill:#e6f0ff,stroke:#3366cc
style D fill:#e5f5e5,stroke:#339933
# Clean, sequential data flow!law |>filter(UGPA >3.0) |>group_by(race) |>summarise(mean_lsat =mean(LSAT))
Native Pipe vs. Magrittr
Use the native R pipe |> (R \(\ge\) 4.1.0). In older code bases, you may also see the {magrittr} pipe %>%.
Loading the Law School Dataset
To illustrate {dplyr} verbs, we load the law school dataset (Wightman 1998) using read_csv() from {readr}. This reads the .csv file into a tibble (the tidyverse version of a data frame):
# A tibble: 21,791 × 9
...1 race sex LSAT UGPA region_first ZFYA sander_index first_pf
<dbl> <chr> <dbl> <dbl> <dbl> <chr> <dbl> <dbl> <dbl>
1 0 White 1 39 3.1 GL -0.98 0.783 1
2 1 White 1 36 3 GL 0.09 0.736 1
3 2 White 2 30 3.1 MS -0.35 0.670 1
4 5 Hispanic 2 39 2.2 NE 0.58 0.697 1
5 6 White 1 37 3.4 GL -1.26 0.786 1
6 7 White 1 30.5 3.6 GL 0.3 0.724 1
7 8 White 2 36 3.6 GL -0.1 0.793 1
8 9 White 2 37 2.7 NE -0.12 0.720 0
9 13 White 1 37 2.6 GL 1.53 0.710 1
10 14 White 2 31 3.6 GL 0.34 0.730 1
# ℹ 21,781 more rows
⚙️ Reproducible File Paths with {here}
Hardcoding file paths or using setwd() breaks code when sharing projects across different operating systems and nested folder structures. The {here} package provides robust, platform-independent paths.
Fragile Approaches
Hardcoded Absolute Paths:
# Fails on macOS/Linux and other machines!read_csv("C:/Users/ewen/project/data/law_data.csv")
Manual setwd():
# Breaks Quarto rendering and script portabilitysetwd("data/")
Note that path separators are different depending on the OS: * Windows uses backslashes (\), * macOS/Linux use forward slashes (/)
LSAT score received by the student (measured on a 10 to 48 scale)
UGPA
numeric
Undergraduate GPA of the student (from 0 to 4.33)
region_first
character
Geographic region where the student took their first bar examination (Far West, Great Lakes, Midsouth, Midwest, Mountain West, Northeast, New England, Northwest, South Central, South East)
ZFYA
numeric
Standardized First-Year Average law school grades (FYA)
sander_index
numeric
Composite index weighting LSAT and UGPA (\(200 \times \text{UGPA} + \text{LSAT}\))
first_pf
numeric
Binary indicator for passing the bar exam on the first trial (1 = Pass, 0 = Fail)
Core dplyr Verbs Overview
Data wrangling in {dplyr} relies on a set of core “verbs” designed to manipulate tabular data:
Verb
Action
Typical Use Case
filter()
Subset rows
Keep observations matching specific logical criteria
arrange()
Reorder rows
Sort data in ascending or descending order
select()
Subset columns
Keep, drop, or rename specific variables
mutate()
Create / modify columns
Compute new variables or alter existing ones
summarise()
Aggregate data
Collapse multiple rows into group summary metrics
group_by()
Group observations
Structure operations to execute per category
filter(): Subsetting Rows
filter() selects rows (observations) based on logical conditions evaluating to TRUE.
Question
How do we isolate female students in our dataset?
library(dplyr)law |>filter(sex ==1)
# A tibble: 9,537 × 9
...1 race sex LSAT UGPA region_first ZFYA sander_index first_pf
<dbl> <chr> <dbl> <dbl> <dbl> <chr> <dbl> <dbl> <dbl>
1 0 White 1 39 3.1 GL -0.98 0.783 1
2 1 White 1 36 3 GL 0.09 0.736 1
3 6 White 1 37 3.4 GL -1.26 0.786 1
4 7 White 1 30.5 3.6 GL 0.3 0.724 1
5 13 White 1 37 2.6 GL 1.53 0.710 1
6 19 White 1 35 3.2 NE -0.17 0.742 1
7 24 White 1 36 3 GL 0.04 0.736 1
8 32 White 1 37 3.6 GL 0.81 0.805 1
9 44 White 1 36 2.7 NE -0.05 0.707 1
10 52 White 1 27 3.5 GL -1.05 0.671 0
# ℹ 9,527 more rows
filter(): Multiple Conditions
Combine multiple logical conditions using commas (which act as logical &) or explicit & / | operators.
Question
How do we isolate female students who passed the bar exam on their first attempt?
law |>filter(sex ==1, first_pf ==1)
# A tibble: 8,357 × 9
...1 race sex LSAT UGPA region_first ZFYA sander_index first_pf
<dbl> <chr> <dbl> <dbl> <dbl> <chr> <dbl> <dbl> <dbl>
1 0 White 1 39 3.1 GL -0.98 0.783 1
2 1 White 1 36 3 GL 0.09 0.736 1
3 6 White 1 37 3.4 GL -1.26 0.786 1
4 7 White 1 30.5 3.6 GL 0.3 0.724 1
5 13 White 1 37 2.6 GL 1.53 0.710 1
6 19 White 1 35 3.2 NE -0.17 0.742 1
7 24 White 1 36 3 GL 0.04 0.736 1
8 32 White 1 37 3.6 GL 0.81 0.805 1
9 44 White 1 36 2.7 NE -0.05 0.707 1
10 57 White 1 34 3.7 GL 1.23 0.777 1
# ℹ 8,347 more rows
arrange(): Sorting Rows
arrange() reorders rows based on values in one or more columns. By default, numeric variables are sorted in ascending order.
Question
How can we rank students from lowest to highest Undergraduate GPA?
law |>arrange(UGPA)
# A tibble: 21,791 × 9
...1 race sex LSAT UGPA region_first ZFYA sander_index first_pf
<dbl> <chr> <dbl> <dbl> <dbl> <chr> <dbl> <dbl> <dbl>
1 3791 White 1 36 0 FW 0.42 0.45 1
2 3890 White 1 39 0 FW -0.16 0.488 1
3 5984 White 2 44 1.5 FW 1.88 0.693 1
4 19592 White 2 29.5 1.6 SC 1.1 0.521 1
5 684 White 2 40 1.7 NE 0.52 0.662 1
6 5987 White 1 42 1.7 FW 0.57 0.687 1
7 6067 White 2 37 1.7 SE 0.82 0.624 1
8 15248 White 1 39.5 1.7 NE -0.94 0.656 0
9 4195 Black 2 26.5 1.8 MS -0.58 0.503 0
10 4199 Black 2 31 1.8 MS 0.95 0.559 0
# ℹ 21,781 more rows
arrange(): Multi-Column & Descending Sorting
Use desc() to sort in descending order. Additional columns break ties from previous variables.
Question
How can we group students by sex (descending) and rank them by highest UGPA within each group?
law |>arrange(desc(sex), desc(UGPA))
# A tibble: 21,791 × 9
...1 race sex LSAT UGPA region_first ZFYA sander_index first_pf
<dbl> <chr> <dbl> <dbl> <dbl> <chr> <dbl> <dbl> <dbl>
1 20757 White 2 45 4.2 GL -1.07 0.962 1
2 16300 White 2 44 4.1 NE 1.03 0.940 1
3 16893 White 2 43 4.1 GL 1.77 0.928 1
4 18867 White 2 44 4.1 Mt -0.5 0.940 1
5 18880 White 2 46 4.1 SC 0.78 0.965 1
6 18884 White 2 48 4.1 NE 1.31 0.990 1
7 499 White 2 43 4 Mt 0.87 0.918 1
8 1370 White 2 35 4 Mt 0.89 0.818 1
9 1818 White 2 37 4 MS 0.39 0.843 1
10 2004 White 2 39 4 GL 1.06 0.868 1
# ℹ 21,781 more rows
select(): Choosing Specific Columns
select() extracts specified variables from the dataset, dropping all others.
Question
How do we isolate only student demographics (race, sex) and their bar exam outcome (first_pf)?
law |>select(race, sex, first_pf)
# A tibble: 21,791 × 3
race sex first_pf
<chr> <dbl> <dbl>
1 White 1 1
2 White 1 1
3 White 2 1
4 Hispanic 2 1
5 White 1 1
6 White 1 1
7 White 2 1
8 White 2 0
9 White 1 1
10 White 2 1
# ℹ 21,781 more rows
select(): Excluding Columns
Use - before a column name to drop specific variables while keeping everything else.
Question
How do we remove the raw row index column (...1) from the dataset?
law |>select(-`...1`)
# A tibble: 21,791 × 8
race sex LSAT UGPA region_first ZFYA sander_index first_pf
<chr> <dbl> <dbl> <dbl> <chr> <dbl> <dbl> <dbl>
1 White 1 39 3.1 GL -0.98 0.783 1
2 White 1 36 3 GL 0.09 0.736 1
3 White 2 30 3.1 MS -0.35 0.670 1
4 Hispanic 2 39 2.2 NE 0.58 0.697 1
5 White 1 37 3.4 GL -1.26 0.786 1
6 White 1 30.5 3.6 GL 0.3 0.724 1
7 White 2 36 3.6 GL -0.1 0.793 1
8 White 2 37 2.7 NE -0.12 0.720 0
9 White 1 37 2.6 GL 1.53 0.710 1
10 White 2 31 3.6 GL 0.34 0.730 1
# ℹ 21,781 more rows
mutate(): Creating New Variables
mutate() creates new columns (or transforms existing ones) while preserving existing data.
Question
How can we construct a logical indicator identifying non-white students?
law |>mutate(is_non_white = race !="White") |>select(race, is_non_white)
# A tibble: 21,791 × 2
race is_non_white
<chr> <lgl>
1 White FALSE
2 White FALSE
3 White FALSE
4 Hispanic TRUE
5 White FALSE
6 White FALSE
7 White FALSE
8 White FALSE
9 White FALSE
10 White FALSE
# ℹ 21,781 more rows
mutate(): Multiple Variable Creation
Multiple new columns can be created inside a single mutate() call, separated by commas.
Question
How do we simultaneously create a is_non_white indicator and categorize LSAT into “high” vs. “low” scores?
# A tibble: 21,791 × 4
region_first LSAT mean_lsat_region lsat_vs_region
<chr> <dbl> <dbl> <chr>
1 GL 39 36.8 Above or Equal
2 GL 36 36.8 Below
3 MS 30 36.8 Below
4 NE 39 36.8 Above or Equal
5 GL 37 36.8 Above or Equal
6 GL 30.5 36.8 Below
7 GL 36 36.8 Below
8 NE 37 36.8 Above or Equal
9 GL 37 36.8 Above or Equal
10 GL 31 36.8 Below
# ℹ 21,781 more rows
Key Distinction: summarise() vs. mutate()
summarise() reduces the dataset to one row per group.
mutate() keeps all original rows, broadcasting the group statistic across every observation within that group.
Pay attention!
When using group_by() + mutate(): Always call ungroup() afterward when finished with grouped calculations.
When using group_by() + summarise(): remember to set the argument .groups ="drop" in summarise().
Task 1: Practice with dplyr Verbs
Using the law dataset and {dplyr} functions:
Create a subset of female students (sex == 1) who took the bar exam in the "Far West" region. Keep only the columns LSAT, UGPA, and first_pf.
Add a new variable called gpa_above_3 which is TRUE if UGPA > 3.0 and FALSE otherwise.
Calculate the average LSAT score and the overall first-time pass rate (first_pf) for each racial group (race). Sort the result by pass rate in descending order.
Calculate the average UGPA for each race group, and create a variable diff_from_race_gpa measuring the difference between a student’s UGPA and their race’s average UGPA.
Frequency Helpers: count() & tally()
Instead of group_by() + summarise(n =n()), {dplyr} provides shortcut functions:
count(): Groups data by specified variables, calculates group frequencies into a column named n, and keeps groups active for subsequent steps.
tally(): Calculates frequencies on an already grouped dataset.
# count() preserves active groupinglaw |>group_by(sex) |>count() |>mutate(pct = n /sum(n))
# A tibble: 2 × 3
# Groups: sex [2]
sex n pct
<dbl> <int> <dbl>
1 1 9537 1
2 2 12254 1
# A tibble: 2 × 3
sex n pct
<dbl> <int> <dbl>
1 1 9537 0.438
2 2 12254 0.562
Computing Relative Proportions Across Categories
Calculate the proportion of each racial group within each geographic region using count() followed by a grouped mutate():
Question
What is the racial breakdown within each bar examination region?
law |>count(region_first, race) |>group_by(region_first) |>mutate(prop = n /sum(n)) |>ungroup()
Row-Preserving Helpers: add_count() & add_tally()
add_count() and add_tally() append group frequencies directly as a new column (n) to the original table without collapsing rows (similar to combining group_by() + mutate(n =n())).
add_count(): Groups and adds the frequency column in one step.
law |>add_count(region_first) |>select(region_first, race, LSAT, n)
# A tibble: 21,791 × 4
region_first race LSAT n
<chr> <chr> <dbl> <int>
1 GL White 39 3822
2 GL White 36 3822
3 MS White 30 2346
4 NE Hispanic 39 4302
5 GL White 37 3822
6 GL White 30.5 3822
7 GL White 36 3822
8 NE White 37 4302
9 GL White 37 3822
10 GL White 31 3822
# ℹ 21,781 more rows
add_tally(): Adds frequency column to an existing grouped table.
law |>group_by(region_first) |>add_tally() |>select(region_first, race, LSAT, n)
# A tibble: 21,791 × 4
# Groups: region_first [11]
region_first race LSAT n
<chr> <chr> <dbl> <int>
1 GL White 39 3822
2 GL White 36 3822
3 MS White 30 2346
4 NE Hispanic 39 4302
5 GL White 37 3822
6 GL White 30.5 3822
7 GL White 36 3822
8 NE White 37 4302
9 GL White 37 3822
10 GL White 31 3822
# ℹ 21,781 more rows
Task 2: Quick Practice with Proportions
Using the law dataset and the helper functions you just learned:
Calculate the proportion of men (sex == 2) and women (sex == 1) within each racial group (race).
Dynamic Column Selection with where()
The where() helper from {tidyselect} selects columns based on predicate functions evaluating to TRUE or FALSE.
Question
How can we automatically select only continuous numeric features or categorical string variables?
# A tibble: 21,791 × 2
race region_first
<chr> <chr>
1 White GL
2 White GL
3 White MS
# ℹ 21,788 more rows
Other common predicate functions:where(is.logical), where(is.factor), or custom functions like where(\(x) is.numeric(x) &&mean(x, na.rm =TRUE) >10).
⚙️ Anonymous (Lambda) Functions in where()
Since R 4.1.0, \(x) provides a concise shorthand for defining inline anonymous (lambda) functions :
\(x) ... is identical to function(x) ....
How where() Evaluates Columns
where() passes each column vector of a data frame / tibble as the input argument x. The function must evaluate to a single TRUE or FALSE for each column.
⚙️ Going Through the Example
law |>select(where(\(x) is.numeric(x) &&mean(x, na.rm =TRUE) >10))
┌── Lambda definition (binds column vector to 'x')
│ ┌── Type Guard (must be TRUE to continue)
│ │ ┌── Short-circuit AND (prevents errors on text columns)
│ │ │ ┌── Column condition (returns single TRUE/FALSE)
┌─┴─┐┌─┴──────────┐ ┌┴┐┌─┴────────────────────────────┐
where(\(x) is.numeric(x) && mean(x, na.rm = TRUE) > 10)
\(x): Defines the inline function taking column vector x.
is.numeric(x): Returns TRUE or FALSE according to the class of column vector x (allows to filter out non numeric variables).
&& : If is.numeric(x) is FALSE, execution halts immediately for that column; which prevents mean() from throwing a runtime error on non-numeric data.
mean(x, na.rm = TRUE) > 10: Computes the column summary metric and tests the threshold.
Reading Chapter 15 in Wickham, Çetinkaya-Rundel, and Grolemund (2023) is recommended to learn how regular expressions work.
Sequenced Column Selection (billboard)
Question
The billboard dataset contains one row per song, with columns for track metadata such as artist and track, followed by weekly chart rankings. The columns wk1, wk2, wk3, and so on record the song’s rank in each week after release.
Assume we want to select the track metadata together with the first four weekly rankings.
{.col} is replaced by the original column name (e.g., LSAT).
{.fn} is replaced by the name of the function from the list (e.g., mean), creating LSAT_mean.
Applying across() to Categorical Variables
across() is equally effective for non-numeric columns. For example, we can identify the most frequent value (mode) for every character column in law.
# Compute the most common category for all text variableslaw |>summarise(across(.cols =where(is.character),.fns =list(most_frequent = \(x) names(which.max(table(x)))),.names ="{.col}_{.fn}" ) )
# A tibble: 1 × 2
race_most_frequent region_first_most_frequent
<chr> <chr>
1 White NE
Combining Numeric & Categorical Summaries
You can combine multiple across() calls within a single summarise() statement to aggregate numeric and categorical metrics simultaneously:
law |>group_by(region_first) |>summarise(# Count of students per regionn_students =n(),# Numeric summariesacross(.cols =c(LSAT, UGPA),.fns =list(avg = \(x) mean(x, na.rm =TRUE), med = \(x) median(x, na.rm =TRUE)),.names ="{.col}_{.fn}" ),# Categorical summaryacross(.cols =where(is.character),.fns =list(bottom_cat = \(x) names(which.min(table(x))),bottom_count = \(x) min(table(x)) ) ) )
# A tibble: 3 × 4
country `2021` `2022` `2023`
<chr> <dbl> <dbl> <dbl>
1 France 6.8 2.7 1.1
2 Germany 3.7 1.4 -0.3
3 Italy 8.9 4.2 0.7
Reshape growth_wide into long format using pivot_longer(). Your resulting dataset should have three columns: country, year, growth. Make sure that year is stored as an integer.
How many rows does the reshaped dataset contain? What does one row represent in this long dataset?
Starting from your long dataset, use pivot_wider() to return to the original wide format.
Suppose the GDP growth rate for Italy in 2022 was not recorded: