Strings and Regular Expressions

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Checked against R 4.6.1 (2026-06-24) on 2026-10-20; R 4.6.2 is scheduled for 2026-10-29. Everything on this page is base R except the last section before the pitfalls, which uses stringr 1.6.0 (a CRAN package that calls the stringi library, which in turn wraps ICU) only to show the equivalents of the base functions; the scripts also use the books and loans data frames and the book class of the running shelf scenario, which are plain base R code, and the lintr 3.4.0 default linters were used only to check the scripts.

R has no string type of its own. A string is one element of a character vector, and almost every string job is done by a handful of vectorised functions that take a character vector and return a character vector (paste(), sprintf(), substr(), sub()), a logical vector (grepl(), startsWith()), a list (strsplit(), gregexpr()) or an integer vector of positions (regexpr()). This page goes through those functions in the order a data-cleaning job needs them: build strings, format numbers into them, trim and slice them, split and convert them, print them, and then match patterns with regular expressions, which in R come in three flavours selected by two logical arguments. It finishes by cleaning the deliberately messy text columns of the shelf data and by mapping the base functions to stringr.

Every example below is a script that was run with Rscript on R 4.6.1 with warnings printed as they happen (options(warn = 1)), and the lines starting with #> are what it printed. Long scripts are shown in several consecutive pieces, one per section, and a piece that needs a helper defined earlier in the same script says so. The scripts pass the default lintr linters. Errors and warnings that a script shows on purpose are caught with tryCatch() and printed as their conditionMessage(), so the text is exactly what R says. The examples use the library scenario of the section: books, loans and shelf_today come from the scenario.R file described on the section index, and the scripts that use them start with source("scenario.R"). The example session was a UTF-8 session in an en_US.UTF-8 locale (an example locale name; the results that depend on the locale are pointed out where they occur, and Encodings, Locales and Internationalization explains the mechanism).

Strings are elements of character vectors

The manual introduces character quantities in the section on character vectors: they are written between matching double or single quotes, always printed with double quotes (or without quotes at all), and use C-style escapes with the backslash as the escape character (An Introduction to R: Character vectors). Three consequences shape everything that follows.

  • There is no scalar string. "The Dispossessed" is a character vector of length 1, so every function below is vectorised over a whole column such as books$title without a loop.

  • An empty string and a missing string are different values. "" has zero characters and NA_character_ is a missing value of type character; is.na(), nzchar() and nchar() tell them apart (see Missing and Special Values).

  • The text that R prints is a representation of the string, not the string itself: it adds the quotes and escapes backslashes, quotes and control characters. Printing and quoting returns to this.

source("scenario.R")

# A string is one element of a character vector; there is no scalar string
typeof("The Dispossessed")
#> [1] "character"
length("The Dispossessed")
#> [1] 1
is.vector(books$title)
#> [1] TRUE
length(books$title)
#> [1] 16
nchar(books$title[1:3])
#> [1] 16 19 20

# NA_character_ is a missing string, "" is an empty one
missing_title <- c("Loud Roofs", NA, "")
is.na(missing_title)
#> [1] FALSE  TRUE FALSE
nzchar(missing_title)
#> [1]  TRUE  TRUE FALSE
nchar(missing_title)
#> [1] 10 NA  0

# The deliberate mess of the shelf data
books$title[c(3, 10)]
#> [1] "  the quiet streets " "THE LONG TABLE"
books$author[c(8, 10)]
#> [1] "Le Guin, Ursula K." "Iris  Fenwick"
books$genre[c(1, 4, 10)]
#> [1] "Science fiction" "fiction"         "Fiction "
unique(books$genre)
#> [1] "Science fiction" "Classic"         "Fiction"         "fiction"
#> [5] "Mystery"         "Non-fiction"     "Poetry"          "Fiction "
#> [9] "science fiction"

The last four lines show the mess that the rest of the page cleans: the title with leading and trailing blanks, the all-capitals title, the author written both as Ursula K. Le Guin and as Le Guin, Ursula K., the double space in Iris Fenwick, and nine spellings of what should be six genres. Factors and the levels of a cleaned genre column are the subject of Factors; the atomic type itself is described on Atomic Vectors and Types.

The table lists the functions of this page by the job they do, so that you can jump to the section that explains them. Each name links to its help page in the later sections.

Job Functions Section

Join pieces and format values

paste(), paste0(), sprintf(), format(), formatC(), prettyNum(), toString(), strrep()

Building strings

Change case, translate, trim, test prefixes

toupper(), tolower(), casefold(), chartr(), trimws(), startsWith(), endsWith()

Case, translation and trimming

Measure and slice

nchar(), nzchar(), substr(), substring(), strtrim(), substr<-

Measuring and slicing

Split and convert

strsplit(), strtoi(), utf8ToInt(), intToUtf8(), charToRaw(), rawToChar()

Splitting and converting

Print and quote

print(), cat(), writeLines(), noquote(), encodeString(), sQuote(), dQuote(), shQuote()

Printing and quoting

Match patterns

grepl(), grep(), grepv(), sub(), gsub(), regexpr(), gregexpr(), regexec(), gregexec(), regmatches(), agrep()

Regular expressions

Building strings

paste() and paste0()

paste() converts its arguments to character with as.character() and joins them term by term; paste0() is paste(..., sep = "") a little more efficiently (R documentation: paste). Two arguments control the shape of the result:

  • sep goes between the terms of each element of the result (default " ").

  • collapse, when it is a string, folds the whole result into a single string with that separator between the elements; when it is NULL (default) the result keeps one element per term. Confusing the two is the usual reason a result has the wrong length: paste("a", "b", collapse = ", ") is the single string "a b", because the arguments have length 1 and there is nothing to collapse; use sep to join arguments and collapse to join elements.

Arguments are recycled to the length of the longest one, so the vectors in the next script behave like the labs example of the manual: the shorter vector is repeated.

source("scenario.R")

# paste() joins with sep = " "; paste0() has no separator
paste("Ursula K.", "Le Guin")
#> [1] "Ursula K. Le Guin"
paste0("978", "0306406157")
#> [1] "9780306406157"
paste("a", "b", sep = "-")
#> [1] "a-b"

# Vectors are joined element by element and recycled
paste("L", 1:3, sep = "")
#> [1] "L1" "L2" "L3"
paste0("L", sprintf("%03d", 1:3))
#> [1] "L001" "L002" "L003"
paste(c("a", "b", "c"), 1:6)
#> [1] "a 1" "b 2" "c 3" "a 4" "b 5" "c 6"

# collapse folds a vector into one string
paste(books$title[4:6], collapse = "; ")
#> [1] "Loud Roofs; Midnight Ledger; A Map of Salt"
paste0(c("x", "y"), collapse = "")
#> [1] "xy"

# sep and collapse together
paste(c("a", "b"), c("c", "d"), sep = "+", collapse = " | ")
#> [1] "a+c | b+d"

The third example of the first group, paste(c("a", "b", "c"), 1:6), recycles the shorter vector twice. When the longer length is not a multiple of the shorter one, paste() recycles silently and does not warn, a trap that the last example of the next block shows.

Missing and zero-length arguments

NA is converted like any other value, so it becomes the two characters NA: paste("pages:", NA) is "pages: NA", and the help page notes that this may be undesirable when pasting two character vectors but is convenient in a message. If you want a missing value to propagate instead, test with is.na() first (stringr’s str_c() propagates it; see stringr).

A zero-length argument is not dropped but recycled as the empty string "", so paste("a", character(0)) is "a " with a trailing separator, not character(0). This surprises people who build file names from a possibly empty vector. R 4.0.1 added the argument recycle0 for the standard vectorised behaviour, where a zero-length argument makes the whole result zero-length. The NEWS entry for R 4.0.1 says that paste() and paste0() gain recycle0 and adds that a package using it should depend on R (>= 4.0.1). Note the one exception that the help page spells out: with collapse set to a string the result is always a single string, so recycle0 = TRUE gives "" and not character(0).

# NA is pasted as the text "NA"
paste("pages:", books$pages[5:7])
#> [1] "pages: 301" "pages: NA"  "pages: 96"
paste0("year ", NA)
#> [1] "year NA"
paste(NA_character_, "x")
#> [1] "NA x"

# A zero-length argument is recycled as "", not dropped
paste("a", character(0))
#> [1] "a "
paste0("a", NULL, "b")
#> [1] "ab"
paste(character(0), collapse = "+")
#> [1] ""
paste("a", character(0), sep = "-")
#> [1] "a-"

# recycle0 = TRUE (R 4.0.1) gives character(0) unless collapse is a string
paste("a", character(0), recycle0 = TRUE)
#> character(0)
paste("a", character(0), recycle0 = TRUE, collapse = "+")
#> [1] ""
paste0("a", character(0), recycle0 = TRUE, collapse = NULL)
#> character(0)

Other classes

paste() calls as.character() on each object argument, so a factor contributes its labels, a Date its ISO text, and a list element that is a vector contributes its deparsed form. Numbers are converted with up to 15 significant digits, which is the cause of several of the surprises in Pitfalls.

# Factors are pasted as their labels
paste(factor(c("fiction", "poetry")), 1:2)
#> [1] "fiction 1" "poetry 2"

# Dates are formatted with as.character()
paste("due", shelf_today + 21L)
#> [1] "due 2026-07-21"

# A list element that is a vector is deparsed
paste(list(1:3, "a", c(TRUE, FALSE)))
#> [1] "1:3"            "a"              "c(TRUE, FALSE)"

# A non-multiple length recycles silently, without a warning
paste(1:3, c("a", "b"))
#> [1] "1 a" "2 b" "3 a"

toString(x) is paste(x, collapse = ", ") (R documentation: toString), and strrep(x, times) repeats a string (R documentation: strrep; added in R 3.3.0). rep() plus paste(collapse = "") was the idiom before it.

sprintf()

sprintf() is a wrapper for the C library function of the same name: a format string with conversion specifications that start with % is filled from the following arguments, which are recycled together with the format and processed in parallel (R documentation: sprintf). paste() builds a string from pieces; sprintf() writes the whole sentence as a template and fills in typed values, which is also what translators prefer (gettextf() is sprintf() with translation of the format string). The conversions used most often are:

Specification Meaning Example result

%s

a string; other types are converted with as.character(); NA becomes "NA"

sprintf("%s", 3.5) is "3.5"

%d, %i

an integer; a double is accepted when it holds a whole number, and a logical is accepted too

sprintf("%d", 3) is "3"

%f

fixed-point decimal; the precision is the number of decimals, default 6

sprintf("%5.2f", pi) is " 3.14"

%e, %E

exponential notation

sprintf("%e", 123456.789) is "1.234568e+05"

%g, %G

%e or %f depending on the exponent; the precision counts significant digits

sprintf("%g", 123456.789) is "123457"

%x, %X, %o

hexadecimal and octal integer

sprintf("%X", 255L) is "FF"

%a, %A

hexadecimal floating point

sprintf("%a", 1) is "0x1p+0"

%%

a literal percent sign

sprintf("100%%") is "100%"

Between the % and the letter come, in any order, a width and precision m.n, the flags - (left-justify), + (always show a sign), a space (prefix a space), 0 (pad numbers with zeros) and # (alternate form), an optional n$ to select the nth argument, and * to take the width or the precision (but not both) from an argument.

source("scenario.R")

# The conversion specifications
sprintf("%s has %d pages", "Loud Roofs", 198L)
#> [1] "Loud Roofs has 198 pages"
sprintf("%5.2f", pi)
#> [1] " 3.14"
sprintf("%-10s|", "left")
#> [1] "left      |"
sprintf("%10s|", "right")
#> [1] "     right|"
sprintf("%05d", 42L)
#> [1] "00042"
sprintf("%+d", 7L)
#> [1] "+7"
sprintf("%e", 123456.789)
#> [1] "1.234568e+05"
sprintf("%g", 123456.789)
#> [1] "123457"
sprintf("%x %X %o", 255L, 255L, 8L)
#> [1] "ff FF 10"
sprintf("%.3s", "Dispossessed")
#> [1] "Dis"
sprintf("100%%")
#> [1] "100%"
sprintf("%08.2f", pi)
#> [1] "00003.14"
sprintf("%#x", 255L)
#> [1] "0xff"
sprintf("%.0f", c(0.5, 1.5, 2.5))
#> [1] "0" "2" "2"
sprintf("%5.1f%%", 45.678)
#> [1] " 45.7%"

# %d accepts a whole-number double, %s accepts anything
sprintf("%d", 3)
#> [1] "3"
sprintf("%s", 3.5)
#> [1] "3.5"
sprintf("%s", TRUE)
#> [1] "TRUE"
sprintf("%s", 1e6)
#> [1] "1e+06"
sprintf("%s", 123456789)
#> [1] "123456789"

# Vectorised over all arguments
sprintf("%s (%d)", books$title[4:6], books$year[4:6])
#> [1] "Loud Roofs (2021)"      "Midnight Ledger (2016)" "A Map of Salt (2008)"
sprintf("%2$s was written by %1$s", "Jane Austen", "Pride and Prejudice")
#> [1] "Pride and Prejudice was written by Jane Austen"

# The width can come from an argument with *
sprintf("%*d", 6L, 42L)
#> [1] "    42"
sprintf("%-*s|", 8L, "ab")
#> [1] "ab      |"

Three details from the help page matter in practice. %.0f does not round half up: 0.5, 1.5 and 2.5 print as 0, 2 and 2, because the C library rounds the binary double to even in the halfway case. %s applied to a number uses as.character(), so a double such as 1e6 becomes "1e+06"; use %d for an integer-valued count and %.0f or format() when you want plain digits. And an argument that the format does not use makes sprintf() warn: that warning exists since R 4.1.0, whose NEWS says that sprintf() now warns on arguments unused by the format string; %.0s skips an argument without the warning.

Missing, zero-length, bytes and vectors

NA is formatted as NA for every type and keeps the field width. A zero-length argument gives a zero-length result, unlike paste(). The width of %s is counted in bytes, as the help page says (it follows the C standard), so an accented letter that takes two bytes in UTF-8 gets one space fewer than an ASCII letter: use formatC() or format() (below), which count characters or display width, when you align non-ASCII text. The format itself is vectorised, and the last example formats a small table with cat(sep = "\n").

# NA is printed as NA, with width and for every type
sprintf("%d", NA_integer_)
#> [1] "NA"
sprintf("%5.1f", NA_real_)
#> [1] "   NA"
sprintf("%s", NA_character_)
#> [1] "NA"
sprintf("%10s|", NA)
#> [1] "        NA|"

# Widths of %s count bytes, not characters
sprintf("%5s|", "e")
#> [1] "    e|"
sprintf("%5s|", "é")
#> [1] "   é|"

# Zero-length arguments give a zero-length result
sprintf("%s", character(0))
#> character(0)

# fmt itself is vectorised
sprintf(c("%d pages", "%d years"), 387L)
#> [1] "387 pages" "387 years"

# Format a table of the shelf
cat(sprintf("%-22s %4d %5s", books$title[1:4], books$year[1:4],
            format(books$pages[1:4])), sep = "\n")
#> The Dispossessed       1974   387
#> Pride and Prejudice    1813   432
#>   the quiet streets    2019   214
#> Loud Roofs             2021   198

The errors of sprintf()

The format is checked against the type of the argument before anything is formatted, so the common mistakes are errors, and the error text names the right conversion. The first one below is the error that people meet when they print a computed double with %d: a double with a fractional part (or outside the integer range) is not accepted by %d. The script catches every condition and prints its message.

# The errors and warnings of sprintf(), caught so that their text is shown
msg <- function(expr) {
  tryCatch(expr, error = \(e) conditionMessage(e),
           warning = \(w) paste("warning:", conditionMessage(w)))
}

msg(sprintf("%d", 3.5))
#> [1] "invalid format '%d'; use format %f, %e, %g or %a for numeric objects"
msg(sprintf("%d", "3"))
#> [1] "invalid format '%d'; use format %s for character objects"
msg(sprintf("%s and %s", "one"))
#> [1] "too few arguments"
msg(sprintf("%s", "one", "two"))
#> [1] "warning: one argument not used by format '%s'"
msg(sprintf("%y", 1L))
#> [1] "unrecognised format specification '%y'"
msg(sprintf("%5", 1L))
#> [1] "unrecognised format specification '%5'"
msg(sprintf("%s", list(1, "a")))
#> [1] "1" "a"
msg(sprintf("%s", list(1:2, "a")))
#> [1] "1:2" "a"
msg(sprintf("%f", 1L))
#> [1] "1.000000"
msg(sprintf("%d", TRUE))
#> [1] "1"
msg(sprintf("%s", sum))
#> [1] "cannot coerce type 'builtin' to vector of type 'character'"
msg(sprintf("%s %s", c("a", "b"), c("x", "y", "z")))
#> [1] "arguments cannot be recycled to the same length"
msg(sprintf("%.2f%%", 12.345))
#> [1] "12.35%"
msg(sprintf("%s", factor("fiction")))
#> [1] "fiction"
msg(sprintf("%d", factor("fiction")))
#> [1] "1"

Read the results one by one. %d with 3.5 gives invalid format '%d'; use format %f, %e, %g or %a for numeric objects, and %d with a string says to use %s for character objects; a format that needs more arguments than given is too few arguments; an extra argument is a warning, not an error; an unknown conversion is unrecognised format specification; and arguments of different lengths that do not recycle to a common length are an error (arguments cannot be recycled to the same length). Two results are not errors although they look like traps: a list is accepted for %s and each element is converted with as.character(), so a vector element is deparsed (list(1:2, "a") gives "1:2" and "a"), and a factor passed to %d supplies its integer codes. The hazard of sprintf() is therefore silent conversion rather than an error, which Pitfalls expands.

format(), formatC() and prettyNum()

sprintf() formats each value on its own. format() is different: it formats a whole vector so that all elements have a common width and a common number of decimals, which is what a printed column needs (R documentation: format). Its main arguments are width (a minimum width), justify ("left", "right", "centre" or "none", for character input), digits (significant digits for numbers), nsmall (a minimum number of digits after the decimal point), scientific (force or forbid exponents), big.mark and na.encode. formatC() is the C-style interface to the same job for numbers and strings, with width, digits, format ("d", "f", "e", "g", "fg" or "s"), flag ("0" pads with zeros, "-" left-justifies, "+", " " and "#") and big.mark; prettyNum() inserts the marks into numbers that are already formatted (R documentation: formatC).

source("scenario.R")

# format() pads a vector to a common width
format(c("a", "bbb", "cc"))
#> [1] "a  " "bbb" "cc "
format(c("a", "bbb", "cc"), width = 6)
#> [1] "a     " "bbb   " "cc    "
format(c("a", "bbb", "cc"), justify = "right")
#> [1] "  a" "bbb" " cc"
format(c("a", "bbb", "cc"), justify = "centre", width = 7)
#> [1] "   a   " "  bbb  " "  cc   "

# For numbers it uses a common number of significant digits
format(c(1, 10, 100))
#> [1] "  1" " 10" "100"
format(pi, digits = 3)
#> [1] "3.14"
format(c(0.5, 10, 123.456))
#> [1] "  0.500" " 10.000" "123.456"
format(2, nsmall = 2)
#> [1] "2.00"
format(1234567.891, big.mark = ",")
#> [1] "1,234,568"
format(1e6, scientific = TRUE)
#> [1] "1e+06"
format(1e-5)
#> [1] "1e-05"
format(0.00001, scientific = FALSE)
#> [1] "0.00001"
format(TRUE)
#> [1] "TRUE"
format(NA)
#> [1] "NA"
format(c(1.5, NA, 3))
#> [1] "1.5" " NA" "3.0"
format(NA_character_)
#> [1] "NA"
format(shelf_today, "%d %B %Y")
#> [1] "30 June 2026"
format(books$pages[5:7])
#> [1] "301" " NA" " 96"
format(-1.5)
#> [1] "-1.5"
format(c(-1, 1))
#> [1] "-1" " 1"

Three behaviours deserve attention. format() decides the number of significant digits for the whole vector, so format(c(0.5, 10, 123.456)) shows three decimals for every element; ask for a fixed number with nsmall or round(). NA is formatted as the two characters NA, right-justified for numbers, and format(shelf_today, "%d %B %Y") shows that format() is a generic: the Date method takes a strptime-style format string, as Dates and Times explains. And the result of format() is always character, so format(2, nsmall = 2) is the string "2.00", not the number.

# formatC() is the C-style interface: width, flag, format, digits
formatC(3.14159, digits = 3, format = "f")
#> [1] "3.142"
formatC(42L, width = 8, flag = "0")
#> [1] "00000042"
formatC(42L, width = 8, flag = " ")
#> [1] "      42"
formatC(c(1, 10, 100), width = 5)
#> [1] "    1" "   10" "  100"
formatC("ab", width = 6)
#> [1] "    ab"
formatC("ab", width = -6)
#> [1] "ab    "
formatC("ab", flag = "-", width = 6)
#> [1] "ab    "
formatC(123456789, big.mark = ",", format = "d")
#> [1] "123,456,789"
formatC(0.000123, format = "e", digits = 2)
#> [1] "1.23e-04"
formatC(c(1.5, 22.25, 333), format = "g")
#> [1] "1.5"   "22.25" "333"
formatC(1:3, width = 3, flag = "0")
#> [1] "001" "002" "003"
formatC(NA_real_, width = 6)
#> [1] "    NA"
formatC(1e6, format = "d", big.mark = ",")
#> [1] "1,000,000"

# prettyNum() inserts marks into already formatted numbers
prettyNum(1234567, big.mark = ",")
#> [1] "1,234,567"
prettyNum(c(123456, 1234), big.mark = ",")
#> [1] "123,456" "1,234"

formatC(x, width = 8, flag = "0") is the standard way to pad identifiers with zeros ("00000042"), and a negative width or flag = "-" left-justifies. formatC() picks "d" for integers and "g" for doubles, so formatC(c(1.5, 22.25, 333), format = "g") drops the trailing zeros that format() would keep. The big.mark argument produces thousands separators ("123,456,789") for display; never parse such text back as a number without removing the marks first, and see Numbers and Arithmetic for rounding and digits. Which function to use:

Function Strength Typical use

paste() / paste0()

joins already-formatted pieces; no number formatting

labels, keys, file names

sprintf()

one template with typed placeholders, per-element precision, vectorised over the template

messages, report lines, zero-padded keys such as sprintf("%03d", 7L)

format()

common width and digits across a vector; generic, so it formats dates and factors

printing a column, aligning text

formatC()

C-style flags, zero padding and big.mark, per-element formatting

identifiers, thousands separators

prettyNum()

adds marks to numbers (or text of numbers) that already exist

a final display step

The last piece compares how the three padding functions count the width of the accented letter: sprintf() counts bytes, so its result is one character short, while formatC() and format() pad to five characters.

# Aligning accented text: sprintf() counts bytes, formatC() and format() chars
pad_test <- c(
  sprintf("%5s", "é"), formatC("é", width = 5), format("é", width = 5)
)
nchar(pad_test)
#> [1] 4 5 5

Case, translation, trimming and prefixes

toupper(), tolower() and casefold() change case, and chartr(old, new, x) translates characters one for one (a range such as "a-c" is allowed, character classes are not) (R documentation: chartr). Case conversion uses the rules of the current locale, so it is not one-to-one in every language: the help page notes that more than one character can be mapped to a single upper-case character. In the example session toupper("straße") leaves the sharp s alone and returns "STRAßE", so a comparison such as toupper("ß") == "SS" is FALSE; Pitfalls shows it with the sort order, which is also locale-dependent. chartr() is the right tool to remove accents with a fixed table, as in the example.

trimws(x, which = c("both", "left", "right"), whitespace = "[ \t\r\n]") removes leading and trailing whitespace (R documentation: trimws). The function came in R 3.2.0; the whitespace argument, a regular expression for one whitespace character, was added in R 3.6.0 so that Unicode spaces can be included. Internally it is sub(re, "", x, perl = TRUE), so the pattern is a PCRE one: the default only covers space, tab, carriage return and newline for portability, and a non-breaking space survives it. The help page recommends the class [\h\v] to match all horizontal and vertical Unicode whitespace, which is written "[\\h\\v]" in R code.

source("scenario.R")

toupper(books$title[10])
#> [1] "THE LONG TABLE"
tolower(books$title[10])
#> [1] "the long table"
casefold("Le Guin", upper = TRUE)
#> [1] "LE GUIN"
chartr("abc", "xyz", "aabbcc")
#> [1] "xxyyzz"
chartr("áé", "ae", "café")
#> [1] "cafe"
toupper("straße")
#> [1] "STRAßE"
tolower("ÉCOLE")
#> [1] "école"

# trimws() removes leading and trailing whitespace
trimws(books$title[3])
#> [1] "the quiet streets"
trimws(books$genre[10], which = "right")
#> [1] "Fiction"
trimws("  both  ", which = "left")
#> [1] "both  "
trimws("..path..", whitespace = "[.]")
#> [1] "path"
trimws(c("\t tab", "nl\n", "nbsp\u00a0"))
#> [1] "tab"   "nl"    "nbsp "
trimws(c("\t tab", "nl\n", "nbsp\u00a0"), whitespace = "[\\h\\v]")
#> [1] "tab"  "nl"   "nbsp"
trimws("  a  b  ")
#> [1] "a  b"

trimws() does not touch the inside of a string: trimws(" a b ") keeps the two blanks between a and b. Collapsing inner runs of blanks takes gsub("\\s+", " ", x), which the cleaning section combines with trimws() for the shelf titles.

startsWith(x, prefix) and endsWith(x, suffix) test a fixed prefix or suffix without a regular expression (R documentation: startsWith). They came in R 3.3.0 and are the cleanest way to ask "does this ISBN start with 978-0-00?", because they cannot be confused by metacharacters in the prefix. A missing x or a missing prefix gives NA, a zero-length x gives logical(0), and a prefix vector is recycled along x.

# startsWith() and endsWith() are fixed-string tests
startsWith(books$isbn, "978-0-00")
#>  [1] FALSE FALSE FALSE  TRUE  TRUE FALSE  TRUE  TRUE FALSE  TRUE FALSE FALSE
#> [13] FALSE FALSE FALSE FALSE
endsWith(books$title, "s")
#>  [1] FALSE FALSE FALSE  TRUE FALSE FALSE  TRUE FALSE  TRUE FALSE  TRUE FALSE
#> [13] FALSE FALSE  TRUE FALSE
startsWith(c("abc", NA), "a")
#> [1] TRUE   NA
startsWith("abc", NA_character_)
#> [1] NA
startsWith(character(0), "a")
#> logical(0)
startsWith("abc", c("a", "b"))
#> [1]  TRUE FALSE
isTRUE(startsWith("Rivers of Glass", "Rivers"))
#> [1] TRUE

Measuring and slicing

nchar() and nzchar()

nchar(x, type = "chars", allowNA = FALSE, keepNA = NA) returns an integer vector of the sizes of the elements of x, measured as "chars" (the default), "bytes" or "width" (the number of columns cat() needs in a monospaced font) (R documentation: nchar). nzchar(x) is the fast test for non-empty strings. The three sizes differ for non-ASCII text: "café" has four characters, five bytes and width four, and each of the three Japanese characters in the example is one character with width two.

The treatment of missing values is a place where old tutorials are wrong. The R 3.3.0 NEWS changed the default to keepNA = NA, which returns NA where x is NA except for type = "width", so in R 4.6.1 nchar(NA_character_) is NA_integer_, and so is nchar(NA) for the logical NA, which is coerced to the missing string. The number 2 (the printed width of NA) comes back only with keepNA = FALSE or type = "width". The script prints all of these. A factor is an error because nchar() does not guess whether you mean labels or codes.

source("scenario.R")

# nchar(): characters by default, bytes and width on request
nchar("café")
#> [1] 4
nchar("café", type = "bytes")
#> [1] 5
nchar("café", type = "width")
#> [1] 4
nchar("日本語")
#> [1] 3
nchar("日本語", type = "width")
#> [1] 6
nchar(c("a", NA, "NA"))
#> [1]  1 NA  2
nchar(NA)
#> [1] NA
nchar(NA_character_)
#> [1] NA
nchar(NA_character_, type = "bytes")
#> [1] NA
nchar(NA_character_, keepNA = FALSE)
#> [1] 2
nchar(c(one = "a", two = "bb"))
#> one two
#>   1   2
nchar(123456)
#> [1] 6
tryCatch(nchar(factor("fiction")), error = \(e) conditionMessage(e))
#> [1] "'nchar()' requires a character vector"
nchar(as.character(factor("fiction")))
#> [1] 7
nchar(c("a", NA), type = "bytes")
#> [1]  1 NA
nchar(c("a", NA), type = "chars", allowNA = TRUE)
#> [1]  1 NA

substr(), substring() and the replacement forms

substr(x, start, stop) extracts the characters from start to stop of every element, with 1-based positions, and substring(text, first, last) is the S-compatible variant that recycles first and last along the longest argument, which makes it the tool for cutting one string at many places (R documentation: substr). Positions outside the string are forgiven: start beyond the end gives "", stop beyond the end gives the rest, and start below 1 is treated as 1. NA in an argument gives NA, the names of x are kept by substr(), and both functions count characters in a UTF-8 string.

R 4.6.0 changed what the end argument may be. The NEWS says that substring(txt, i1, i2), substr(..) and their replacement versions now accept i2 = NULL to mean "to the end_of_string". The names i1 and i2 are the NEWS shorthand, because the help page for 4.6.1 names the arguments start/stop for substr() and first/last for substring(): stop (or last) may now be NULL, meaning the end of each string. In 4.6.1 last of substring() defaults to NULL, so substring(x, 5) returns everything from position 5, while stop of substr() still has no default, so substr(x, 5) is the error argument "stop" is missing, with no default and you write substr(x, 5, NULL). Older code that wrote substr(x, 5, 1000000L) or substr(x, 5, nchar(x)) still works.

source("scenario.R")

x <- "The Dispossessed"
substr(x, 5, 8)
#> [1] "Disp"
substr(x, 5, 100)
#> [1] "Dispossessed"
substr(x, 0, 3)
#> [1] "The"
substr(x, 9, 4)
#> [1] ""
tryCatch(substr(x, 5), error = \(e) conditionMessage(e))
#> [1] "argument \"stop\" is missing, with no default"
substr(x, 5, NULL)
#> [1] "Dispossessed"
substring(x, 5)
#> [1] "Dispossessed"
substring(x, 1:3, 3:5)
#> [1] "The" "he " "e D"
substring(x, seq(1, 12, by = 4), seq(4, 16, by = 4))
#> [1] "The "             "Disp"             "osse"             "The Dispossessed"
substring(x, 1:3)
#> [1] "The Dispossessed" "he Dispossessed"  "e Dispossessed"
substr(c(a = "alpha", b = "beta"), 2, 3)
#>    a    b
#> "lp" "et"
substr(NA, 1, 2)
#> [1] NA
substr(c("ab", NA), 1, 1)
#> [1] "a" NA

The replacement forms substr(x, start, stop) <- value and substring(text, first, last) <- value overwrite characters in place and never change the length of the string: a shorter replacement overwrites only part of the target, a longer one is cut off, and a start past the end changes nothing. The R 4.6.0 NULL end works here as well (substr(y, 15, NULL) <- "!!"). The first-N and last-N helpers use nchar().

# Replacement keeps the length of the string
y <- "Pride and Prejudice"
substr(y, 1, 5) <- "PRIDE"
y
#> [1] "PRIDE and Prejudice"
substr(y, 1, 5) <- "xy"
y
#> [1] "xyIDE and Prejudice"
substr(y, 1, 3) <- "123456"
y
#> [1] "123DE and Prejudice"
substr(y, 15, NULL) <- "!!"
y
#> [1] "123DE and Prej!!ice"
substring(y, 2) <- "Z"
y
#> [1] "1Z3DE and Prej!!ice"
substr(y, 30, 31) <- "no"
y
#> [1] "1Z3DE and Prej!!ice"

# Last N characters, first N characters
last_n <- \(s, n) substr(s, nchar(s) - n + 1L, nchar(s))
last_n(books$isbn[1:3], 4)
#> [1] "15-7" "51-8" "0100"
strtrim("Dispossessed", 5)
#> [1] "Dispo"
strtrim("日本語", 4)
#> [1] "日本"
strtrim(c("abcdef", "ab"), c(3, 5))
#> [1] "abc" "ab"
strrep("ab", 3)
#> [1] "ababab"
strrep("-", 0:3)
#> [1] ""    "-"   "--"  "---"
strrep(c("a", "b"), 2)
#> [1] "aa" "bb"

strtrim(x, width) cuts a string to a display width rather than to a number of characters (R documentation: strtrim), which is why the three Japanese characters are cut to two when the width is four. strrep(x, times) repeats; with times = 0 it gives "". Neither changes the argument.

Splitting and converting

strsplit()

strsplit(x, split, fixed = FALSE, perl = FALSE) splits each element of x at the matches of split and returns a list with one character vector per element, in the same order (R documentation: strsplit). The list is what makes a typical beginner’s loop wrong: strsplit(x, ",") of one string is a list of length 1, so you take [[1]] of it or vapply() over the elements. A few behaviours follow from the algorithm that the help page prints:

  • The pattern is a regular expression. strsplit("a.b.c", ".") splits at every character, because . matches anything, and returns five empty strings; use fixed = TRUE or "[.]".

  • An empty pattern (or character(0)) splits into single characters, counted in characters, not bytes.

  • A match at the start gives a leading empty string, but a match at the end does not give a trailing one: "a,b," splits into "a" and "b".

  • A missing x gives NA, an empty string gives character(0), and a factor is an error (non-character argument).

source("scenario.R")

# strsplit() returns a list with one element per input string
strsplit("a,b,c", ",")
#> [[1]]
#> [1] "a" "b" "c"
#>
strsplit(c("a-b", "c-d-e", NA, ""), "-")
#> [[1]]
#> [1] "a" "b"
#>
#> [[2]]
#> [1] "c" "d" "e"
#>
#> [[3]]
#> [1] NA
#>
#> [[4]]
#> character(0)
#>
strsplit("a,b,c", ",")[[1]]
#> [1] "a" "b" "c"
strsplit(books$author[c(1, 8)], ", ")
#> [[1]]
#> [1] "Ursula K. Le Guin"
#>
#> [[2]]
#> [1] "Le Guin"   "Ursula K."
#>
tryCatch(strsplit(factor("a-b"), "-"), error = \(e) conditionMessage(e))
#> [1] "non-character argument"

# The pattern is a regular expression unless fixed = TRUE
strsplit("a.b.c", ".")
#> [[1]]
#> [1] "" "" "" "" ""
#>
strsplit("a.b.c", ".", fixed = TRUE)
#> [[1]]
#> [1] "a" "b" "c"
#>
strsplit("a.b.c", "[.]")
#> [[1]]
#> [1] "a" "b" "c"
#>
strsplit("a1b22c", "[0-9]+")
#> [[1]]
#> [1] "a" "b" "c"
#>

# An empty pattern splits into characters
strsplit("abc", "")[[1]]
#> [1] "a" "b" "c"
strsplit("café", "")[[1]]
#> [1] "c" "a" "f" "é"

# A trailing empty piece is dropped; a leading one is kept
strsplit("a,b,", ",")[[1]]
#> [1] "a" "b"
strsplit(",a,b", ",")[[1]]
#> [1] ""  "a" "b"
strsplit("a,,b", ",")[[1]]
#> [1] "a" ""  "b"

Reversing a string is the standard use of the empty split: split into characters, rev() them, paste them back. utf8ToInt() and intToUtf8() give the same result through the code points.

Code points, integers and raw bytes

utf8ToInt(x) returns the Unicode code points of one string and intToUtf8(x, multiple = FALSE) builds a string from code points (R documentation: utf8Conversion). utf8ToInt() takes a single string: given a longer vector it keeps only the first element and warns argument should be a character vector of length 1 followed by all but the first element will be ignored, and a missing string gives NA. charToRaw() and rawToChar() convert between a string and its bytes (R documentation: rawConversion). Code points are the portable way to do arithmetic on letters, for instance LETTERS[utf8ToInt("d") - utf8ToInt("a") + 1L]; chartr() is the shorter way to map a table. Combining characters make a reversed string wrong for text that was typed with them, which is why Encodings, Locales and Internationalization recommends normalising first.

# Reversing a string
rev_string <- function(x) {
  vapply(strsplit(x, ""), \(ch) paste(rev(ch), collapse = ""), character(1))
}
rev_string(c("stressed", "ab"))
#> [1] "desserts" "ba"
intToUtf8(rev(utf8ToInt("stressed")))
#> [1] "desserts"

# Code points
utf8ToInt("A")
#> [1] 65
utf8ToInt("é")
#> [1] 233
intToUtf8(c(72, 105))
#> [1] "Hi"
intToUtf8(c(72, 105), multiple = TRUE)
#> [1] "H" "i"
utf8ToInt(c("a"))
#> [1] 97
tryCatch(utf8ToInt(c("a", "b")), warning = \(w) conditionMessage(w))
#> [1] "argument should be a character vector of length 1\nall but the first element will be ignored"
utf8ToInt(NA_character_)
#> [1] NA
chartr("abc", "ABC", "aabbcc")
#> [1] "AABBCC"
LETTERS[utf8ToInt("d") - utf8ToInt("a") + 1L]
#> [1] "D"

strtoi()

strtoi(x, base = 0L) converts strings to integers with the C function strtol (R documentation: strtoi). Note the default base: 0L does not mean base 10 but "choose the base from the string", following the C rules, so a leading 0 means octal and a leading 0x means hexadecimal, and strtoi("010") is 8. Pass base = 10L for decimal text (or use as.integer(), which the help page recommends for decimal strings). A string that does not parse completely ("12abc"), an empty string and a value outside the integer range are NA; leading blanks and an explicit sign are accepted. The R 3.6.0 NEWS fixed strtoi("", base) to give NA on all platforms. Use base = 2L, 8L, 16L and up to 36L for the other bases; as.hexmode() formats in the other direction.

# strtoi() converts to integer in a given base
strtoi("ff", base = 16L)
#> [1] 255
strtoi("777", base = 8L)
#> [1] 511
strtoi("101", base = 2L)
#> [1] 5
strtoi("077", 8L)
#> [1] 63
strtoi("12abc", base = 10L)
#> [1] NA
strtoi("", base = 10L)
#> [1] NA
strtoi(c("10", " 10", "-10", "+10", NA), base = 10L)
#> [1]  10  10 -10  10  NA
strtoi("2147483648", base = 10L)
#> [1] NA
strtoi("0x1F", 16L)
#> [1] 31
strtoi("0x1F")
#> [1] 31
strtoi("010")
#> [1] 8
strtoi("010", 10L)
#> [1] 10
strtoi("z", base = 36L)
#> [1] 35
as.hexmode(255)
#> [1] "ff"
format(as.hexmode(255), width = 4)
#> [1] "00ff"
rawToChar(as.raw(c(72, 105)))
#> [1] "Hi"
charToRaw("Hi")
#> [1] 48 69
as.integer(charToRaw("A"))
#> [1] 65

Printing and quoting

A string and the text that R prints for it are different things, and the R FAQ spends an entry on the difference (R FAQ 7.37: Why does backslash behave strangely inside strings?). print() (and autoprinting) shows the representation: the quotes, and the escape sequences \", \\ and \n. cat() and writeLines() write the characters themselves (R documentation: cat). nchar() counts the characters of the string, so the string with a newline in it has one character for the \n, not two.

noquote(x) and print(x, quote = FALSE) drop the surrounding quotes but still escape backslashes and control characters, which surprises people who expect the output of cat(): the printed back\\slash still has two backslashes although the string has one. encodeString(x, quote = "'") returns the escaped form as a string, the same text print() would show (R documentation: encodeString); it is the right function when you build an error message from user text, and noquote the right one for a table of names without quotes (R documentation: noquote).

x <- c("say \"hi\"", "back\\slash", "new\nline")

# print() shows the escaped representation, cat() and writeLines() the string
print(x)
#> [1] "say \"hi\""  "back\\slash" "new\nline"
cat(x, sep = "\n")
#> say "hi"
#> back\slash
#> new
#> line
writeLines(x)
#> say "hi"
#> back\slash
#> new
#> line
nchar(x)
#> [1]  8 10  8
noquote(x)
#> [1] say "hi"    back\\slash new\nline
print(x, quote = FALSE)
#> [1] say "hi"    back\\slash new\nline
encodeString(x)
#> [1] "say \"hi\""    "back\\\\slash" "new\\nline"
encodeString(x, quote = "'")
#> [1] "'say \"hi\"'"    "'back\\\\slash'" "'new\\nline'"
encodeString("a", width = 5, justify = "right")
#> [1] "    a"
encodeString(NA)
#> [1] "<NA>"
encodeString(NA, na.encode = FALSE)
#> [1] NA
toString(1:5)
#> [1] "1, 2, 3, 4, 5"

cat() converts its arguments with the same rules as as.character() for atomic vectors, separates them with sep (default one blank) and does not add a final newline. It gives numbers up to seven significant digits, a factor as its integer codes (the 1 for "fiction"), and it refuses a list with argument 1 (type 'list') cannot be handled by 'cat'. message() is the right function for diagnostics, because it writes to the standard error stream and can be suppressed.

# cat() converts and separates its arguments
cat("a", "b", sep = "")
#> ab
cat("\n")
#>
cat("pages:", 387L, "\n")
#> pages: 387
cat("x", TRUE, NULL, NA, 3.5, "\n")
#> x TRUE NA 3.5
cat(c("a", "b"), "c", sep = ", ")
#> a, b, c
cat("\n")
#>
cat(1234567.891, 0.1 + 0.2, 1 / 3, "\n")
#> 1234568 0.3 0.3333333
cat(factor("fiction"), "\n")
#> 1
tryCatch(cat(list(1, "a"), "\n"), error = \(e) conditionMessage(e))
#> [1] "argument 1 (type 'list') cannot be handled by 'cat'"
tryCatch(cat(list(1:2), "\n"), error = \(e) conditionMessage(e))
#> [1] "argument 1 (type 'list') cannot be handled by 'cat'"

sQuote() and dQuote() wrap text in quotation marks for marking up messages, and the marks depend on getOption("useFancyQuotes") (R documentation: sQuote). The default TRUE gives the directional Unicode quotes in a UTF-8 locale, which is why sQuote("title") prints ‘title’ with curly quotes in a UTF-8 session, FALSE gives plain ASCII quotes, "TeX" and "UTF-8" force a style, and a character vector of length four sets your own pairs (for example guillemets). Passing the second argument q = FALSE has the same effect for one call. Use the ASCII form whenever the result goes into code, a file name or a pattern: curly quotes are different characters, and a script that contains one fails to parse. shQuote(x) is something else: it quotes a string for a shell command line (type = "sh" for the Bourne family, "csh", and "cmd" for Windows), and the example shows that a string with an apostrophe is wrapped in double quotes instead.

# Quoting functions
sQuote("title", FALSE)
#> [1] "'title'"
dQuote("title", FALSE)
#> [1] "\"title\""
sQuote("title")
#> [1] "‘title’"
dQuote("title")
#> [1] "“title”"
getOption("useFancyQuotes")
#> [1] TRUE
old <- options(useFancyQuotes = FALSE)
sQuote("title")
#> [1] "'title'"
dQuote("title")
#> [1] "\"title\""
options(old)
shQuote("it's here")
#> [1] "\"it's here\""
shQuote("plain")
#> [1] "'plain'"
shQuote("it's here", type = "cmd")
#> [1] "\"it's here\""
shQuote("a b", type = "csh")
#> [1] "'a b'"

Regular expressions

A regular expression is a pattern that describes a set of strings, and R has three ways to read one. The help page ?regex is the reference for the syntax and is long: this page covers what you need to choose the engine, to write patterns that survive R’s escaping rules and to read the results (R documentation: regex). Every function in the family takes the pattern as a single string and accepts the same three switches, which select the engine:

Switch Engine What the pattern means

none (the default)

POSIX 1003.2 extended regular expressions, implemented by the TRE library (with some extensions)

the dot, bracket expressions, groups, alternation a|b, quantifiers such as {n,m}, and the backreferences \1 to \9; the extensions \w, \d, \s, \b, \< and \>; POSIX classes such as [[:digit:]]; lazy quantifiers such as .*?

perl = TRUE

Perl-like regular expressions, implemented by the PCRE2 library

everything above except \< and \>, plus lookahead and lookbehind, \U and \L in replacements, the flags (?i), (?m) and (?s), \Q and \E quoting, \h, Unicode properties such as \p{Nd}, and named groups

fixed = TRUE

a literal string, no metacharacters at all

the characters of the pattern, matched exactly

fixed = TRUE takes priority: the help page says it overrides all conflicting arguments. R 4.0.0 migrated the perl = TRUE engine from PCRE to PCRE2, and the 4.0.0 NEWS lists the consequence: PCRE2 reports an error for some patterns PCRE1 accepted, for example a hyphen inside a character class now has to be escaped to be literal unless it is first or last, and \R, \B and \X are no longer allowed inside a class. Which engine is the right one? The help page’s guidance on performance is qualitative: perl = TRUE is generally faster than the default engine, and fixed = TRUE is faster still when the pattern is a literal. Choose by meaning first: fixed = TRUE for any pattern that is just text (a file name, a prefix, a user’s input), the default engine for simple patterns, and perl = TRUE when you need lookaround, case conversion in a replacement, named groups or Unicode properties. Name the logical arguments: a bare TRUE in the wrong position can land in ignore.case, perl or value, and the grep help page warns that passing unnamed arguments that land in these slots is an all too common mistake.

Same pattern, different engines

The next script asks the engines the same questions. The first group shows that . is a wildcard in the default engine and a dot only with fixed = TRUE, and that the last argument combination triggers a warning: ignore.case = TRUE is ignored when fixed = TRUE. The second group is the class question. \d and [[:digit:]] are the same in the default engine and match only ASCII digits in this session, perl = TRUE without further options matches only ASCII too, and (*UCP) or \p{Nd} brings in the Unicode digits (the Arabic-Indic digits in the example). The set of letters and digits a class accepts can differ between platforms, as the grep help page notes, except for [:digit:] and [:xdigit:].

# The same question put to the three engines
msg <- function(expr) {
  tryCatch(expr, error = \(e) conditionMessage(e),
           warning = \(w) paste("warning:", conditionMessage(w)))
}
# err() keeps the error and drops the warning that comes before it
err <- function(expr) {
  tryCatch(suppressWarnings(expr), error = \(e) conditionMessage(e))
}

x <- c("a.b", "axb", "A.B")

grepl("a.b", x)
#> [1]  TRUE  TRUE FALSE
grepl("a.b", x, fixed = TRUE)
#> [1]  TRUE FALSE FALSE
grepl("a\\.b", x)
#> [1]  TRUE FALSE FALSE
grepl("a[.]b", x)
#> [1]  TRUE FALSE FALSE
grepl("a.b", x, ignore.case = TRUE)
#> [1] TRUE TRUE TRUE
msg(grepl("a.b", x, fixed = TRUE, ignore.case = TRUE))
#> [1] "warning: argument 'ignore.case = TRUE' will be ignored"

# Classes: \d, [[:digit:]] and [0-9]
y <- c("42", "x7", "١٢")
grepl("\\d", y)
#> [1]  TRUE  TRUE FALSE
grepl("[[:digit:]]", y)
#> [1]  TRUE  TRUE FALSE
grepl("[0-9]", y)
#> [1]  TRUE  TRUE FALSE
grepl("\\d", y, perl = TRUE)
#> [1]  TRUE  TRUE FALSE
grepl("(*UCP)\\d", y, perl = TRUE)
#> [1] TRUE TRUE TRUE
grepl("\\p{Nd}", y, perl = TRUE)
#> [1] TRUE TRUE TRUE

The lookaround group needs perl = TRUE. The default engine has no lookahead or lookbehind and rejects the pattern with invalid regular expression ..., reason 'Invalid regexp' (the next section prints the warning that comes first). The lazy quantifier .*? works in both engines, and a negated bracket expression such as <[^>]*> is the portable way to say "up to the next closing angle bracket". The word boundary \b works in both, while \< and \> exist only in the default engine: with perl = TRUE a backslashed symbol is a literal character, so \<cat\> matches nothing. The help page also warns that the default engine does not work correctly with repeated word boundaries in gsub() and gregexpr() and recommends perl = TRUE for them.

# Lookaround exists only with perl = TRUE
err(grepl("(?<=ISBN )[0-9]+", "ISBN 123"))
#> [1] "invalid regular expression '(?<=ISBN )[0-9]+', reason 'Invalid regexp'"
grepl("(?<=ISBN )[0-9]+", "ISBN 123", perl = TRUE)
#> [1] TRUE
gsub("(?<![0-9])0+(?=[0-9])", "", "007 and 0042 and 0", perl = TRUE)
#> [1] "7 and 42 and 0"
price <- "price: 10 euro"
regmatches(price, regexpr("[0-9]+(?= euro)", price, perl = TRUE))
#> [1] "10"
err(grepl("a(?=b)", "ab"))
#> [1] "invalid regular expression 'a(?=b)', reason 'Invalid regexp'"

# Lazy quantifiers work in both; the greedy default differs
sub("<.*>", "", "<a><b>text")
#> [1] "text"
sub("<.*?>", "", "<a><b>text")
#> [1] "<b>text"
sub("<.*?>", "", "<a><b>text", perl = TRUE)
#> [1] "<b>text"
sub("<[^>]*>", "", "<a><b>text")
#> [1] "<b>text"

# Word boundaries
gsub("\\bcat\\b", "dog", "cat concat cat.")
#> [1] "dog concat dog."
gsub("\\<cat\\>", "dog", "cat concat cat.")
#> [1] "dog concat dog."
gsub("\\<cat\\>", "dog", "cat concat cat.", perl = TRUE)
#> [1] "cat concat cat."
gsub("\\bcat\\b", "dog", "cat concat cat.", perl = TRUE)
#> [1] "dog concat dog."

The last group of the script covers inline flags, fixed = TRUE overriding perl = TRUE, \Q...\E to quote a literal inside a PCRE pattern, and the locale-dependent classes. (?i) works in both engines; (?m) (multi-line ^) and (?s) (dot matches a newline) are PCRE flags. In the default engine a dot matches a newline, in PCRE it does not unless (?s) is set, and ^ matches only at the start of the string in both unless (?m) is set. The [[:alpha:]] class and \w match é in the default engine in this UTF-8 session but not with perl = TRUE, where the help page says the named classes and \w match only ASCII characters in UTF-8 mode. When a result depends on the engine, say so in a comment and test it on the non-ASCII strings you expect.

# Case-insensitive inline flag and multiline
grepl("(?i)LOUD", "Loud Roofs", perl = TRUE)
#> [1] TRUE
grepl("(?i)LOUD", "Loud Roofs")
#> [1] TRUE
grepl("^b", "a\nb")
#> [1] FALSE
grepl("(?m)^b", "a\nb", perl = TRUE)
#> [1] TRUE
grepl("a.b", "a\nb")
#> [1] TRUE
grepl("a.b", "a\nb", perl = TRUE)
#> [1] FALSE
grepl("(?s)a.b", "a\nb", perl = TRUE)
#> [1] TRUE

# fixed = TRUE overrides perl = TRUE
msg(grepl("a.b", x, perl = TRUE, fixed = TRUE))
#> [1] "warning: argument 'perl = TRUE' will be ignored"
msg(grepl("(?i)a", "A", fixed = TRUE))
#> [1] FALSE

# fixed = TRUE and ignore.case
msg(sub("A", "x", "banana", fixed = TRUE, ignore.case = TRUE))
#> [1] "warning: argument 'ignore.case = TRUE' will be ignored"

# Escaping a literal pattern with \Q ... \E
grepl("\\Qa.b\\E", x, perl = TRUE)
#> [1]  TRUE FALSE FALSE

# POSIX word class in a bracket expression
grepl("^[[:alpha:]]+$", c("abc", "ab1", "é"))
#> [1]  TRUE FALSE  TRUE
grepl("^[[:alpha:]]+$", c("abc", "ab1", "é"), perl = TRUE)
#> [1]  TRUE FALSE FALSE
grepl("^\\w+$", c("abc", "ab_1", "é"))
#> [1] TRUE TRUE TRUE
grepl("^\\w+$", c("abc", "ab_1", "é"), perl = TRUE)
#> [1]  TRUE  TRUE FALSE

grepl(), grep() and grepv()

grepl(pattern, x) returns one logical per element, grep(pattern, x) returns the integer positions of the matching elements, and grep(pattern, x, value = TRUE) returns the matching elements themselves (R documentation: grep). invert = TRUE selects the non-matching ones. R 4.5.0 added grepv(), which the NEWS describes as identical to grep() except for the default value value = TRUE; it is the function to reach for when you want the elements, and args(grepv) shows the one difference. A missing element is never a match for a non-missing pattern, grepl("a", NA) is FALSE, and value = TRUE keeps the names of a named vector. Nothing else changes: the same pattern switches, the same single-string pattern, and a factor is matched on its labels.

source("scenario.R")

# grepl() answers TRUE/FALSE for every element
grepl("-", books$isbn, fixed = TRUE)
#>  [1]  TRUE  TRUE FALSE  TRUE  TRUE FALSE  TRUE  TRUE FALSE  TRUE FALSE  TRUE
#> [13]  TRUE  TRUE FALSE  TRUE
grepl("^[Tt]he ", books$title)
#>  [1]  TRUE FALSE FALSE FALSE FALSE FALSE FALSE  TRUE FALSE FALSE FALSE FALSE
#> [13] FALSE  TRUE FALSE FALSE
grepl("fiction", books$genre)
#>  [1]  TRUE FALSE FALSE  TRUE FALSE  TRUE FALSE FALSE  TRUE FALSE FALSE  TRUE
#> [13]  TRUE  TRUE FALSE FALSE
grepl("fiction", books$genre, ignore.case = TRUE)
#>  [1]  TRUE FALSE  TRUE  TRUE FALSE  TRUE FALSE FALSE  TRUE  TRUE FALSE  TRUE
#> [13]  TRUE  TRUE  TRUE FALSE
grepl("^fiction$", trimws(books$genre), ignore.case = TRUE)
#>  [1] FALSE FALSE  TRUE  TRUE FALSE FALSE FALSE FALSE FALSE  TRUE FALSE FALSE
#> [13] FALSE FALSE  TRUE FALSE
grepl("a", NA)
#> [1] FALSE
grepl("a", character(0))
#> logical(0)
grepl(NA, "a")
#> [1] NA

# grep() returns positions, or the matching elements with value = TRUE
grep("Guin", books$author)
#> [1] 1 8
grep("Guin", books$author, value = TRUE)
#> [1] "Ursula K. Le Guin"  "Le Guin, Ursula K."
grep("Guin", books$author, invert = TRUE)
#>  [1]  2  3  4  5  6  7  9 10 11 12 13 14 15 16
grep("zzz", books$author)
#> integer(0)
grep("zzz", books$author, value = TRUE)
#> character(0)

# grepv() is grep(value = TRUE) (R 4.5.0)
grepv("Guin", books$author)
#> [1] "Ursula K. Le Guin"  "Le Guin, Ursula K."
identical(grepv("Mara", books$author), grep("Mara", books$author, value = TRUE))
#> [1] TRUE
args(grepv)
#> function (pattern, x, ignore.case = FALSE, perl = FALSE, value = TRUE,
#>     fixed = FALSE, useBytes = FALSE, invert = FALSE)
#> NULL
args(grep)
#> function (pattern, x, ignore.case = FALSE, perl = FALSE, value = FALSE,
#>     fixed = FALSE, useBytes = FALSE, invert = FALSE)
#> NULL

# value = TRUE keeps the names of a named vector
named <- setNames(books$author[1:4], books$title[1:4])
grepv("Quill|Austen", named)
#>  Pride and Prejudice   the quiet streets            Loud Roofs
#>        "Jane Austen"         "Mara Quill"         "Mara Quill"

# Matching the elements of a factor works on its labels
grepl("fic", factor(c("fiction", "poetry")))
#> [1]  TRUE FALSE

The pattern must be a single string. A longer pattern vector uses the first element with a warning (argument 'pattern' has length > 1 and only the first element will be used), a zero-length one is the error invalid 'pattern' argument, and an NA pattern gives NA. The logical arguments are checked too: since R 4.5.0 a value that is neither TRUE nor FALSE is reported as NA, as the NEWS entry says, which makes the error message the unhelpful NA in coercion to boolean when you pass something like fixed = "yes".

# The pattern must be a single string
tryCatch(grepl(c("a", "b"), "abc"), warning = \(w) conditionMessage(w))
#> [1] "argument 'pattern' has length > 1 and only the first element will be used"
tryCatch(grepl(character(0), "abc"), error = \(e) conditionMessage(e))
#> [1] "invalid 'pattern' argument"
tryCatch(grepl("a", "abc", fixed = "yes"), warning = \(w) conditionMessage(w),
         error = \(e) conditionMessage(e))
#> [1] "NA in coercion to boolean"

sub() and gsub()

sub(pattern, replacement, x) replaces the first match in each element and gsub() replaces all of them, and both return a character vector with the attributes of x (R documentation: grep). The replacement is not a plain string: in the default engine and in PCRE a backslash followed by a digit from 1 to 9 inserts the text of that parenthesised group, which is how the shelf’s Le Guin, Ursula K. becomes Ursula K. Le Guin; with perl = TRUE the replacement may also contain \U, \L and \E to convert the rest of the replacement to upper or lower case. Neither engine has a backreference to the whole match: put the whole pattern in parentheses and use \1. Remember that R reads the string first, so every backslash is written twice ("\\2 \\1"); Escaping gives the full rule.

source("scenario.R")

# sub() replaces the first match, gsub() every match
x <- "2026-06-30"
sub("-", "/", x)
#> [1] "2026/06-30"
gsub("-", "/", x)
#> [1] "2026/06/30"
gsub("[0-9]", "#", x)
#> [1] "####-##-##"
gsub(".", "-", "a.b", fixed = TRUE)
#> [1] "a-b"
gsub(".", "-", "a.b")
#> [1] "---"

# Collapse runs of blanks (the double space in "Iris  Fenwick")
gsub(" +", " ", books$author[10])
#> [1] "Iris Fenwick"
gsub("\\s+", " ", "a \t b\n c")
#> [1] "a b c"
gsub("[[:space:]]+", " ", "a \t b\n c")
#> [1] "a b c"

# Backreferences: swap "Le Guin, Ursula K." to "Ursula K. Le Guin"
sub("^(.*), (.*)$", "\\2 \\1", books$author[8])
#> [1] "Ursula K. Le Guin"
sub("^(.*), (.*)$", "\\2 \\1", books$author[1:3])
#> [1] "Ursula K. Le Guin" "Jane Austen"       "Mara Quill"

# \1 to \9 are the groups; an unmatched group gives ""
gsub("(a)(b)?", "[\\1|\\2]", "ab a")
#> [1] "[a|b] [a|]"
# \0 is not the whole match in either engine; a group around the pattern is
sub("[0-9]+", "<\\0>", "ab123cd")
#> [1] "ab<0>cd"
sub("[0-9]+", "<\\0>", "ab123cd", perl = TRUE)
#> [1] "ab<0>cd"
sub("([0-9]+)", "<\\1>", "ab123cd")
#> [1] "ab<123>cd"

# perl = TRUE adds case conversion with \U \L \E
gsub("\\b([a-z])", "\\U\\1", tolower(books$title[10]), perl = TRUE)
#> [1] "The Long Table"
gsub("(\\w+)@(\\w+)", "\\U\\1\\E at \\2", "ana@shelf", perl = TRUE)
#> [1] "ANA at shelf"
gsub("(?<=a)b", "X", "abab", perl = TRUE)
#> [1] "aXaX"
gsub("\\b(\\w)", "\\U\\1", "the quiet streets", perl = TRUE)
#> [1] "The Quiet Streets"
gsub("(\\w+)", "\\L\\1", "THE LONG", perl = TRUE)
#> [1] "the long"
# the default engine does not know \U: the U is kept literally
gsub("(\\w+)", "\\U\\1", "the long")
#> [1] "Uthe Ulong"

Details you will otherwise discover the hard way:

  • \0 is not a backreference: ?grep documents only \1 to \9, and with or without perl = TRUE the replacement "<\\0>" inserts <0>. Wrap the whole pattern in a group and use \1 instead. An unmatched optional group inserts nothing.

  • \U\1 without perl = TRUE is not an error: the U is kept as a literal, and gsub("(\\w+)", "\\U\\1", "the long") returns "Uthe Ulong".

  • ignore.case = TRUE works with the default and PCRE engines and is ignored (with a warning) with fixed = TRUE.

  • An NA replacement makes every matched element NA; an NA pattern makes everything NA; non-matching elements are returned unchanged, and a factor x is returned as character.

  • Named backreferences are not supported by sub(): the help page of ?regex says so.

# ignore.case, NA, vectors
sub("^the ", "", c("The Slow Comet", "THE LONG TABLE", NA), ignore.case = TRUE)
#> [1] "Slow Comet" "LONG TABLE" NA
sub("a", "A", character(0))
#> character(0)
sub("a", NA, "banana")
#> [1] NA
sub(NA, "A", "banana")
#> [1] NA
sub("a", "A", factor(c("banana", "apple")))
#> [1] "bAnana" "Apple"

# Special characters in the replacement
gsub("a", "\\\\", "banana")
#> [1] "b\\n\\n\\"
gsub("a", "$1", "banana")
#> [1] "b$1n$1n$1"
gsub("(a)", "\\\\1", "banana")
#> [1] "b\\1n\\1n\\1"
gsub("a", "&", "banana")
#> [1] "b&n&n&"
gsub(".", "\\.", "a.b", fixed = TRUE)
#> [1] "a\\.b"

# With fixed = TRUE the pattern is literal, so "(a)" is not found
sub("(a)", "\\1\\1", "banana", fixed = TRUE)
#> [1] "banana"
sub("(a)", "[\\1]", "b(a)nana", fixed = TRUE)
#> [1] "b[\\1]nana"

# An empty pattern matches between characters
gsub("", "-", "abc")
#> [1] "-a-b-c-"
gsub("b*", "-", "abc")
#> [1] "-a-c-"
sub("", "-", "abc")
#> [1] "-abc"

# Remove everything but digits, upper-case, then ISBN normalisation
gsub("[^0-9X]", "", toupper(books$isbn[1:3]))
#> [1] "9780306406157" "9780141439518" "9780001000100"

In the replacement, the R literal "\\\\" produces one literal backslash, $1 and & have no special meaning (they are not group references in R), and an escaped dot in a fixed = TRUE replacement is the two characters backslash and dot. An empty pattern matches between every character, and a pattern that can match the empty string, such as b*, gives the extra insertions that surprise newcomers: gsub("b*", "-", "abc") is "-a-c-".

regexpr(), gregexpr(), regexec() and gregexec()

The matching functions that return positions differ by how much they tell you (R documentation: grep):

Function Returns Notes

regexpr()

an integer vector with the start of the first match in each element, -1 for no match

attribute match.length holds the length (-1 for no match); with perl = TRUE and named groups also capture.start, capture.length and capture.names

gregexpr()

a list with one regexpr()-like vector per element, covering every match

an element without a match is -1 with match.length -1

regexec()

a list; each element holds the start of the match and of each parenthesised group

match.length has the same layout; a group that did not take part has start 0 and length 0

gregexec()

like regexec() but for all matches, as a matrix per element

new in R 4.1.0

Positions are in characters unless useBytes = TRUE, in which case they are in bytes: in the example regexpr("é", "café") is position 4 with length 1 in characters and length 2 in bytes. For ASCII-only text the result also carries the attributes index.type and useBytes, and the printed useBytes is TRUE because the byte and character positions are the same; the help page describes this. NA input gives NA positions, a zero-length match is allowed (regexpr("x*", "abc") matches the empty string at position 1), and an NA pattern is an error for these functions (invalid 'pattern' argument). The positions are only useful with substring() or regmatches().

source("scenario.R")

titles <- c("The Dispossessed", "Loud Roofs", "Ten Winters")

# regexpr(): position and length of the first match, -1 for no match
m <- regexpr("[A-Z][a-z]+$", titles)
m
#> [1] 5 6 5
#> attr(,"match.length")
#> [1] 12  5  7
#> attr(,"index.type")
#> [1] "chars"
#> attr(,"useBytes")
#> [1] TRUE
as.integer(m)
#> [1] 5 6 5
attr(m, "match.length")
#> [1] 12  5  7
regmatches(titles, m)
#> [1] "Dispossessed" "Roofs"        "Winters"

# gregexpr(): a list, one element per string, with every match
g <- gregexpr("[A-Z]", titles)
g[[1]]
#> [1] 1 5
#> attr(,"match.length")
#> [1] 1 1
#> attr(,"index.type")
#> [1] "chars"
#> attr(,"useBytes")
#> [1] TRUE
lengths(g)
#> [1] 2 2 2
vapply(g, \(x) sum(x > 0), integer(1))
#> [1] 2 2 2
gregexpr("zzz", "abc")[[1]]
#> [1] -1
#> attr(,"match.length")
#> [1] -1
#> attr(,"index.type")
#> [1] "chars"
#> attr(,"useBytes")
#> [1] TRUE
gregexpr("o", "foo boo", fixed = TRUE)[[1]]
#> [1] 2 3 6 7
#> attr(,"match.length")
#> [1] 1 1 1 1
#> attr(,"index.type")
#> [1] "chars"
#> attr(,"useBytes")
#> [1] TRUE

# Positions are in characters; useBytes changes the unit
regexpr("é", "café")
#> [1] 4
#> attr(,"match.length")
#> [1] 1
regexpr("é", "café", useBytes = TRUE)
#> [1] 4
#> attr(,"match.length")
#> [1] 2
#> attr(,"index.type")
#> [1] "bytes"
#> attr(,"useBytes")
#> [1] TRUE

regexec() is what you use to parse a fixed format with groups, such as a date. Its list element has the start of the whole match first and then of each group, and regmatches() turns that into the strings: the whole match followed by the groups. A group that did not take part in the match has start 0 and gives the empty string. gregexec(), new in R 4.1.0 according to the NEWS, generalises regexec() to all disjoint matches, with the substrings of the groups in the columns of a matrix.

# regexec(): the match and each parenthesised group
d <- "2026-06-30"
r <- regexec("^([0-9]{4})-([0-9]{2})-([0-9]{2})$", d)
r
#> [[1]]
#> [1] 1 1 6 9
#> attr(,"match.length")
#> [1] 10  4  2  2
#> attr(,"index.type")
#> [1] "chars"
#> attr(,"useBytes")
#> [1] TRUE
#>
regmatches(d, r)
#> [[1]]
#> [1] "2026-06-30" "2026"       "06"         "30"
#>
regmatches(d, r)[[1]][2:4]
#> [1] "2026" "06"   "30"
as.integer(regmatches(d, r)[[1]][2:4])
#> [1] 2026    6   30
regexec("(a)(z)?", "abc")[[1]]
#> [1] 1 1 0
#> attr(,"match.length")
#> [1] 1 1 0
#> attr(,"index.type")
#> [1] "chars"
#> attr(,"useBytes")
#> [1] TRUE
regmatches("abc", regexec("(a)(z)?", "abc"))
#> [[1]]
#> [1] "a" "a" ""
#>

# gregexec(): every match and its groups, as a matrix (R 4.1.0)
s <- "Test: A1 BC23 DEF456"
gm <- gregexec("([[:alpha:]]+)([[:digit:]]+)", s)
regmatches(s, gm)
#> [[1]]
#>      [,1] [,2]   [,3]
#> [1,] "A1" "BC23" "DEF456"
#> [2,] "A"  "BC"   "DEF"
#> [3,] "1"  "23"   "456"
#>

# An NA input gives NA positions; a zero-length match is allowed
regexpr("a", c("banana", NA))
#> [1]  2 NA
#> attr(,"match.length")
#> [1]  1 NA
#> attr(,"index.type")
#> [1] "chars"
#> attr(,"useBytes")
#> [1] TRUE
regexpr("x*", "abc")
#> [1] 1
#> attr(,"match.length")
#> [1] 0
#> attr(,"index.type")
#> [1] "chars"
#> attr(,"useBytes")
#> [1] TRUE
tryCatch(regexpr(NA, "abc"), error = \(e) conditionMessage(e))
#> [1] "invalid 'pattern' argument"

regmatches() and its replacement form

regmatches(x, m) extracts the matched substrings using the match data m returned by regexpr(), gregexpr(), regexec() or gregexec() (R documentation: regmatches). With the vector data of regexpr() it returns a character vector with one element per match, and the strings without a match are dropped, so the result can be shorter than x. With the list data of gregexpr() it returns a list with character(0) for the strings without a match. invert = TRUE returns the pieces between the matches (it is a strsplit() driven by the match data) and invert = NA returns both. This is how the "extract the first number" and "extract all numbers" idioms are written in base R.

source("scenario.R")

# Extract every ISBN-like run of 13 digits after removing hyphens
isbn <- gsub("-", "", books$isbn, fixed = TRUE)
regmatches(isbn, regexpr("^[0-9]{13}$", isbn))
#>  [1] "9780306406157" "9780141439518" "9780001000100" "9780002000291"
#>  [5] "9780003000313" "9780004000404" "9780005000571" "9780006000624"
#>  [9] "9780008000851" "9780009000911" "9780010001044" "9780011001129"
#> [13] "9780012001264" "9780013001331" "9780014001453"

# regexpr() gives a vector: strings without a match are dropped
x <- c("A and B", "A, B and C", "foobar")
pattern <- "[[:space:]]*(,|and)[[:space:]]"
m <- regexpr(pattern, x)
regmatches(x, m)
#> [1] " and " ", "
regmatches(x, m, invert = TRUE)
#> [[1]]
#> [1] "A" "B"
#>
#> [[2]]
#> [1] "A"       "B and C"
#>
#> [[3]]
#> [1] "foobar"
#>

# gregexpr() gives a list, with character(0) for no match
g <- gregexpr(pattern, x)
regmatches(x, g)
#> [[1]]
#> [1] " and "
#>
#> [[2]]
#> [1] ", "    " and "
#>
#> [[3]]
#> character(0)
#>
regmatches(x, g, invert = TRUE)
#> [[1]]
#> [1] "A" "B"
#>
#> [[2]]
#> [1] "A" "B" "C"
#>
#> [[3]]
#> [1] "foobar"
#>

# invert = NA returns both the matches and the pieces between them
regmatches("a1b22c", gregexpr("[0-9]+", "a1b22c"), invert = NA)
#> [[1]]
#> [1] "a"  "1"  "b"  "22" "c"
#>

# Extract all numbers from a string
txt <- "Loan L012 returned after 25 days, fine 3 euro"
as.integer(regmatches(txt, gregexpr("[0-9]+", txt))[[1]])
#> [1] 12 25  3
regmatches(txt, gregexpr("[0-9]+", txt))
#> [[1]]
#> [1] "012" "25"  "3"
#>

The replacement form regmatches(x, m) <- value overwrites the matched pieces. For vector data, value is a character vector with one element per matched string; for list data it is a list with one character vector per element of x, recycled generously; missing replacement values are not allowed (missing replacement values are not allowed). Replacing by match data is how you make a different replacement for each match, which gsub() cannot do.

# regmatches<- replaces the matched parts in place
y <- c("price 10 and 20", "none", "7")
m <- gregexpr("[0-9]+", y)
regmatches(y, m) <- list(c("<ten>", "<twenty>"), character(0), "<seven>")
y
#> [1] "price <ten> and <twenty>" "none"
#> [3] "<seven>"

z <- c("a1", "b22", "c")
regmatches(z, regexpr("[0-9]+", z)) <- c("X", "YY")
z
#> [1] "aX"  "bYY" "c"

# Values are recycled generously and must not be missing
w <- c("a1b2", "c3")
regmatches(w, gregexpr("[0-9]", w)) <- list("#")
w
#> [1] "a#b#" "c#"
tryCatch({
  regmatches(w, gregexpr("[a-z]", w)) <- list(NA_character_)
}, error = \(e) conditionMessage(e))
#> [1] "missing replacement values are not allowed"

Named capture groups

With perl = TRUE, regexpr() and gregexpr() support Python-style named groups (?<name>...). The result then carries the attributes capture.start, capture.length and capture.names, matrices with one row per element and one column per group, -1 for a non-match, and you extract a group with substring(). This is the base R way of parsing a date into its parts. The help page adds the restriction that named capture does not work for long-vector inputs, and named backreferences are not supported by sub().

# Named capture groups (PCRE only)
dates <- c("2026-06-30", "x", "2025-01-02")
m <- regexpr("(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})",
             dates, perl = TRUE)
attr(m, "capture.names")
#> [1] "year"  "month" "day"
attr(m, "capture.start")
#>      year month day
#> [1,]    1     6   9
#> [2,]   -1    -1  -1
#> [3,]    1     6   9
attr(m, "capture.length")
#>      year month day
#> [1,]    4     2   2
#> [2,]   -1    -1  -1
#> [3,]    4     2   2
start <- attr(m, "capture.start")[, "year"]
len <- attr(m, "capture.length")[, "year"]
substring(dates, start, start + len - 1L)
#> [1] "2026" ""     "2025"

Approximate matching

agrep() and agrepl() match approximately, allowing a number of insertions, deletions and substitutions (max.distance), and adist() computes the generalised Levenshtein distance (R documentation: agrep). The next lines find the title Dispossessed misspelled with one s missing, and the distance between two words is 3. The block also repeats the agrep() example with max.distance = 2. They are the base tool for fuzzy joins of the messy shelf text.

agrepl("Dispossesed", "The Dispossessed")
#> [1] TRUE
agrep("lasy", c("1 lazy", "1", "1 LAZY"), max.distance = 2, value = TRUE)
#> [1] "1 lazy"
adist("kitten", "sitting")
#>      [,1]
#> [1,]    3

Invalid patterns and their errors

A pattern that the engine cannot compile first raises a warning that names the engine, then an error. The warning is useful because the error of the default engine repeats only a short reason, and the PCRE2 error has no reason at all. The script catches both with a calling handler, collapses the line breaks that PCRE2 puts in its message into spaces, and prints the messages as R produces them.

# Invalid patterns: a warning with the engine's reason, then an error
show <- \(expr) {
  out <- character()
  res <- withCallingHandlers(
    tryCatch(
      paste("value:", format(expr)),
      error = \(e) paste("error:", conditionMessage(e))
    ),
    warning = \(w) {
      txt <- gsub("\n\t", " ", conditionMessage(w))
      out <<- c(out, paste("warning:", txt))
      invokeRestart("muffleWarning")
    }
  )
  c(out, res)
}

show(grepl("(", "a(b"))
#> [1] "warning: TRE pattern compilation error 'Missing ')''"
#> [2] "error: invalid regular expression '(', reason 'Missing ')''"
show(grepl("[a", "a"))
#> [1] "warning: TRE pattern compilation error 'Missing ']''"
#> [2] "error: invalid regular expression '[a', reason 'Missing ']''"
show(grepl("a{2,1}", "aa"))
#> [1] "warning: TRE pattern compilation error 'Invalid contents of {}'"
#> [2] "error: invalid regular expression 'a{2,1}', reason 'Invalid contents of {}'"
show(grepl("\\", "a"))
#> [1] "warning: TRE pattern compilation error 'Trailing backslash'"
#> [2] "error: invalid regular expression '\\', reason 'Trailing backslash'"
show(grepl("*a", "aa"))
#> [1] "value: TRUE"
show(grepl("a)", "a)"))
#> [1] "value: TRUE"
show(grepl("(", "a(b", perl = TRUE))
#> [1] "warning: PCRE pattern compilation error 'missing closing parenthesis' at ''"
#> [2] "error: invalid regular expression '('"
show(grepl("[a", "a", perl = TRUE))
#> [1] "warning: PCRE pattern compilation error 'missing terminating ] for character class' at ''"
#> [2] "error: invalid regular expression '[a'"
show(grepl("(?<n>a)(?<n>b)", "ab", perl = TRUE))
#> [1] "warning: PCRE pattern compilation error 'two named subpatterns have the same name (PCRE2_DUPNAMES not set)' at 'b)'"
#> [2] "error: invalid regular expression '(?<n>a)(?<n>b)'"
show(grepl("\\p{Zz}", "a", perl = TRUE))
#> [1] "warning: PCRE pattern compilation error 'unknown property after \\P or \\p' at ''"
#> [2] "error: invalid regular expression '\\p{Zz}'"
show(grepl("*a", "aa", perl = TRUE))
#> [1] "warning: PCRE pattern compilation error 'quantifier does not follow a repeatable item' at '*a'"
#> [2] "error: invalid regular expression '*a'"
show(grepl("[z-a]", "a"))
#> [1] "warning: TRE pattern compilation error 'Invalid character range'"
#> [2] "error: invalid regular expression '[z-a]', reason 'Invalid character range'"
show(grepl("[z-a]", "a", perl = TRUE))
#> [1] "warning: PCRE pattern compilation error 'range out of order in character class' at 'a]'"
#> [2] "error: invalid regular expression '[z-a]'"
show(gsub("(a", "x", "a"))
#> [1] "warning: TRE pattern compilation error 'Missing ')''"
#> [2] "error: invalid regular expression '(a', reason 'Missing ')''"
show(regexpr("(", "a"))
#> [1] "warning: TRE pattern compilation error 'Missing ')''"
#> [2] "error: invalid regular expression '(', reason 'Missing ')''"
show(strsplit("a(b", "("))
#> [1] "warning: TRE pattern compilation error 'Missing ')''"
#> [2] "error: invalid regular expression '(', reason 'Missing ')''"
show(grepl("(", "a(b", fixed = TRUE))
#> [1] "value: TRUE"
show(gsub("a", "\\1", "banana"))
#> [1] "value: bnn"
show(gsub("(a)", "\\2", "banana"))
#> [1] "value: bnn"
show(grepl("a", "a", ignore.case = NA))
#> [1] "error: NA in coercion to boolean"

The error invalid regular expression '(', reason 'Missing ')'' is the one everybody meets: an unbalanced parenthesis, where the reason Missing ')' is itself quoted, so the message ends with two apostrophes. The default engine gives short reasons (Missing ']', Invalid contents of {}, Trailing backslash, Invalid character range) and treats a few odd patterns as valid: a leading * or an unmatched ) compiles, which hides typos. PCRE2 is stricter and gives longer reasons, for example quantifier does not follow a repeatable item for a leading * and range out of order in character class for [z-a]. strsplit(), gsub() and regexpr() raise the same errors as grepl(). With fixed = TRUE nothing can be invalid, which is why untrusted text such as a user’s search box must always be matched with fixed = TRUE or escaped first.

A replacement that refers to a group that does not exist does not fail: gsub("(a)", "\\2", "banana") removes the a, since the help page says only that the result is undefined and in practice the missing group is taken as empty. Use grepl() on the pattern, or count the groups of a test match, when the replacement is generated.

Backslashes, escaping and raw strings

The R FAQ entry for this problem opens with the distinction between a string and its representation and then states the rule: to put a backslash in a string, escape the escape character by doubling it. Two parsers read the text in turn, so a regular expression is escaped twice, once for R’s string parser and once for the regex engine (R FAQ 7.37: Why does backslash behave strangely inside strings?):

What you want R string literal What the regex engine receives

a literal dot

"\\."

\. (two characters)

a literal backslash

"\\\\"

\\, which matches one backslash

a digit

"\\d"

\d

a word boundary

"\\b"

\b (a single "\b" is the backspace character, not a boundary)

group 1 in a replacement

"\\1"

\1

a newline

"\n"

the newline character itself (one character, not two)

Printing hides the difference: print() shows the string with each backslash doubled and cat() shows what the engine sees. The manual makes the same point about Windows paths (R FAQ 7.8: How do file names work in Windows?): write "c:\\data\\money.dat" or use forward slashes, which R accepts on Windows.

# The string, its printed representation and what cat() writes
s <- "C:\\shelf\\books.csv"
s
#> [1] "C:\\shelf\\books.csv"
nchar(s)
#> [1] 18
cat(s, "\n")
#> C:\shelf\books.csv
writeLines(s)
#> C:\shelf\books.csv

# FAQ 7.37: "\n" is one character, not two
nchar("\n")
#> [1] 1
nchar("\\n")
#> [1] 2
cat("\\n", "\n")
#> \n

# A regular expression needs the backslash twice: R string, then regex
grepl("\\.", c("a.b", "axb"))
#> [1]  TRUE FALSE
grepl(".", c("a.b", "axb"), fixed = TRUE)
#> [1]  TRUE FALSE
nchar("\\.")
#> [1] 2
cat("\\.", "\n")
#> \.
gsub("\\\\", "/", s)
#> [1] "C:/shelf/books.csv"
gsub("\\", "/", s, fixed = TRUE)
#> [1] "C:/shelf/books.csv"
strsplit(s, "\\", fixed = TRUE)[[1]]
#> [1] "C:"        "shelf"     "books.csv"
strsplit(s, "\\\\")[[1]]
#> [1] "C:"        "shelf"     "books.csv"
gsub("/", "\\\\", "a/b/c")
#> [1] "a\\b\\c"
cat(gsub("/", "\\\\", "a/b/c"), "\n")
#> a\b\c

Raw strings

R 4.0.0 added raw character constants, written r"(...)" with the same idea as C++. The NEWS entry says the content is any character sequence that does not contain a closing parenthesis followed by a double quote, and that this makes it easier to write strings that contain backslashes or both kinds of quotes. Inside a raw string nothing is an escape, so r"(\.)" is the two characters backslash and dot, and r"(C:\shelf\books.csv)" is the Windows path as typed. If the text contains a closing parenthesis followed by a double quote, add dashes between the r and the quote: r"-(...)-" ends only at the sequence closing parenthesis, dash, double quote. The delimiters can also be square or curly brackets instead of parentheses, and the r can be upper case. The syntax and the delimiter rules are covered on Syntax and Lexical Structure; raw strings do not exist before R 4.0.0, so code for older versions keeps the doubled backslashes.

Raw strings do not make the regex engine’s own escapes go away: r"(\d{13})" still contains the backslash and the braces that PCRE and TRE read, only R’s string parser stays out of the way. A raw string is also an ordinary character constant once parsed, so identical() shows it equal to the doubled form.

# Raw strings (R 4.0.0) need no doubling
r"(C:\shelf\books.csv)"
#> [1] "C:\\shelf\\books.csv"
identical(r"(C:\shelf\books.csv)", s)
#> [1] TRUE
grepl(r"(\.)", c("a.b", "axb"))
#> [1]  TRUE FALSE
gsub(r"(\\)", "/", s)
#> [1] "C:/shelf/books.csv"
r"(a "quoted" word)"
#> [1] "a \"quoted\" word"
r"-(contains )" inside)-"
#> [1] "contains )\" inside"
r"[brackets]"
#> [1] "brackets"
r"{braces}"
#> [1] "braces"
R"(upper-case R)"
#> [1] "upper-case R"
nchar(r"(\d{13})")
#> [1] 6
isbn13 <- r"(^(978|979)\d{10}$)"
grepl(isbn13, c("9780306406157", "978030640615", "1230306406157"))
#> [1]  TRUE FALSE FALSE

# \b: backspace in a string, word boundary in a regex
nchar("\b")
#> [1] 1
grepl("\\bsalt\\b", "A Map of Salt", ignore.case = TRUE)
#> [1] TRUE
grepl("\b", "A Map of Salt")
#> [1] FALSE

Escaping a literal pattern

To match user-supplied text literally use fixed = TRUE (the cheapest option), wrap a metacharacter in a bracket expression ([(], [.]), put a backslash in front of it, or build the escape programmatically. PCRE can also quote with \Q...\E. The helper in the next block escapes every regular expression metacharacter of the default engine with one gsub() (the character class [][{}()+*^$|\\?.] lists them, with ] first as the help page requires). Escaping a pattern is rarely needed when fixed = TRUE fits.

# Escaping the pattern: fixed = TRUE, brackets, or a helper
literal <- "a(b"
grepl(literal, "xa(by", fixed = TRUE)
#> [1] TRUE
grepl("a[(]b", "xa(by")
#> [1] TRUE
grepl("a\\(b", "xa(by")
#> [1] TRUE
regex_escape <- \(x) gsub("([][{}()+*^$|\\\\?.])", "\\\\\\1", x)
regex_escape("a(b)+[c].d")
#> [1] "a\\(b\\)\\+\\[c\\]\\.d"
grepl(regex_escape(literal), "xa(by")
#> [1] TRUE

Cleaning the shelf data

The shelf data has the typical mess of text that a person typed. The cleaning below uses only functions from this page. It is deliberately written as small named functions, which is the form to use when the same cleaning is applied to several columns.

Titles and authors

A title is cleaned by trimming it, squeezing inner runs of blanks into one, and applying a case convention. Sentence case is built with substr(), toupper() and substring(); title case with a PCRE replacement that upper-cases the first letter of each word with \U. The author column has two problems: the blank run in Iris Fenwick and the reversed order of Le Guin, Ursula K., which a backreference swaps (\\2 \\1). After the cleaning, table(authors) shows that the two spellings of Le Guin are one author.

source("scenario.R")

# Titles: trim, squeeze blanks, then sentence case
clean_title <- function(x) {
  x <- gsub("\\s+", " ", trimws(x))
  paste0(toupper(substr(x, 1, 1)), tolower(substring(x, 2)))
}
clean_title(books$title[c(1, 3, 10)])
#> [1] "The dispossessed"  "The quiet streets" "The long table"

# Title case with PCRE: capitalise each word
title_case <- function(x) {
  gsub("\\b([a-z])", "\\U\\1", tolower(trimws(x)), perl = TRUE)
}
title_case(books$title[c(3, 10)])
#> [1] "The Quiet Streets" "The Long Table"

# Authors: squeeze blanks and reorder "Last, First" to "First Last"
fix_author <- function(x) {
  x <- gsub("\\s+", " ", trimws(x))
  sub("^([^,]+), (.+)$", "\\2 \\1", x)
}
authors <- fix_author(books$author)
authors[c(1, 8, 10)]
#> [1] "Ursula K. Le Guin" "Ursula K. Le Guin" "Iris Fenwick"
table(authors)[c("Ursula K. Le Guin", "Iris Fenwick")]
#> authors
#> Ursula K. Le Guin      Iris Fenwick
#>                 2                 2

ISBNs

Strip the hyphens with a fixed pattern (cheaper than a regular expression and cannot be confused by the hyphen), then test the shape: ^97[89][0-9]{10}$ is 13 digits starting with 978 or 979. The regular expression only checks the shape. Whether the check digit is right is arithmetic, done by is_valid_isbn13() of the scenario, which is why 15 values have the right shape but only 14 are valid: the book "Orchard Logic" has a well-formed ISBN with a wrong check digit, and the empty string of "Rivers of Glass" fails both tests. The scenario code uses the same two functions, gsub() and toupper(), in normalise_isbn().

# ISBNs: strip hyphens, then check the shape with a regex
bare <- gsub("-", "", books$isbn, fixed = TRUE)
bare
#>  [1] "9780306406157" "9780141439518" "9780001000100" "9780002000291"
#>  [5] "9780003000313" "9780004000404" "9780005000571" "9780006000624"
#>  [9] ""              "9780008000851" "9780009000911" "9780010001044"
#> [13] "9780011001129" "9780012001264" "9780013001331" "9780014001453"
grepl("^97[89][0-9]{10}$", bare)
#>  [1]  TRUE  TRUE  TRUE  TRUE  TRUE  TRUE  TRUE  TRUE FALSE  TRUE  TRUE  TRUE
#> [13]  TRUE  TRUE  TRUE  TRUE
sum(grepl("^97[89][0-9]{10}$", bare))
#> [1] 15
# The checksum rule lives in the scenario's is_valid_isbn13()
is_valid_isbn13(books$isbn)
#>  [1]  TRUE  TRUE  TRUE  TRUE  TRUE  TRUE  TRUE  TRUE FALSE  TRUE  TRUE  TRUE
#> [13] FALSE  TRUE  TRUE  TRUE

Genres and loan members

The nine spellings of genre become six after trimws() and tolower(); the hyphen in Non-fiction is a separate decision (rename to nonfiction or keep it) that depends on what the reader wants, and the code makes it explicit with an anchored sub(). The loans$member column has ten distinct strings: the empty string and nine spellings of eight members, because ana ruiz with a trailing blank is Ana Ruiz. Squeezing blanks, trimming and title-casing fix it, and the empty string in row 33 stays empty, because it is a missing value written as text, not a name. Data Frames shows how to apply such a function to a column and keep the result in the data frame.

# Genres: trim and lower-case, then compare the levels
genre <- tolower(trimws(books$genre))
genre <- sub("^non-fiction$", "nonfiction", genre)
sort(unique(genre))
#> [1] "classic"         "fiction"         "mystery"         "nonfiction"
#> [5] "poetry"          "science fiction"
table(genre)
#> genre
#>         classic         fiction         mystery      nonfiction          poetry
#>               2               4               2               3               2
#> science fiction
#>               3
unique(books$genre)
#> [1] "Science fiction" "Classic"         "Fiction"         "fiction"
#> [5] "Mystery"         "Non-fiction"     "Poetry"          "Fiction "
#> [9] "science fiction"

# Loan members: trim, squeeze blanks, title-case the names
members <- title_case(gsub("\\s+", " ", loans$member))
sort(unique(members))
#> [1] ""              "Ana Ruiz"      "Ben Okafor"    "Chloe Martin"
#> [5] "Dev Patel"     "Eva Lindqvist" "Farid Karimi"  "Grace Liu"
#> [9] "Hugo Silva"
sort(unique(loans$member))
#>  [1] ""              "Ana Ruiz"      "ana ruiz "     "Ben Okafor"
#>  [5] "Chloe Martin"  "Dev Patel"     "Eva Lindqvist" "Farid Karimi"
#>  [9] "Grace Liu"     "Hugo Silva"
loans$member[c(33, 1:3)]
#> [1] ""           "Hugo Silva" "Ben Okafor" "Dev Patel"

Report lines then use sprintf() for a fixed layout, and a length test on nchar() finds the one loan whose ISBN is a digit short.

# Pad loan ids and build labels with sprintf()
labels <- sprintf("%s: %-12s %s", loans$loan_id[1:3],
                  trimws(loans$member[1:3]), format(loans$loan_date[1:3]))
labels
#> [1] "L001: Hugo Silva   2025-01-14" "L002: Ben Okafor   2025-01-16"
#> [3] "L003: Dev Patel    2025-01-21"
# Find the loans whose isbn is not 13 digits
loans$loan_id[nchar(loans$isbn) != 13L]
#> [1] "L005"

stringr: the same jobs in a package

stringr 1.6.0 is a labelled add-on package (it is not part of R) that wraps the stringi library. Its functions all start with str_, take the vector of strings as the first argument, treat NA and zero-length input the same way everywhere, and use ICU regular expressions rather than TRE or PCRE2 (stringr package documentation). ICU supports lookaround, named groups, \p{...} properties and Unicode-aware \d and \w, but its syntax is not identical to PCRE2, and its error messages look different (the script prints one). The package is covered with the rest of the tidyverse text tools on tidyr, purrr and stringr; the table is the mapping from the base functions of this page.

Base R stringr Difference worth knowing

paste0(...), paste()

str_c()

str_c() returns NA if any input is NA; paste0() pastes the text "NA"

nchar(x)

str_length()

same values, including NA for a missing string

substr(x, start, stop)

str_sub()

negative positions count from the end; str_sub(x, -4) is the last four characters

grepl(p, x)

str_detect()

pattern is the second argument, x the first; str_starts() and str_ends() replace startsWith()

sub(p, r, x)

str_replace()

backreferences are written \\1, as in base R

gsub(p, r, x)

str_replace_all()

same

regmatches(x, regexpr(p, x))

str_extract()

returns one element per input, NA for no match, so the length is preserved

regmatches(x, gregexpr(p, x))

str_extract_all()

a list, like gregexpr()

regmatches(x, regexec(p, x))

str_match()

a character matrix with the whole match and one column per group, named for named groups

strsplit(x, p)

str_split()

str_split_fixed() returns a matrix with a fixed number of pieces

trimws(x)

str_trim() and str_squish()

str_squish() also collapses inner runs of blanks

toupper(x), tolower(x)

str_to_upper(), str_to_lower(), str_to_title()

locale-aware, with a locale argument

sprintf(), format(), formatC()

str_pad(), str_glue()

str_pad() pads to a width; str_glue() interpolates variables into a template

fixed = TRUE

fixed(), regex(), coll()

a pattern is a regex unless wrapped in a modifier function

The stringr script checks several equivalences with identical() and then shows the differences: str_c() propagates NA where paste0() writes "NA", str_extract() keeps the length, and the \d of ICU matches the Arabic-Indic digits that the base engines did not. The pattern modifiers are the equivalent of fixed = TRUE: str_detect("axb", fixed(".")) is FALSE.

library(stringr)
source("scenario.R")

# base R on the left, stringr on the right: same results for the clean cases
x <- c("  the quiet streets ", "THE LONG TABLE", NA)
identical(trimws(x), str_trim(x))
#> [1] TRUE
identical(toupper(x), str_to_upper(x))
#> [1] TRUE
identical(nchar(x), str_length(x))
#> [1] TRUE
identical(paste0("a", x), str_c("a", x))
#> [1] FALSE
paste0("a", x)
#> [1] "a  the quiet streets " "aTHE LONG TABLE"       "aNA"
str_c("a", x)
#> [1] "a  the quiet streets " "aTHE LONG TABLE"       NA

# NA: nchar() and str_length() agree; paste0() and str_c() do not
nchar(x)
#> [1] 20 14 NA
str_length(x)
#> [1] 20 14 NA

# Detecting, replacing, extracting
str_detect(books$genre, "fiction")
#>  [1]  TRUE FALSE FALSE  TRUE FALSE  TRUE FALSE FALSE  TRUE FALSE FALSE  TRUE
#> [13]  TRUE  TRUE FALSE FALSE
str_detect(books$genre, regex("fiction", ignore_case = TRUE))
#>  [1]  TRUE FALSE  TRUE  TRUE FALSE  TRUE FALSE FALSE  TRUE  TRUE FALSE  TRUE
#> [13]  TRUE  TRUE  TRUE FALSE
str_replace(books$author[8], "^(.*), (.*)$", "\\2 \\1")
#> [1] "Ursula K. Le Guin"
str_replace_all("a  b   c", "\\s+", " ")
#> [1] "a b c"
str_extract(c("Loan L012", "none"), "[0-9]+")
#> [1] "012" NA
str_extract_all("L012 L034", "[0-9]+")
#> [[1]]
#> [1] "012" "034"
#>
str_sub(books$isbn[1:3], -4)
#> [1] "15-7" "51-8" "0100"
str_sub("The Dispossessed", 5)
#> [1] "Dispossessed"
str_split("a,b,c", ",")
#> [[1]]
#> [1] "a" "b" "c"
#>
str_split_fixed("Le Guin, Ursula K.", ", ", 2)
#>      [,1]      [,2]
#> [1,] "Le Guin" "Ursula K."
str_pad(c("7", "42"), width = 4, side = "left", pad = "0")
#> [1] "0007" "0042"
str_squish("  Iris   Fenwick  ")
#> [1] "Iris Fenwick"
str_to_title("the long table")
#> [1] "The Long Table"
str_starts(books$isbn[1:4], "978-0-0")
#> [1] FALSE FALSE FALSE  TRUE
# Regular expressions are ICU: \d is Unicode, lookaround and named groups work
str_detect(c("42", "x7", "١٢"), "\\d")
#> [1] TRUE TRUE TRUE
str_match("2026-06-30", "(?<y>\\d{4})-(?<m>\\d{2})-(?<d>\\d{2})")
#>                   y      m    d
#> [1,] "2026-06-30" "2026" "06" "30"

# The pattern is a regex unless wrapped
str_detect("a.b", ".")
#> [1] TRUE
str_detect("a.b", fixed("."))
#> [1] TRUE
str_detect("axb", fixed("."))
#> [1] FALSE
tryCatch(str_detect("a", "("), error = \(e) conditionMessage(e))
#> [1] "Incorrectly nested parentheses in regex pattern. (U_REGEX_MISMATCHED_PAREN, context=`(`)"

The package’s own From base R article compares the two sets of functions in more detail. Base R has everything this page needed and no dependency, and it is the right choice for a package or a script that should run anywhere; stringr gives a more consistent interface when you chain many string steps in a pipeline.

Pitfalls

  • sprintf() converts silently. A list argument is converted element by element with as.character(), so a vector element is deparsed ("1:2"), and %d rejects a list altogether (unsupported type). A double with a whole value is accepted by %d, a fractional or out-of-range one (3000000000) is the invalid format error.

  • Numbers are converted with up to 15 significant digits: paste(0.1 + 0.2) is "0.3" while sprintf("%.17g", 0.1 + 0.2) shows 0.30000000000000004. as.character(1e5) is "1e+05" but as.character(100000L) is "100000", and as.character(1e15) is "1e+15". Format numbers explicitly with sprintf() or format() when the text matters, as with identifiers and file names. R FAQ 7.31: Why doesn’t R think these numbers are equal? explains why decimal fractions are not exact in binary.

  • sprintf() argument lengths must recycle to a common length (arguments cannot be recycled to the same length), but paste() recycles silently.

  • Case conversion and sorting depend on the locale. In the example en_US.UTF-8 locale sort(c("b", "A", "a", "B")) is "a" "A" "b" "B", while method = "radix" sorts by code point and gives "A" "B" "a" "b". Compare strings with == for equality; use tolower() on both sides for case-insensitive equality, and do not rely on toupper("ß") == "SS".

  • A string that looks like one character on the screen can be two: "na\u0308ive" with a combining diaeresis has six characters, five of display width. See Encodings, Locales and Internationalization.

  • toupper(NA) is NA (as a character), toupper(1:3) converts numbers to character, and toupper(factor("a")) returns the upper-cased label as a character vector.

  • charmatch() and pmatch() do partial matching and treat an ambiguous match differently (0 versus NA); exact matching is match() or %in%. Do not use partial matching for data cleaning.

msg <- function(expr) {
  tryCatch(expr, error = \(e) conditionMessage(e),
           warning = \(w) paste("warning:", conditionMessage(w)))
}

# sprintf() with a list argument
sprintf("%s", list("a", 1:2))
#> [1] "a"   "1:2"
msg(sprintf("%d", list(1L, 2L)))
#> [1] "unsupported type"
msg(sprintf("%s", list(list(1))))
#> [1] "list(1)"
sprintf("%s", c(1.5, 2, 3))
#> [1] "1.5" "2"   "3"
sprintf("%s", 1 / 3)
#> [1] "0.333333333333333"
sprintf("%.15g", 0.1 + 0.2)
#> [1] "0.3"
sprintf("%.17g", 0.1 + 0.2)
#> [1] "0.30000000000000004"
paste(0.1 + 0.2, 1 / 3)
#> [1] "0.3 0.333333333333333"
as.character(1e5)
#> [1] "1e+05"
as.character(100000L)
#> [1] "100000"
as.character(123456.7)
#> [1] "123456.7"
as.character(1e15)
#> [1] "1e+15"
as.character(0.1)
#> [1] "0.1"
msg(sprintf("%d", 3000000000))
#> [1] "invalid format '%d'; use format %f, %e, %g or %a for numeric objects"

# sprintf() cannot recycle a zero-length fmt or mix lengths
msg(sprintf(character(0), 1))
#> character(0)
msg(sprintf("%s-%s", 1:2, 1:3))
#> [1] "arguments cannot be recycled to the same length"
msg(sprintf("%s", NULL))
#> character(0)
# Case conversion is not always one-to-one
toupper("straße")
#> [1] "STRAßE"
nchar(toupper("straße"))
#> [1] 6
toupper("ß") == "SS"
#> [1] FALSE
sort(c("b", "A", "a", "B"))
#> [1] "a" "A" "b" "B"
sort(c("b", "A", "a", "B"), method = "radix")
#> [1] "A" "B" "a" "b"
order(c("b", "A", "a", "B"))
#> [1] 3 2 1 4
"a" < "B"
#> [1] TRUE
rev(utf8ToInt("é"))
#> [1] 233
substr("naïve", 3, 3)
#> [1] "ï"
nchar("naïve")
#> [1] 5
nchar("na\u0308ive")
#> [1] 6
nchar("na\u0308ive", type = "chars")
#> [1] 6
nchar("na\u0308ive", type = "width")
#> [1] 5
toupper(NA)
#> [1] NA
toupper(character(0))
#> character(0)
toupper(1:3)
#> [1] "1" "2" "3"
toupper(factor("a"))
#> [1] "A"
# Compare and match
"abc" == "ABC"
#> [1] FALSE
tolower("abc") == tolower("ABC")
#> [1] TRUE
"abc" %in% c("ABC", "abc")
#> [1] TRUE
identical("a", "a ")
#> [1] FALSE
trimws("a ") == "a"
#> [1] TRUE
match("b", letters)
#> [1] 2
charmatch("me", c("mean", "median"))
#> [1] 0
pmatch("me", c("mean", "median"))
#> [1] NA
startsWith("mean", "me")
#> [1] TRUE

Where older tutorials differ

Many pages on the web predate these changes, or describe habits that R has long moved away from. When one of them disagrees with this page, the table says why.

Older tutorial Current practice Why Version

nchar(NA) is 2

nchar(NA) is NA_integer_; use keepNA = FALSE or type = "width" for 2

the default is keepNA = NA since R 3.3.0

R 3.3.0

substr(x, start, 1000000L) or substr(x, start, nchar(x)) for "the rest"

substr(x, start, NULL) and substring(x, start)

the end argument may be NULL

R 4.6.0

grep(p, x, value = TRUE) for the matching elements

grepv(p, x)

grepv() is grep() with value = TRUE

R 4.5.0

doubled backslashes for every escape in patterns and paths, as in gsub("\\.", "", x)

raw strings, as in gsub(r"(\.)", "", x)

no escaping of backslashes for R’s parser

R 4.0.0

sapply(strsplit(x, ","), "[", 1) for the first piece

vapply(strsplit(x, ","), "[", character(1), 1L)

sapply() can return a list when a piece is missing; the type template catches it

always

paste("a", NULL) expected to give character(0) or "a" (it gives "a ")

paste("a", NULL, recycle0 = TRUE)

zero-length arguments are recycled as "" unless recycle0 = TRUE

R 4.0.1

regmatches(x, regexpr(...)) with strings that may not match

check the length, use gregexpr() or grepl() first

non-matches are dropped, so the result can be shorter than x

always

gsub("^ +| +$", "", x) to trim blanks

trimws(x)

trimws() is in base R and handles tabs and newlines

R 3.2.0

substr(x, 1, nchar(prefix)) == prefix

startsWith(x, prefix)

clearer and faster

R 3.3.0

rep(s, n) plus paste(collapse = "")

strrep(s, n)

one function

R 3.3.0

gsub(pattern, replacement, x) with perl = TRUE assumed to use PCRE1

nothing to change for most patterns; escape a hyphen in a character class

PCRE2 is stricter than PCRE1

R 4.0.0

stringsAsFactors = TRUE converting every text column

text stays character (see Factors)

read.csv() and data.frame() default to FALSE

R 4.0.0

For the full list of changes by release see What’s New and Migrating Older Code.

Where to go next