Text Wrangling with stringr
Reviewed & published by Brayan K
stringr is the tidyverse package for working with text, giving every string operation a consistent str_ name with the string always first — so detecting, replacing, extracting, and splitting text becomes predictable and pipe-friendly.
Part of the free R course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
By the end of this lesson you'll detect patterns, replace and extract text, split and pad strings, and use regular expressions to clean messy real-world data.
What You'll Learn in This Lesson
- How str_detect() tests each string for a pattern
- How str_replace() and str_replace_all() differ
- How to extract substrings with str_sub() and str_extract()
- How str_split() breaks strings into pieces
- How str_pad() and str_trim() clean up text
- How regular expressions power pattern matching
1️⃣ Detecting and Measuring Text
Start by asking questions about each string. str_detect() returns a logical vector you can use to filter; str_length() counts characters; and str_to_upper() / str_to_lower() normalise case. In every function the text comes first and the pattern second.
# stringr is the tidyverse string toolkit. Load it first.
library(stringr)
emails <- c("ada@dev.io", "bob@work.com", "no-at-sign")
# str_detect() returns TRUE/FALSE for each element
str_detect(emails, "@")
# -> [1] TRUE TRUE FALSE
# str_length() counts characters (like base nchar)
str_length(c("hi", "hello"))
# -> [1] 2 5
# str_to_upper / str_to_lower change case
str_to_upper(c("ada", "bob"))
# -> [1] "ADA" "BOB"2️⃣ Replacing, Slicing, and Padding
str_replace() swaps the first match (use str_replace_all() for every match). str_sub() pulls out a substring by start/end position, and str_pad() fills a string to a fixed width — perfect for zero-padded IDs.
library(stringr)
phones <- c("555-1234", "555-5678")
# str_replace() swaps the FIRST match; str_replace_all() swaps every match
str_replace(phones, "-", " ")
# -> [1] "555 1234" "555 5678"
# str_sub() extracts by position (1-indexed, inclusive)
str_sub("LearnCodingFast", 1, 5)
# -> [1] "Learn"
# str_pad() pads to a width — handy for fixed-width IDs
str_pad(c("7", "42"), width = 4, pad = "0")
# -> [1] "0007" "0042"3️⃣ Extracting and Splitting with Regex
Regular expressions describe patterns, not exact text. str_extract() pulls the first match (e.g. "[0-9]+" grabs a run of digits), str_split() breaks a string into a list of pieces, and str_count() tallies matches.
library(stringr)
logs <- c("ERROR 404 page", "OK 200 fine", "ERROR 500 boom")
# str_extract() pulls the FIRST regex match from each element
str_extract(logs, "[0-9]+")
# -> [1] "404" "200" "500"
# str_split() breaks each string on a pattern, returning a list
str_split("a,b,c", ",")
# -> [[1]]
# [1] "a" "b" "c"
# str_count() counts how many times a pattern appears
str_count(c("banana", "apple"), "a")
# -> [1] 3 1Your turn. Fill in the ___ blank, run it, and compare with the expected output.
# 🎯 YOUR TURN — replace each ___ then run it.
library(stringr)
names <- c(" Ada ", " Bob ", " Cleo ")
# 1) Trim the surrounding spaces with str_trim()
clean <- str_trim(names)
# 2) Make each name UPPER case
shout <- ___(clean) # 👉 replace ___ with str_to_upper
# 3) Keep only names that contain the letter "o" (case-insensitive)
withO <- shout[str_detect(shout, "O")]
print(shout)
print(withO)
# ✅ Expected output:
# [1] "ADA" "BOB" "CLEO"
# [1] "BOB" "CLEO"Mini-Challenge: Tidy Product Codes
Chain a few stringr calls to standardise messy SKUs. Remember the string is always the first argument, so these read naturally left to right.
# 🎯 MINI-CHALLENGE: tidy a list of messy product codes.
#
# codes <- c("sku_001", "SKU_042", "sku_999")
#
# 1. Upper-case every code with str_to_upper().
# 2. Replace the underscore with a dash using str_replace().
# 3. Extract just the numeric part with str_extract(..., "[0-9]+").
# 4. Print how many codes end in an even... actually, just print:
# - the cleaned codes, e.g. "SKU-001"
# - the extracted numbers, e.g. "001"
#
# ✅ Cleaned: [1] "SKU-001" "SKU-042" "SKU-999"
# ✅ Numbers: [1] "001" "042" "999"
# your code hereCommon Errors (and the fix)
- Forgetting to load the package — ❌ "could not find function str_detect". ✅ Run library(stringr) first.
- Only the first match changes — ❌ str_replace() stops after one. ✅ Use str_replace_all() for every match.
- Treating a split result as a vector — ❌ str_split(x, ",")[1]. ✅ Index the list with [[1]].
- Special regex characters taken literally — ❌ "." matches ANY character. ✅ Escape it as "\\." or wrap in fixed(".").
Pro Tips
- 💡 Pipe it: stringr's string-first design means x %>% str_trim() %>% str_to_lower() reads top to bottom.
- 💡 Glue values in: the companion str_glue("Hi {name}") interpolates variables into text.
- 💡 Case-insensitive search: wrap your pattern in regex("err", ignore_case = TRUE).
📋 Quick Reference — stringr
| Task | R Syntax |
|---|---|
| Test for a pattern | str_detect(x, "@") |
| Replace all matches | str_replace_all(x, "-", " ") |
| Extract first match | str_extract(x, "[0-9]+") |
| Split on a pattern | str_split(x, ",") |
| Substring by position | str_sub(x, 1, 5) |
| Pad to width | str_pad(x, 4, pad = "0") |
🎉 Lesson Complete!
- ✅ stringr functions all start with str_ and take the string first
- ✅ str_detect() filters; str_replace_all() rewrites
- ✅ str_extract() and str_split() pull text apart
- ✅ str_pad() and str_trim() tidy whitespace and width
- ✅ A handful of regex symbols unlock powerful pattern matching
Practice quiz
What does str_detect(x, '@') return?
- The matched text
- A logical TRUE/FALSE for each element
- The first position
- A list of matches
Answer: A logical TRUE/FALSE for each element. str_detect() returns a logical vector, ideal for filtering.
In stringr functions, which argument comes first?
- The string being operated on
- The pattern
- The replacement
- The width
Answer: The string being operated on. The string is always the first argument, making functions pipe-friendly.
What is the difference between str_replace() and str_replace_all()?
- They are identical
- str_replace_all() changes only the first match
- str_replace() changes the first match; _all changes every match
- str_replace() works only on numbers
Answer: str_replace() changes the first match; _all changes every match. str_replace() swaps the first match; str_replace_all() swaps them all.
Which function extracts a substring by position, e.g. characters 1 to 5?
- str_extract()
- str_split()
- str_count()
- str_sub()
Answer: str_sub(). str_sub(x, 1, 5) slices by start and end position.
What does str_pad(c('7','42'), width = 4, pad = '0') produce?
- "0007" "0042"
- "7000" "4200"
- "7" "42"
- "0070" "0420"
Answer: "0007" "0042". str_pad() left-pads each string with zeros to width 4.
Which function pulls the FIRST regex match from each element?
- str_detect()
- str_extract()
- str_replace()
- str_length()
Answer: str_extract(). str_extract(x, "[0-9]+") returns the first matching substring.
Why does str_split() return a list instead of a vector?
- It is a bug
- It is faster
- Each input can split into a different number of pieces
- Lists are always used in stringr
Answer: Each input can split into a different number of pieces. Because piece counts vary per input, a list (one element per input) is the honest container.
Which function counts how many times a pattern appears in each string?
- str_count()
- str_length()
- str_detect()
- str_sub()
Answer: str_count(). str_count(x, "a") tallies matches per element.
Which function removes surrounding whitespace?
- str_pad()
- str_squish()
- str_trim()
- str_wrap()
Answer: str_trim(). str_trim() strips leading and trailing whitespace.
How do you make a stringr pattern case-insensitive?
- Use fixed('...')
- Add ignore = TRUE as an argument
- Use str_to_lower first only
- Wrap it in regex('...', ignore_case = TRUE)
Answer: Wrap it in regex('...', ignore_case = TRUE). regex("err", ignore_case = TRUE) enables case-insensitive matching.
Continue this course
- Previous: Matrix Algebra (%*%, solve, t)
- Next: ANOVA & Model Comparison — Compare group means and nested models with aov and anova()
- Quick reference: R cheat sheet
Frequently asked questions
Why use stringr instead of base R functions like gsub() and substr()?
stringr gives you a consistent, predictable interface. Every function starts with str_, the string you're operating on is always the first argument, and the pattern is second — which makes the functions pipe-friendly and easy to remember. Base R's grepl(), gsub(), regmatches(), and substr() each order their arguments differently and return inconsistent types. stringr also handles edge cases like NA and zero-length input more sanely. The underlying engine (stringi) is fast and fully Unicode-aware.
What's the difference between str_replace() and str_replace_all()?
str_replace() changes only the first match within each string; str_replace_all() changes every match. So str_replace('a-b-c', '-', ' ') gives 'a b-c', while str_replace_all() gives 'a b c'. If you forget and only the first occurrence changes, you almost certainly wanted the _all version.
Why does str_split() return a list instead of a vector?
Because each input string can split into a different number of pieces, the only honest container is a list — one element per input, each holding that input's pieces. If you split a single string you'll get a list of length one; grab the pieces with the [[1]] index. When you know every input splits into the same fixed number of fields, str_split_fixed() returns a tidy matrix instead.
Do stringr functions use the same regular expressions as base R?
Yes — by default they use POSIX extended regular expressions via the ICU library, which covers the patterns you'll reach for: character classes like [0-9], anchors ^ and $, quantifiers + and *, and groups. If you want a literal match with no special characters, wrap your pattern in fixed('...'); for case-insensitive matching, wrap it in regex('...', ignore_case = TRUE).