Skip to content

Rust Regular Expressions with regex

A regular expression (regex for short) is a tiny pattern language for text. Instead of saying “find the word cat”, you can say “find four digits, a dash, two digits, a dash, two digits”, and match every date like 2024-05-17 at once.

Rust’s standard library has handy string methods like contains, split and find (see Strings). When a fixed piece of text isn’t enough, and you need a shape of text, use the regex crate. It’s maintained by the Rust project and it’s very fast.

In your project folder, run:

Terminal window
cargo add regex

That adds regex = "1" under [dependencies] in your Cargo.toml. (New to crates? See Cargo and Crates.) The “Try it” buttons open the Rust Playground, which already has regex.

use regex::Regex;
fn main() {
let re = Regex::new(r"\d{4}-\d{2}-\d{2}").unwrap();
println!("{}", re.is_match("Released on 2024-05-17."));
println!("{}", re.is_match("Released last spring."));
}

Output:

true
false
▶ Try it Yourself

\d means “any digit” and {4} means “exactly four of the previous thing”. is_match answers one question: is there a match anywhere in the text?

Notice the r before the pattern: r"\d{4}". That’s a raw string. In a normal Rust string, \ starts an escape like \n (newline). Regex patterns are full of backslashes, so raw strings save you from writing "\\d{4}". Always use raw strings for patterns.

A pattern is a little program, and it can have mistakes in it, like a bracket you forgot to close. Regex::new checks the pattern and returns a Result: Ok(Regex) if it’s valid, Err with a helpful message if not:

use regex::Regex;
fn main() {
match Regex::new(r"(\d+") {
Ok(_) => println!("valid pattern"),
Err(e) => println!("bad pattern:\n{e}"),
}
}

Output:

bad pattern:
regex parse error:
(\d+
^
error: unclosed group
▶ Try it Yourself

When the pattern is written in your code and you know it’s correct, .unwrap() is fine: if it’s wrong, you’ll find out the first time the program runs. When the pattern comes from a user (say, a search box), handle the Err instead of crashing.

find returns the first match as an Option. find_iter gives you an iterator over all matches:

use regex::Regex;
fn main() {
let re = Regex::new(r"\d+").unwrap();
let text = "3 apples, 12 pears and 7 plums";
if let Some(m) = re.find(text) {
println!("first number: {} (at bytes {}..{})", m.as_str(), m.start(), m.end());
}
let all: Vec<&str> = re.find_iter(text).map(|m| m.as_str()).collect();
println!("all numbers: {:?}", all);
let total: u32 = re.find_iter(text).map(|m| m.as_str().parse::<u32>().unwrap()).sum();
println!("total fruit: {total}");
}

Output:

first number: 3 (at bytes 0..1)
all numbers: ["3", "12", "7"]
total fruit: 22
▶ Try it Yourself

\d+ means “one or more digits”, so 12 is found as one match, not two. Each match knows its text (as_str()) and its position (start() and end(), in bytes).

Often you don’t just want the match, you want pieces of it. Wrap a part of the pattern in parentheses to capture it. Give it a name with (?<name>...) so your code is easier to read:

use regex::Regex;
fn main() {
let re = Regex::new(r"(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})").unwrap();
let text = "Backup ran on 2024-05-17 and 2024-06-02.";
if let Some(caps) = re.captures(text) {
println!("whole match: {}", &caps[0]);
println!("year: {}", &caps["year"]);
println!("month: {}", &caps[2]);
println!("day: {}", caps.name("day").unwrap().as_str());
}
for caps in re.captures_iter(text) {
println!("{}/{}/{}", &caps["day"], &caps["month"], &caps["year"]);
}
}

Output:

whole match: 2024-05-17
year: 2024
month: 05
day: 17
17/05/2024
02/06/2024
▶ Try it Yourself

Three ways to read a group:

  • caps[0] is always the whole match. Groups are numbered from 1, left to right: caps[2] is the month.
  • caps["year"] reads a group by name. Indexing with [...] panics if that group didn’t take part in the match.
  • caps.name("day") returns an Option, which is the safe choice when a group is optional.

captures finds the first match. captures_iter walks through every match, just like find_iter.

replace changes the first match, and replace_all changes every match. In the replacement text, $name (or $1) inserts what a group captured:

use regex::Regex;
fn main() {
let spaces = Regex::new(r"\s+").unwrap();
println!("{}", spaces.replace_all("too many spaces", " "));
let date = Regex::new(r"(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})").unwrap();
let text = "From 2024-05-17 to 2024-06-02";
println!("{}", date.replace(text, "$d/$m/$y"));
println!("{}", date.replace_all(text, "$d/$m/$y"));
println!("{}", date.replace_all(text, "$m_$y"));
println!("{}", date.replace_all(text, "${m}_${y}"));
}

Output:

too many spaces
From 17/05/2024 to 2024-06-02
From 17/05/2024 to 02/06/2024
From 2024 to 2024
From 05_2024 to 06_2024
▶ Try it Yourself

Look at the fourth line. It printed From 2024 to 2024, not 05_2024. Why? A group name can contain _, so $m_ was read as a group called m_. There is no such group, so it became empty text. Put the name in braces, ${m}_, to say exactly where it ends. When in doubt, use braces.

\s+ means “one or more whitespace characters”, so replacing it with one space is a classic way to tidy up messy input.

split cuts a string wherever the pattern matches. It’s like str::split, but the separator can be a pattern:

use regex::Regex;
fn main() {
let sep = Regex::new(r"\s*[,;]\s*").unwrap();
let parts: Vec<&str> = sep.split("apple, banana;cherry , date").collect();
println!("{:?}", parts);
let two: Vec<&str> = sep.splitn("a, b, c, d", 2).collect();
println!("{:?}", two);
}

Output:

["apple", "banana", "cherry", "date"]
["a", "b, c, d"]
▶ Try it Yourself

[,;] means “a comma or a semicolon”, and the \s* on each side swallows any spaces around it. splitn(text, 2) stops after two pieces.

Building a Regex is the slow part: the crate turns your pattern into an efficient matcher. Using it is fast. So never call Regex::new inside a loop or a function that runs often. Build it once and reuse it.

For a pattern used all over your program, put it in a static with std::sync::LazyLock. It’s built the first time you use it, and then shared:

use regex::Regex;
use std::sync::LazyLock;
static EMAIL: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$").unwrap()
});
fn is_email(s: &str) -> bool {
EMAIL.is_match(s)
}
fn main() {
for input in ["ada@example.com", "Bob.Smith@Mail.CO.UK", "not-an-email", "x@y"] {
println!("{input:22} -> {}", is_email(input));
}
}

Output:

ada@example.com -> true
Bob.Smith@Mail.CO.UK -> true
not-an-email -> false
x@y -> false
▶ Try it Yourself

New pieces in this pattern:

  • (?i) turns on case-insensitive matching, so [a-z] also matches B and M.
  • ^ and $ mean “start of text” and “end of text”. Without them, is_match would say yes to "hello ada@example.com!!", because the email appears somewhere in it.
  • \. is a real dot. A plain . means “any character”.

This is a “looks like an email” check, good for catching typos. Truly validating every legal email address with a regex is famously hard, so the only real test is sending an email to it.

Let’s combine everything. We read a log, skip lines that don’t fit the format, and for every ERROR line print the date, the time, and any email addresses in the message:

use regex::Regex;
use std::sync::LazyLock;
static LOG_LINE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(?<date>\d{4}-\d{2}-\d{2}) (?<time>\d{2}:\d{2}:\d{2}) (?<level>[A-Z]+) +(?<msg>.*)$").unwrap()
});
static EMAIL: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}").unwrap()
});
fn main() {
let log = "\
2024-05-17 09:12:03 INFO user ada@example.com logged in
2024-05-17 09:15:44 ERROR payment failed for bob@shop.org
this line is garbage
2024-05-18 11:02:10 WARN disk 91% full
2024-05-18 11:05:00 ERROR could not email carol@example.net or dan@example.net";
let mut errors = 0;
for line in log.lines() {
let Some(caps) = LOG_LINE.captures(line) else {
println!("skipped: {line:?}");
continue;
};
if &caps["level"] == "ERROR" {
errors += 1;
let emails: Vec<&str> = EMAIL.find_iter(&caps["msg"]).map(|m| m.as_str()).collect();
println!("{} {} -> {:?}", &caps["date"], &caps["time"], emails);
}
}
println!("{errors} errors found");
}

Output:

2024-05-17 09:15:44 -> ["bob@shop.org"]
skipped: "this line is garbage"
2024-05-18 11:05:00 -> ["carol@example.net", "dan@example.net"]
2 errors found
▶ Try it Yourself

A few notes:

  • The "\ at the start of log is a Rust trick: a backslash at the end of a line inside a string skips the newline, so the first log line starts right away.
  • let Some(caps) = ... else { ... } is let-else: if the line doesn’t match, we print it and continue to the next one.
  • (?<level>[A-Z]+) + matches the level and then one or more spaces, because INFO and WARN are padded with an extra space to line up with ERROR.
  • In a real program you’d read the log from a file, line by line. See File I/O.

If you’ve used regexes in JavaScript, Python or Perl, you might reach for look-around ((?=...), (?<=...)) or backreferences (\1, “the same text as group 1”). The regex crate doesn’t support them, and tells you so:

regex parse error:
foo(?=bar)
^^^
error: look-around, including look-ahead and look-behind, is not supported

This is a deliberate trade. Those features can make some patterns take exponential time on certain inputs, so a short pattern and a long enough string can freeze a program (this is called catastrophic backtracking, and it has taken down real websites). Without them, the regex crate can guarantee that matching time grows only in proportion to the length of the text (times the size of the pattern). Twice the text means roughly twice the work, never an explosion. That makes it safe even for patterns or text that come from users.

Usually you can get the same result another way: use a capture group and read only the part you want, or do a second simple check in plain Rust code. If you really need these features, the fancy-regex crate adds them, without the time guarantee.

Pattern Matches
. any character except a newline
\d \w \s a digit, a “word” character (letter, digit, _), whitespace
\D \W \S the opposite: not a digit, not a word character, not whitespace
[abc] [a-z] [^0-9] one of a, b, c; any lowercase letter; anything but a digit
* + ? zero or more, one or more, zero or one of the previous item
{3} {2,5} {2,} exactly 3, from 2 to 5, at least 2
^ $ start and end of the text
\b a word boundary (between a word character and a non-word character)
(...) (?<name>...) a numbered group, a named group
(?i) case-insensitive from here on

One more: a vertical bar means “or”. For example, cat|dog matches either word, and gr(a|e)y matches gray and grey. To match a special character literally, put a backslash in front: \., \(, \$.

Forgetting the raw string. In a normal string, \d is not a valid escape, so Rust stops before the regex crate ever sees your pattern:

use regex::Regex;
fn main() {
let re = Regex::new("\d+").unwrap();
println!("{}", re.is_match("id=22"));
}
error: unknown character escape: `d`
--> src/main.rs:4:27
|
4 | let re = Regex::new("\d+").unwrap();
| ^ unknown character escape
|
help: if you meant to write a literal backslash (perhaps escaping in a regular expression), consider a raw string literal
|
4 | let re = Regex::new(r"\d+").unwrap();
| +

The compiler even suggests the fix: add r in front.

Building the regex inside a loop. This compiles and works, but it’s slow, because the pattern is compiled again for every line:

for line in lines {
let re = Regex::new(r"\d+").unwrap(); // compiled again and again
if re.is_match(line) { /* ... */ }
}

Move Regex::new above the loop, or into a LazyLock static as shown earlier.

Forgetting ^ and $ when validating. is_match looks for the pattern anywhere. To check that the whole input has a shape, anchor the pattern at both ends.

  • Add the crate with cargo add regex, and write patterns as raw strings: r"\d+".
  • Regex::new returns a Result, because a pattern can be invalid.
  • is_match says yes or no; find/find_iter give the matched text; captures/captures_iter give groups by number or by (?<name>...).
  • replace and replace_all can insert groups with $name; use ${name} when letters or _ follow.
  • Compile a pattern once and reuse it, for example in a static LazyLock.
  • No look-around or backreferences, by design: in exchange, matching is always linear time.

Quick check

1. Why do regex patterns in Rust usually start with r, as in r"\d+"?

2. With the pattern (?<y>\d{4})-(?<m>\d{2}), what does caps["m"] give for the text 2024-05?

3. Why doesn't the regex crate support look-around and backreferences?

4. Where is the best place to call Regex::new for a pattern you use on every line of a big file?

Score: 0 / 4