Skip to content

05 · Working with Strings (stringr)

Base R has string functions (nchar(), substr(), paste(), grepl()), but their argument order and naming are famously inconsistent — some take the pattern first, some the string first, some use fixed = TRUE for literal matching and some use different flags entirely. stringr wraps the same underlying capability (via the stringi package) in a consistent family of functions that all start with str_ and always take the string as the first argument, making them easy to use in a dplyr pipe.

library(stringr)

products <- c("  Widget A ", "gadget-X", "TOOL_Z", "Gizmo Q99")

Basic operations

str_length(products)          # character count, like nchar()
# [1] 11  8  6  9

str_trim(products)            # remove leading/trailing whitespace
# [1] "Widget A" "gadget-X" "TOOL_Z"   "Gizmo Q99"

str_to_lower(products)
# [1] "  widget a " "gadget-x"    "tool_z"      "gizmo q99"

str_to_upper(products)
str_to_title(products)        # "  Widget A ", "Gadget-X", "Tool_z", "Gizmo Q99"

Every one of these takes the string first — no need to remember whether a particular base function wants nchar(x) or substr(x, start, stop) order.

Detecting and matching patterns

str_detect(products, "gadget")             # case-sensitive by default
# [1] FALSE  TRUE FALSE FALSE

str_detect(str_to_lower(products), "gadget")
# [1] FALSE  TRUE FALSE FALSE

str_starts(products, "  ")   # does it start with this literal text?
str_ends(products, "9")      # does it end with this?

str_count(products, "[aeiouAEIOU]")   # count pattern occurrences per string
# [1] 3 2 2 2

str_detect() returns one TRUE/FALSE per element — the stringr equivalent of base R's grepl(), but with the string always first and no separate pattern/x ordering to remember.

Extracting matches

codes <- c("ORD-2024-001", "ORD-2024-002", "INV-2023-099")

str_extract(codes, "[0-9]{4}")          # first match of a 4-digit run
# [1] "2024" "2024" "2023"

str_extract(codes, "^[A-Z]+")           # leading letters
# [1] "ORD" "ORD" "INV"

str_match(codes, "([A-Z]+)-([0-9]{4})-([0-9]+)")
#      [,1]           [,2]  [,3]   [,4]
# [1,] "ORD-2024-001" "ORD" "2024" "001"
# [2,] "ORD-2024-002" "ORD" "2024" "002"
# [3,] "INV-2023-099" "INV" "2023" "099"

str_match() with parenthesized groups () in the pattern returns a matrix: column 1 is the whole match, and each subsequent column is one capture group — useful for pulling several pieces out of a structured string (like a code, an ID, or a log line) in one call.

Replacing text

str_replace(products, "_", " ")          # replaces the FIRST match only
# [1] "  Widget A "  "gadget-X"     "TOOL Z"       "Gizmo Q99"

str_replace_all(products, "[-_]", " ")   # replaces EVERY match
# [1] "  Widget A "  "gadget X"     "TOOL Z"       "Gizmo Q99"

Trap: str_replace() (singular) only touches the first match per string — a common surprise for people expecting global replacement by default, since most other languages' basic "replace" defaults to global. Reach for str_replace_all() whenever you mean "every occurrence."

Splitting and joining

str_split("2024-01-15", "-")
# [[1]]
# [1] "2024" "01"   "15"

str_split_fixed("2024-01-15", "-", n = 3)   # returns a matrix, not a list
#      [,1]   [,2] [,3]
# [1,] "2024" "01" "15"

str_c("Order", "#", 42, sep = "-")           # concatenate, like paste()
# [1] "Order-#-42"

str_c(c("a", "b", "c"), collapse = ", ")     # join a vector into one string
# [1] "a, b, c"

str_split() returns a list (one element per input string, since different strings can split into different numbers of pieces) — a subtlety that trips people up when they expect a flat vector back. Use str_split_fixed() when you know every string splits into the same fixed number of parts and want a matrix/data frame directly, and unlist() on str_split()'s output if you only ever had one string to begin with.

Padding and truncating

str_pad(c("1", "22", "333"), width = 5, pad = "0")
# [1] "00001" "00022" "00333"

str_trunc("This is a very long product description", width = 20)
# [1] "This is a very lo..."

str_pad() is the idiomatic way to zero-pad IDs or align text columns without hand-rolling sprintf("%05d", x) logic for non-numeric strings.

Regular expressions vs. fixed strings

Every stringr function that takes a pattern treats it as a regular expression by default. If your "pattern" is a literal string that happens to contain regex metacharacters (., +, (, [, ...), wrap it in fixed() to match it literally instead:

prices <- c("$4.99", "$12.00", "4.99")

str_detect(prices, "4.99")           # "." matches ANY character here -- true for "4x99" too
str_detect(prices, fixed("4.99"))    # matches the literal text "4.99" only

This is a very common silent bug: a pattern like "3.14" or a file extension like ".csv" will match more than intended because . in regex means "any character," not "a literal period."

stringr cheat sheet

Task Function
Length str_length(x)
Trim whitespace str_trim(x)
Change case str_to_lower(x), str_to_upper(x), str_to_title(x)
Test for a pattern str_detect(x, pattern)
Count occurrences str_count(x, pattern)
Extract first match str_extract(x, pattern)
Extract capture groups str_match(x, pattern)
Replace first match str_replace(x, pattern, replacement)
Replace all matches str_replace_all(x, pattern, replacement)
Split str_split(x, pattern) (list) or str_split_fixed(x, pattern, n) (matrix)
Concatenate str_c(..., sep = "")
Join a vector into one string str_c(x, collapse = ", ")
Pad str_pad(x, width, pad = "0")
Match literally, not as regex str_detect(x, fixed("..."))

How It Actually Works

Base R strings are stored as character vectors where each element points to an entry in R's global string pool (CHARSXP cache) — a hash table the interpreter maintains so that identical strings appearing anywhere in your session share one underlying memory allocation rather than being duplicated. This is why comparing many repeated strings is cheap: R can often short-circuit equality by comparing pool pointers before ever comparing bytes.

stringr functions are thin, consistently-ordered (str_*(string, pattern)) wrappers around ICU (International Components for Unicode) regular-expression and string routines linked into R via the stringi package, rather than R's own base regex engine (POSIX extended or, with perl = TRUE, PCRE). That's why stringr handles Unicode case folding, locale-aware sorting, and multi-byte characters more consistently than base grepl()/gsub() — it's not "the same regex engine with a nicer API," it's a different, more complete engine underneath. Pattern matching itself works by compiling your regex string into a finite-state automaton once, then running that automaton over each input string's bytes — which is why using a pre-compiled pattern object over thousands of calls, where supported, avoids re-compiling the same regex repeatedly.

Exercise

Given emails <- c("alice@example.com", "bob.smith@company.org", "not-an-email"): use str_detect() with a regex pattern to build a logical vector flagging which entries look like a valid email (contain @ and at least one . after it), then use str_extract() to pull out just the domain (everything after @) for the entries that pass. Finally, use fixed() to demonstrate the difference between matching a literal . in "company.org" versus an unescaped . in a regex pattern.