Skip to main content
R beginner Lesson 5 of 10

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.

Frequently Asked Questions

Why use stringr instead of base R string functions?
Consistent argument order — the string always comes first, so functions pipe — consistent `NA` handling, and one regex engine across every function. `gsub`, `grepl` and `regmatches` all work, with different argument orders and different escaping rules.
What is the difference between a Date and a POSIXct in R?
`Date` stores a calendar day with no time and no timezone. `POSIXct` stores an instant as seconds since the epoch, interpreted through a timezone. Storing a timestamp as `Date` silently truncates the time; comparing two `POSIXct` values with different timezones is where the day-shift bugs come from.
When should a column be a factor?
When it is genuinely categorical with a known set of levels, and you want a defined order for plotting or modelling. Otherwise leave it as character — factors silently drop values not in their levels, which turns a new category into `NA` rather than an error.
How do I control the order of bars in a ggplot?
Set the factor levels. `fct_reorder(name, value)` orders by another variable, `fct_infreq()` by frequency, and `fct_rev()` reverses. ggplot draws factor levels in their stored order, so ordering is a data step rather than a plotting argument.