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

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 1

Your 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 here

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — stringr

TaskR Syntax
Test for a patternstr_detect(x, "@")
Replace all matchesstr_replace_all(x, "-", " ")
Extract first matchstr_extract(x, "[0-9]+")
Split on a patternstr_split(x, ",")
Substring by positionstr_sub(x, 1, 5)
Pad to widthstr_pad(x, 4, pad = "0")

🎉 Lesson Complete!

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

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).