Manual examples and pattern matching

Type a handful of messages by hand, then build a pattern you can test

source data loading
regular expressions
testing
Type small examples by hand and use them to find dates, email addresses, and order numbers in R.

Near the end of a support shift, a worker needs to connect incoming messages with the right orders. One message may contain two order numbers, another may contain an email address, and most contain neither. Reading a small inbox is easy. Repeating the job across hundreds of messages makes missed and mistaken matches more likely.

The worker starts by typing five messages by hand rather than exporting the full inbox. Typing them is the point: every expected answer fits on the screen, including the examples that should fail, and nothing arrives from a system that might have reformatted it. This small rehearsal set will help us build a text pattern, test its limits, and hide an address before the messages are shared.

TipWhat you will learn

By the end of this lesson, you will be able to:

  • search for an exact word or phrase;
  • describe a family of values such as order numbers or dates;
  • extract and replace matching text; and
  • turn hand-written examples into checks that catch mistakes.

Make a small rehearsal set

The five messages below are varied enough to pose real questions and short enough to check by eye. In R, c() combines values into a collection called a vector. The arrow <- saves that vector under the name messages.

library(stringr)
library(tibble)
library(dplyr)
library(purrr)

messages <- c(
  "Alex likes to play guitar.",
  "Email support@example.com before 17:30.",
  "Order IDs: R-1042 and R-1043.",
  "The café opens on 2026-08-28.",
  "No identifiers here."
)

messages
[1] "Alex likes to play guitar."             
[2] "Email support@example.com before 17:30."
[3] "Order IDs: R-1042 and R-1043."          
[4] "The café opens on 2026-08-28."          
[5] "No identifiers here."                   

R prints all five messages. The number in square brackets at the left marks the position where that line of output begins.

The messages are English, tagged en in the BCP 47 language tags used by web and publishing tools. That matters for two rules written later. The order ID rule assumes ASCII digits, and the ^ERROR: rule assumes a Latin-script label in capital letters. Neither assumption survives a move to Arabic-Indic digits or a script without case. The email and date rules are about shape and travel further, though the date rule still assumes a Gregorian calendar written largest unit first.

Search for exact text

The simplest search is the one that solves the problem with the fewest moving parts. Here we ask for the exact text "café" before introducing any flexible pattern.

cafe_message <- str_subset(messages, fixed("café"))

cafe_message
[1] "The café opens on 2026-08-28."

str_subset() from stringr keeps the messages that match a pattern. messages says where to look, and fixed("café") says to treat "café" as ordinary text rather than as a regular expression.

fixed() is a good choice when the exact word, phrase, label, or punctuation is already known. It prevents punctuation from taking on a special pattern-matching meaning.

Ask a yes-or-no question

The support worker will eventually need one answer for every message. str_detect() asks whether each message contains a match, returning TRUE for yes and FALSE for no. We will display the answer as a tibble, a table that prints its size and each column’s type.

has_order_prefix <- str_detect(messages, fixed("R-"))

knitr::kable(
  tibble(
    message = messages,
    contains_R_dash = has_order_prefix
  ),
  col.names = c("Message", "Contains R-"),
  caption = "Which messages contain R-?",
  row.names = FALSE
)
Which messages contain R-?
Message Contains R-
Alex likes to play guitar. FALSE
Email support@example.com before 17:30. FALSE
Order IDs: R-1042 and R-1043. TRUE
The café opens on 2026-08-28. FALSE
No identifiers here. FALSE

Only the third message contains "R-". We have found the likely message, but the rule is still too loose. It would also accept "R-" with no digits after it.

Describe a family of matches

The two visible order numbers share a shape that we can state one piece at a time:

  • the letter R;
  • a hyphen; and
  • exactly four digits.

A regular expression, often shortened to regex, is a compact description of a text pattern. The next expression asks for the whole order ID rather than its first two characters.

order_id_pattern <- r"(\bR-[0-9]{4}\b)"

order_ids_by_message <- str_extract_all(
  messages,
  order_id_pattern
)

order_ids_by_message
[[1]]
character(0)

[[2]]
character(0)

[[3]]
[1] "R-1042" "R-1043"

[[4]]
character(0)

[[5]]
character(0)

The output stays grouped by message. An empty entry means that no order number was found there. The third entry contains exactly the two IDs the worker could see in the rehearsal set.

This pattern can be read from left to right:

Parts of the order ID pattern
Part Meaning
\b Start or end at a word boundary.
R- Match these two characters exactly.
[0-9] Match one ASCII digit from 0 through 9.
{4} Require the previous item four times.
\b Stop at another word boundary.

R’s raw string notation, written here as r"(...)", keeps the backslashes readable. Without it, the same pattern would be written as "\\bR-[0-9]{4}\\b".

The order system defines its IDs with ASCII digits. In the ICU engine used by stringr, \d also matches decimal digits from other writing systems. Using [0-9] makes the rule explicit.

Find several kinds of information

Order IDs are only one kind of information mixed into the messages. The same rehearsal set also contains a date and an email address, so each gets a separate pattern.

date_pattern <- r"(\b[0-9]{4}-[0-9]{2}-[0-9]{2}\b)"
email_pattern <- r"([[:alnum:]._%+-]+@[[:alnum:].-]+\.[[:alpha:]]{2,})"

message_table <- tibble(
  message_number = seq_along(messages),
  message_text = messages
)

extracted_summary <- message_table |>
  mutate(
    order_ids = str_extract_all(
      message_text,
      order_id_pattern
    ),
    first_date = str_extract(
      message_text,
      date_pattern
    ),
    first_email = str_extract(
      message_text,
      email_pattern
    )
  )

extracted_for_display <- extracted_summary |>
  mutate(
    order_ids = map_chr(
      order_ids,
      \(ids) str_c(ids, collapse = ", ")
    ),
    order_ids = if_else(order_ids == "", "No match", order_ids),
    first_date = coalesce(first_date, "No match"),
    first_email = coalesce(first_email, "No match")
  )

knitr::kable(
  extracted_for_display,
  col.names = c(
    "Message",
    "Text",
    "Order IDs",
    "First date",
    "First email"
  ),
  caption = "Matches kept with the message that supplied them",
  row.names = FALSE
)
Matches kept with the message that supplied them
Message Text Order IDs First date First email
1 Alex likes to play guitar. No match No match No match
2 Email support@example.com before 17:30. No match No match support@example.com
3 Order IDs: R-1042 and R-1043. R-1042, R-1043 No match No match
4 The café opens on 2026-08-28. No match 2026-08-28 No match
5 No identifiers here. No match No match No match

The table keeps every match beside the message that supplied it. The underlying order_ids column is a list-column because each message can produce zero, one, or several IDs. The display copy turns each small character vector into readable text and prints No match where the extractor found nothing.

str_extract() returns only the first match, which is why the other two columns are named first_date and first_email.

message_number is a display position, not a durable identifier. Reordering the vector would change it. Records from a real system should keep the source’s message ID from the moment they are read.

The email pattern is intentionally modest. It recognizes the address in this example, but it does not validate every email address allowed by internet standards. Finding text that resembles an email and proving that an address is valid are different tasks.

Use the start and end of a string

A loose match can occur in the wrong place. Some labels matter only at the start of a text value, and some values must occupy the whole string. In these single-line examples, ^ means “the start of the string” and $ means “the end of the string.”

log_lines <- c(
  "ERROR: file not found",
  "INFO: job started",
  "ERRORISH: not an error record"
)

error_lines <- str_subset(log_lines, "^ERROR:")

error_lines
[1] "ERROR: file not found"

The pattern requires the complete label ERROR: at the start. It leaves out ERRORISH, whose opening text and punctuation do not meet that rule.

Using both anchors checks a complete value from beginning to end. The next pattern also uses parentheses to capture the year, month, and day separately.

complete_date_pattern <- r"(^([0-9]{4})-([0-9]{2})-([0-9]{2})$)"
date_inputs <- c(
  "2026-08-28",
  "2026-8-28",
  "due 2026-08-28"
)

date_parts <- str_match(
  date_inputs,
  complete_date_pattern
)

knitr::kable(
  tibble(
    input = date_inputs,
    complete_match = date_parts[, 1],
    year = date_parts[, 2],
    month = date_parts[, 3],
    day = date_parts[, 4]
  ),
  col.names = c("Input", "Complete match", "Year", "Month", "Day"),
  caption = "Checking complete dates",
  row.names = FALSE
)
Checking complete dates
Input Complete match Year Month Day
2026-08-28 2026-08-28 2026 08 28
2026-8-28 NA NA NA NA
due 2026-08-28 NA NA NA NA

NA means “missing” in R. The last two inputs do not have the required four-digit year, two-digit month, and two-digit day as a complete value.

This pattern checks the arrangement of digits. It does not check the calendar. For example, 2026-99-99 has the expected shape but is not a real date.

Replace matching text

The worker may need to share an example message while keeping the sender’s address private. A pattern can replace matching text before the example is shared. The following code substitutes [email] for the address.

redaction_report <- message_table |>
  mutate(
    removed_addresses = str_extract_all(
      message_text,
      email_pattern
    ),
    shared_version = str_replace_all(
      message_text,
      email_pattern,
      "[email]"
    ),
    changed = shared_version != message_text
  )

removed_addresses <- redaction_report$removed_addresses |>
  list_c()
changed_messages <- sum(redaction_report$changed)

knitr::kable(
  redaction_report |>
    select(message_number, changed, shared_version),
  col.names = c("Message", "Changed", "Version that leaves the building"),
  caption = "Which messages the redaction step altered",
  row.names = FALSE
)
Which messages the redaction step altered
Message Changed Version that leaves the building
1 FALSE Alex likes to play guitar.
2 TRUE Email [email] before 17:30.
3 FALSE Order IDs: R-1042 and R-1043.
4 FALSE The café opens on 2026-08-28.
5 FALSE No identifiers here.
removed_addresses
[1] "support@example.com"

One message changed and one address was removed. Both numbers are printed, and so is the removed text itself. A redaction step that reports only its output cannot be audited: a pattern that matched half a message without warning would look exactly as successful. Read the removed material, count it, and compare that count with the number of matches the pattern found.

The address is gone, while the rest of each sentence stays unchanged. One successful example is not enough to establish that a redaction method protects private information. Test varied cases carefully, inspect the result, and follow the privacy rules that apply to the real records.

Turn examples into safety checks

Before searching the full inbox, the worker adds close failures beside the clear matches. The order ID must contain one R, one hyphen, and exactly four digits. mutate() from dplyr adds the actual test result as a new column.

id_cases <- tibble(
  input = c(
    "R-0001",
    "prefix R-1234 suffix",
    "R-12",
    "RR-1234",
    "R-12345"
  ),
  expected = c(TRUE, TRUE, FALSE, FALSE, FALSE)
)

id_cases <- id_cases |>
  mutate(actual = str_detect(input, order_id_pattern))

knitr::kable(
  id_cases,
  col.names = c("Input", "Expected", "Actual"),
  caption = "Expected and actual order ID results",
  row.names = FALSE
)
Expected and actual order ID results
Input Expected Actual
R-0001 TRUE TRUE
prefix R-1234 suffix TRUE TRUE
R-12 FALSE FALSE
RR-1234 FALSE FALSE
R-12345 FALSE FALSE

The expected and actual columns agree. A near miss such as R-12345 remains visible during testing rather than becoming a mistaken match later.

Reuse a tested pattern

The checked extraction will be used more than once, so we place it in a function. A function is a named set of steps that accepts an input and returns a result.

extract_order_ids_by_message <- function(text, pattern) {
  str_extract_all(text, pattern)
}

collect_unique_order_ids <- function(text, pattern) {
  extract_order_ids_by_message(text, pattern) |>
    list_c() |>
    unique()
}

ids_by_message <- extract_order_ids_by_message(
  messages,
  order_id_pattern
)
unique_order_ids <- collect_unique_order_ids(
  messages,
  order_id_pattern
)

unique_order_ids
[1] "R-1042" "R-1043"

The first function preserves one result per message. The second function deliberately combines those results into a corpus-wide list and removes duplicates. Its name makes the loss of message boundaries explicit.

The distinction matters whenever a later question asks where a match came from. Keep the row-level result, then derive a flattened inventory from it when the inventory is the actual goal.

State which text the pattern accepts

Support messages may use accented letters or writing systems beyond English. In ICU, \p{L} means a code point classified as a letter and \p{M} means a combining mark. The second category matters because a visible accented letter can be stored as a base letter followed by a separate mark.

words <- c(
  "cafe",
  "café",
  "naïve",
  "李雷",
  "e\u0301",
  "hello-world"
)
letters_and_marks_pattern <- r"(^(?:\p{L}\p{M}*)+$)"

letters_and_marks <- str_detect(
  words,
  letters_and_marks_pattern
)

knitr::kable(
  tibble(
    text = words,
    accepted_by_pattern = letters_and_marks
  ),
  col.names = c("Text", "Accepted by this pattern"),
  caption = "Checking the stated letter-and-mark rule",
  row.names = FALSE
)
Checking the stated letter-and-mark rule
Text Accepted by this pattern
cafe TRUE
café TRUE
naïve TRUE
李雷 TRUE
TRUE
hello-world FALSE

The first five examples follow the stated rule. The fifth displays as é but uses e plus a combining accent. hello-world does not match because a hyphen is punctuation. This check answers a specific text question; it is not a rule for deciding whether a person’s name or a word is valid.

NoteTechnical detail: R has more than one pattern engine

The software that interprets a regular expression is called a pattern engine. Different engines do not agree on every feature.

Base R uses POSIX extended regular expressions by default and PCRE2 when perl = TRUE. The stringr package uses the ICU engine through stringi. These engines share common syntax, but some Unicode rules and advanced features differ.

The examples in this lesson use stringr for regular expressions. Test a pattern again if you move it to a different engine. Avoid useBytes = TRUE for normal text because byte-level matching can split one UTF-8 character into several pieces.

Carry the tested pattern into real work

The support worker now has a repeatable process rather than a pattern copied straight into the full inbox:

  1. Write a few short examples that should match.
  2. Add similar examples that must not match.
  3. Use fixed() when you only need exact text.
  4. Build a regular expression one piece at a time.
  5. Inspect the text you extract before replacing anything.
  6. Record the expected answers in the lesson’s hidden checks.
  7. Add a new example whenever the pattern surprises you.

What to remember

  • Exact searches are simpler than regular expressions and should be used when they solve the problem.
  • A regular expression describes a family of possible matches.
  • Small hand-written examples make a pattern easier to understand.
  • Expected failures are as useful as expected matches.
  • Name the language and script a pattern assumes; digits and letter case do not behave the same everywhere.
  • Count and read what a replacement removed, not only what it left behind.
  • A published example should stop the build when its result changes.

The five typed messages end with both order IDs found, one address removed, and that removal counted. Keep the same two small piles beside any new pattern: examples it must accept and examples it must refuse.

Sources