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.
Adding regex and your first match
Section titled “Adding regex and your first match”In your project folder, run:
cargo add regexThat 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:
truefalse\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.
Why Regex::new returns a Result
Section titled “Why Regex::new returns a Result”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 groupWhen 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.
Finding matches: find and find_iter
Section titled “Finding matches: find and find_iter”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\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).
Capture groups and named groups
Section titled “Capture groups and named groups”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-17year: 2024month: 05day: 1717/05/202402/06/2024Three 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 anOption, 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.
Replacing text
Section titled “Replacing text”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 spacesFrom 17/05/2024 to 2024-06-02From 17/05/2024 to 02/06/2024From 2024 to 2024From 05_2024 to 06_2024Look 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.
Splitting text
Section titled “Splitting text”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"][,;] means “a comma or a semicolon”, and the \s* on each side swallows any spaces around it. splitn(text, 2) stops after two pieces.
Compile once, not in a loop
Section titled “Compile once, not in a loop”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 -> trueBob.Smith@Mail.CO.UK -> truenot-an-email -> falsex@y -> falseNew pieces in this pattern:
(?i)turns on case-insensitive matching, so[a-z]also matchesBandM.^and$mean “start of text” and “end of text”. Without them,is_matchwould 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.
Example: pulling data out of log lines
Section titled “Example: pulling data out of log lines”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 in2024-05-17 09:15:44 ERROR payment failed for bob@shop.orgthis line is garbage2024-05-18 11:02:10 WARN disk 91% full2024-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 foundA few notes:
- The
"\at the start oflogis 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 andcontinueto the next one.(?<level>[A-Z]+) +matches the level and then one or more spaces, becauseINFOandWARNare padded with an extra space to line up withERROR.- In a real program you’d read the log from a file, line by line. See File I/O.
What regex doesn’t do (on purpose)
Section titled “What regex doesn’t do (on purpose)”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 supportedThis 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.
Cheat sheet
Section titled “Cheat sheet”| 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: \., \(, \$.
Common mistakes
Section titled “Common mistakes”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::newreturns aResult, because a pattern can be invalid.is_matchsays yes or no;find/find_itergive the matched text;captures/captures_itergive groups by number or by(?<name>...).replaceandreplace_allcan insert groups with$name; use${name}when letters or_follow.- Compile a pattern once and reuse it, for example in a
staticLazyLock. - No look-around or backreferences, by design: in exchange, matching is always linear time.
Quick check
Section titled “Quick check”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
Read more
Section titled “Read more”regexcrate documentation — docs.rs: the official guide, with many examples and the full syntax reference.RegexAPI — docs.rs: every method, includingcaptures_iter,replacenandsplit.LazyLock— standard library docs: how lazily-initialized statics work.- Regular Expressions — Rust Cookbook: short recipes for extracting hashtags, phone numbers, emails and more.