Common Code 15 — Strings and Factors
stringr for text cleaning, forcats for plot order
How to arrange and work with factors
Strings and factors
Two related but distinct problems: strings (stringr) deal with the content of text — trimming whitespace, detecting patterns, fixing capitalisation. Factors (forcats) deal with the order of categories — controlling which group appears first on a plot axis or in a table.
⬇️ Download the companion R script:
15_strings_factors.R
library(tidyverse)
library(palmerpenguins)
library(nycflights13)PART 1 · Strings with stringr
1 · Case conversion
The single most common data-cleaning fix: inconsistent capitalisation makes "Sunny", "SUNNY", and "sunny" three different groups.
str_to_lower("SLIMY SCULPIN") # "slimy sculpin"
str_to_upper("cottus cognatus") # "COTTUS COGNATUS"
str_to_title("slimy sculpin") # "Slimy Sculpin"
# Fix all character columns after import
penguins |>
mutate(across(where(is.character), str_to_lower))2 · Trimming whitespace
"sunny " and "sunny" are different strings to R. This causes phantom extra factor levels and failed joins.
str_trim(" sunny ") # "sunny" — removes leading and trailing spaces
str_squish("sunny shady") # "sunny shady" — also collapses internal spaces
# Apply after every import
penguins |>
mutate(across(where(is.character), str_trim))After importing from Excel, always run tabyl(species) or count(species) to check for phantom duplicate levels. "sunny" and "sunny " will each appear as a separate row — the giveaway.
3 · Detecting patterns
str_detect("brook trout", "trout") # TRUE
str_detect("sculpin", "trout") # FALSE
# Filter rows where species name contains "trout"
tibble(species = c("brook trout","lake trout","sculpin","walleye")) |>
filter(str_detect(species, "trout"))
# Case-insensitive search
str_detect("Brook Trout", regex("trout", ignore_case = TRUE)) # TRUE4 · Replacing patterns
str_replace("bill_length_mm", "_mm", "") # "bill_length" — first match
str_replace_all("length_mm_mm", "_mm", "") # "length" — all matches
# Clean column names that have units embedded like "Length (mm)"
tibble(`Length (mm)` = 1:3, `Mass (g)` = 4:6) |>
rename_with(~ str_replace_all(.x, "\\s*\\(.*\\)", "")) |> # remove (units)
rename_with(str_to_lower) |>
rename_with(~ str_replace_all(.x, " ", "_"))5 · Extracting substrings
# Extract a pattern from a field label like "trout_S01_2026"
tibble(label = c("trout_S01_2026","sculpin_S02_2026","bass_S01_2025")) |>
mutate(
species = str_extract(label, "^[a-z]+"), # first word
site = str_extract(label, "S\\d+"), # "S" + digits
year = str_extract(label, "\\d{4}$") # 4-digit year at end
)6 · Splitting and combining strings
# Split "Lake_Superior_Site01" into components
tibble(id = c("Lake_Superior_Site01","Lake_Michigan_Site02")) |>
separate_wider_delim(
cols = id,
delim = "_",
names = c("water_body_1","water_body_2","site")
) |>
unite("lake", water_body_1, water_body_2, sep = " ")
# Pad site codes to consistent width
str_pad(1:5, width = 2, pad = "0") # "01" "02" "03" "04" "05"separate_wider_delim() vs separate()
separate_wider_delim() is the modern tidyr function (tidyr ≥ 1.3). The older separate() still works but is superseded. Use separate_wider_delim() in new code.
PART 2 · Factors with forcats
7 · Why factors control plot order
By default, R puts categorical variables in alphabetical order on plot axes. That is almost never the order you want. Factors let you set the order explicitly.
# Default: Adelie, Chinstrap, Gentoo (alphabetical — fine here by accident)
penguins |> count(species) |>
ggplot(aes(x = species, y = n)) + geom_col()8 · fct_relevel() — set a custom order
penguins |>
mutate(species = fct_relevel(species,
"Gentoo","Chinstrap","Adelie")) |>
count(species) |>
ggplot(aes(x = species, y = n)) +
geom_col(fill = "steelblue", alpha = 0.7) +
labs(title = "Custom order: Gentoo, Chinstrap, Adelie") +
theme_minimal()9 · fct_reorder() — sort by a numeric value
The most useful forcats function. Sorts bars by their actual value — almost always better than alphabetical.
penguins |>
drop_na(body_mass_g) |>
group_by(species) |>
summarise(mean_mass = mean(body_mass_g), .groups = "drop") |>
mutate(species = fct_reorder(species, mean_mass)) |> # sort ascending
ggplot(aes(x = species, y = mean_mass, fill = species)) +
geom_col(alpha = 0.7) +
labs(x = NULL, y = "Mean body mass (g)",
title = "Bars sorted by mean body mass") +
theme_minimal() +
theme(legend.position = "none")desc()
mutate(species = fct_reorder(species, desc(mean_mass)))Or for horizontal bar charts, coord_flip() combined with ascending sort puts the largest bar at the top — the most readable layout for many groups.
10 · fct_infreq() — sort by count
flights |>
mutate(carrier = fct_infreq(carrier)) |>
count(carrier) |>
ggplot(aes(x = carrier, y = n)) +
geom_col(fill = "steelblue", alpha = 0.7) +
labs(title = "Airlines sorted by flight count (most to least)") +
theme_minimal()11 · fct_lump_n() — collapse rare categories
When you have many levels but only want to highlight the top ones:
flights |>
mutate(carrier = fct_lump_n(carrier, n = 5)) |>
count(carrier, sort = TRUE)
# Top 5 carriers kept; all others collapsed to "Other"12 · Ecological example — species by body size
The canonical use case: fish or species ordered from smallest to largest on a horizontal axis.
fish_df |>
group_by(species) |>
mutate(species = fct_reorder(species, mass_g, .fun = mean)) |>
ggplot(aes(x = species, y = mass_g, fill = species)) +
geom_boxplot(alpha = 0.6) +
coord_flip() +
labs(x = NULL, y = "Mass (g)",
title = "Fish species ordered by mean body mass") +
theme_minimal() +
theme(legend.position = "none")Quick reference
Strings
| Task | Code |
|---|---|
| Lowercase | str_to_lower(x) |
| Title case | str_to_title(x) |
| Trim spaces | str_trim(x) |
| Collapse internal spaces | str_squish(x) |
| Detect pattern | str_detect(x, "pattern") |
| Replace first match | str_replace(x, "old", "new") |
| Replace all matches | str_replace_all(x, "old", "new") |
| Extract pattern | str_extract(x, "regex") |
| Pad to width | str_pad(x, width = 3, pad = "0") |
| Apply to all chr cols | mutate(across(where(is.character), str_trim)) |
Factors
| Task | Code |
|---|---|
| Custom order | fct_relevel(f, "B","A","C") |
| Sort by numeric variable | fct_reorder(f, numeric_col) |
| Sort by frequency | fct_infreq(f) |
| Collapse rare levels | fct_lump_n(f, n = 5) |
| Rename levels | fct_recode(f, "New" = "Old") |
End of Common Code 15 — Strings and Factors. Next: Common Code 16 — Writing functions.