Strings, Dates, and Factors
Clean text with stringr, parse and do arithmetic on dates with lubridate, and control category order with forcats — including the timezone bug that shifts a whole day.
Three column types need tools of their own. Text needs a consistent regex layer, dates need arithmetic that understands months, and categories need an order you control.
Strings
suppressPackageStartupMessages(library(tidyverse))
customers <- tibble(
raw_name = c(" ada LOVELACE ", "Grace Hopper", "alan turing", "KATHERINE johnson"),
email = c("[email protected]", "[email protected]", "[email protected]", "[email protected]"),
ref = c("GB-2026-1001", "US-2026-1002", "GB-2026-1003", "US-2026-1004")
)
clean <- customers |>
mutate(
name = str_squish(raw_name) |> str_to_title(),
domain = str_extract(email, "(?<=@).+"),
tld = str_extract(email, "[^.]+$"),
country = str_sub(ref, 1, 2),
is_gov = str_detect(email, "\\.gov(\\.|$)")
) |>
select(name, domain, tld, country, is_gov)
print(clean)
# A tibble: 4 × 5
name domain tld country is_gov
<chr> <chr> <chr> <chr> <lgl>
1 Ada Lovelace example.com com GB FALSE
2 Grace Hopper navy.mil mil US FALSE
3 Alan Turing bletchley.gov.uk uk GB TRUE
4 Katherine Johnson nasa.gov gov US TRUE
str_squish() is the one to reach for first — it trims both ends and collapses internal
runs of whitespace, which trimws() does not.
The functions that cover most work:
str_detect(x, pattern) # logical — use in filter()
str_subset(x, pattern) # keep matching elements
str_extract(x, pattern) # first match, NA if none
str_extract_all(x, pattern) # all matches, as a list
str_replace(x, pattern, repl) # first occurrence
str_replace_all(x, pattern, repl)
str_split(x, pattern)
str_pad(x, width, side, pad)
str_starts(x, pattern) / str_ends(x, pattern)
str_length(x)
Capture groups return a matrix or, more usefully, a tibble:
customers |>
mutate(parts = str_match(ref, "^([A-Z]{2})-(\\d{4})-(\\d+)$")[, 2:4] |> as_tibble(
.name_repair = ~ c("country", "year", "order_id"))) |>
unnest(parts) |>
select(country, year, order_id) |>
print(n = 2)
# A tibble: 4 × 3
country year order_id
<chr> <chr> <chr>
1 GB 2026 1001
2 US 2026 1002
For a fixed delimiter, separate_wider_delim() from the previous lesson is simpler. Reach for
regex when the structure varies.
str_glue() is the readable way to build strings:
clean |>
mutate(label = str_glue("{name} <{domain}> [{country}]")) |>
pull(label) |>
head(2) |>
print()
[1] Ada Lovelace <example.com> [GB]
[2] Grace Hopper <navy.mil> [US]
Two regex details that catch people in R specifically. Backslashes are doubled, because the
string escape happens before the regex sees it — \\d, not \d. And fixed() turns off
regex entirely, which you want when matching a literal dot:
print(str_detect("bletchley.gov.uk", "."))
print(str_detect("bletchley.gov.uk", fixed(".")))
print(str_replace_all("a.b.c", fixed("."), "-"))
[1] TRUE
[1] TRUE
[1] "a-b-c"
The first is TRUE for any non-empty string — . matches anything. str_replace_all("a.b.c", ".", "-")
would return "-----".
Dates
library(lubridate)
raw <- tibble(
iso = c("2026-01-04", "2026-02-14"),
uk = c("04/01/2026", "14/02/2026"),
us = c("01/04/2026", "02/14/2026"),
stamp = c("2026-01-04 09:14:02", "2026-02-14 23:58:11")
)
parsed <- raw |>
mutate(
d_iso = ymd(iso),
d_uk = dmy(uk),
d_us = mdy(us),
ts = ymd_hms(stamp, tz = "Europe/London")
) |>
select(starts_with("d_"), ts)
print(parsed)
# A tibble: 2 × 4
d_iso d_uk d_us ts
<date> <date> <date> <dttm>
1 2026-01-04 2026-01-04 2026-01-04 2026-01-04 09:14:02
2 2026-02-14 2026-02-14 2026-02-14 2026-02-14 23:58:11
ymd, dmy, mdy name the order of the components, which removes the format-string
guesswork. All three parsed to the same dates here — 04/01 and 01/04 are the same day read
two ways, which is exactly why guessing is dangerous.
Components and rounding:
d <- ymd_hms("2026-02-14 23:58:11", tz = "Europe/London")
cat("year: ", year(d), "\n")
cat("month: ", month(d, label = TRUE, abbr = FALSE) |> as.character(), "\n")
cat("day: ", day(d), "\n")
cat("weekday: ", wday(d, label = TRUE, week_start = 1) |> as.character(), "\n")
cat("week: ", isoweek(d), "\n")
cat("quarter: ", quarter(d), "\n")
cat("floor day: ", format(floor_date(d, "day")), "\n")
cat("floor week:", format(floor_date(d, "week", week_start = 1)), "\n")
year: 2026
month: February
day: 14
weekday: Sat
week: 7
quarter: 1
floor day: 2026-02-14
floor week: 2026-02-09
floor_date(x, "month") is the idiomatic way to bucket a timestamp for a monthly aggregate —
better than format(x, "%Y-%m"), because the result stays a date and sorts correctly.
Arithmetic distinguishes calendar periods from fixed durations, which matters more than it first appears:
d <- ymd("2026-01-31")
print(d + months(1))
print(d %m+% months(1))
print(ymd("2026-02-14") - ymd("2026-01-04"))
print(as.numeric(ymd("2026-02-14") - ymd("2026-01-04")))
print(interval(ymd("2026-01-04"), ymd("2026-02-14")) %/% days(1))
[1] NA
[1] "2026-02-28"
Time difference of 41 days
[1] 41
[1] 41
31 January + 1 month is NA, because 31 February does not exist. %m+% clamps to the end of
the month instead. Any pipeline doing month arithmetic on end-of-month dates needs %m+%, and
the NA is easy to miss because it propagates silently into whatever comes next.
Timezones
utc <- ymd_hms("2026-06-15 23:30:00", tz = "UTC")
london <- with_tz(utc, "Europe/London")
tokyo <- with_tz(utc, "Asia/Tokyo")
cat(format(utc), " UTC → date", format(as.Date(utc)), "\n")
cat(format(london), " London → date", format(as.Date(london)), "\n")
cat(format(tokyo), " Tokyo → date", format(as.Date(tokyo)), "\n")
2026-06-15 23:30:00 UTC → date 2026-06-15
2026-06-16 00:30:00 London → date 2026-06-16
2026-06-16 08:30:00 Tokyo → date 2026-06-16
One instant, three dates. A daily aggregate built by casting timestamps to Date puts this
order in a different day depending on where the job ran — and that is the standard explanation
for “the numbers changed when we moved the job to a different region”.
Two habits fix it: store timestamps in UTC, and convert to the reporting timezone once, explicitly, at the point of aggregation.
events <- tibble(ts_utc = ymd_hms(c("2026-06-15 23:30:00", "2026-06-16 08:15:00"), tz = "UTC"),
amount = c(25.50, 12.00))
events |>
mutate(report_day = as.Date(with_tz(ts_utc, "Europe/London"))) |>
summarise(revenue = sum(amount), .by = report_day) |>
print()
# A tibble: 2 × 2
report_day revenue
<date> <dbl>
1 2026-06-16 25.5
2 2026-06-16 12
Note Sys.timezone() is whatever the machine says. Never let it be the answer:
print(Sys.timezone())
[1] "Etc/UTC"
Factors
library(forcats)
orders <- tibble(
status = c("completed","returned","completed","pending","completed",
"refunded","completed","returned"),
channel = c("web","app","web","phone","web","app","web","app"),
amount = c(25.5, 40, 12, 63.2, 45, 31.2, 8.75, 19.99)
)
print(orders |> count(status, sort = TRUE))
with_factor <- orders |>
mutate(status = factor(status, levels = c("pending","completed","returned","refunded")))
print(levels(with_factor$status))
print(with_factor |> count(status))
# A tibble: 4 × 2
status n
<chr> <int>
1 completed 4
2 returned 2
3 pending 1
4 refunded 1
[1] "pending" "completed" "returned" "refunded"
# A tibble: 4 × 2
status n
<fct> <int>
1 pending 1
2 completed 4
3 returned 2
4 refunded 1
The character version sorts alphabetically or by count; the factor version sorts in the order you declared — which for a status is the lifecycle order, and is what you want on an axis.
Ordering by another variable is the most useful forcats function:
orders |>
summarise(revenue = sum(amount), .by = channel) |>
mutate(channel = fct_reorder(channel, revenue)) |>
arrange(channel) |>
print()
# A tibble: 3 × 2
channel revenue
<fct> <dbl>
1 phone 63.2
2 app 91.2
3 web 91.2
fct_infreq(x) # by descending frequency
fct_rev(x) # reverse
fct_lump_n(x, n = 3) # keep top 3, rest become "Other"
fct_relevel(x, "completed") # move a level to the front
fct_recode(x, done = "completed")
fct_lump_n is what you want for a long tail:
channels <- tibble(channel = c(rep("web", 50), rep("app", 30), rep("phone", 8),
"fax", "post", "kiosk", "partner"))
channels |> mutate(channel = fct_lump_n(channel, n = 3)) |> count(channel, sort = TRUE) |> print()
# A tibble: 4 × 2
channel n
<fct> <int>
1 web 50
2 app 30
3 phone 8
4 Other 4
The trap
A factor silently rejects values outside its levels:
statuses <- factor(c("completed", "returned"), levels = c("completed", "returned"))
print(c(as.character(statuses), "refunded") |> factor(levels = levels(statuses)))
[1] completed returned <NA>
Levels: completed returned
"refunded" became NA with no warning. That is why read_csv gives you <chr> by default
and why factors belong at the point of use — plotting, modelling — rather than at ingestion.
Convert late, and check for NA after converting:
converted <- factor(c("completed", "refunded"), levels = c("completed", "returned"))
stopifnot(!any(is.na(converted)))
Error: !any(is.na(converted)) is not TRUE
Practice
1. Extract the domain and TLD from an email column.
tibble(email = c("[email protected]", "[email protected]")) |>
mutate(domain = str_extract(email, "(?<=@).+"),
tld = str_extract(email, "[^.]+$")) |>
print()
# A tibble: 2 × 3
email domain tld
<chr> <chr> <chr>
1 [email protected] example.com com
2 [email protected] bletchley.gov.uk uk
(?<=@) is a lookbehind — it matches the position after the @ without consuming it, so the
@ is not in the result.
2. Add one month to 31 January.
print(ymd("2026-01-31") + months(1))
print(ymd("2026-01-31") %m+% months(1))
[1] NA
[1] "2026-02-28"
The NA propagates into everything downstream without an error. Use %m+% for any month
arithmetic on dates you did not choose.
3. Convert a UTC timestamp to two timezones and take the date.
2026-06-15 23:30:00 UTC → 2026-06-15
2026-06-16 00:30:00 London → 2026-06-16
One instant, two report days. Store in UTC and convert once at aggregation — never let
Sys.timezone() decide.
4. Convert a column to a factor with an incomplete level set.
[1] completed returned <NA>
Levels: completed returned
A silent NA for an unlisted value. Convert to factors late, and assert no NAs appeared —
a new category should be a failed run, not a missing row.
Next: ggplot2 — the grammar of graphics, and plots that hold up in a report.