How to match and replace text with regex
Short answer: The standard library has no regex engine; the regex crate is the one the ecosystem settled on. Regex::new compiles the pattern, and compiling is the expensive part — build the Regex once and reuse it. is_match answers yes or no, find locates the first match, captures reads the groups, replace_all does the substitution.
From a fresh project — cargo new regex-demo — add the crate below to Cargo.toml (or run cargo add regex), then replace src/main.rs with each block and run it with cargo run. The project name only shows up in cargo’s own output. Every block was run against regex 1.13.1; regex = "1" pulls the current 1.x, and these APIs are stable across it.
[dependencies]regex = "1"Match, find, replace
Section titled “Match, find, replace”Regex::new returns a Result, because a pattern can be invalid; for a literal you typed yourself, unwrap() is fine. The last section shows what a bad one looks like.
use regex::Regex;
fn main() { let text = "a1b22c333d"; let re = Regex::new(r"\d+").unwrap(); println!("{}", re.is_match(text)); if let Some(m) = re.find(text) { println!("{:?} at {:?}", m.as_str(), m.range()); } println!("{}", re.replace_all(text, "N"));
let ci = Regex::new(r"(?i)hello").unwrap(); println!("(?i)hello matches HeLLo: {}", ci.is_match("HeLLo world"));}true"1" at 1..2aNbNcNd(?i)hello matches HeLLo: truefind hands back the first match with its byte range — 1..2, not a character index, because the pattern engine counts bytes. replace_all swaps every match. When the pattern matches nothing the result borrows the input: the return type is a Cow<str>, the same one String::from_utf8_lossy returns. (?i) at the front of the pattern makes it case-insensitive.
Reading capture groups
Section titled “Reading capture groups”Parentheses capture; (?P<name>…) gives the group a name.
use regex::Regex;
fn main() { let text = "host=localhost port=8080 debug=true"; let re = Regex::new(r"(?P<key>\w+)=(?P<value>\w+)").unwrap(); for caps in re.captures_iter(text) { println!("{} -> {}", &caps["key"], &caps["value"]); }}host -> localhostport -> 8080debug -> truecaptures_iter walks every match, and &caps["key"] reads a group by name. One trap: that index panics when the group didn’t participate — an optional (?P<b>b)? that matched nothing panics with no group named 'b'. caps.name("b") returns an Option<Match> instead.
Substituting with group references
Section titled “Substituting with group references”In the replacement text, $1 inserts capture group 1 and $name inserts a named one. A literal dollar sign is $$:
use regex::Regex;
fn main() { let re = Regex::new(r"(\d+)").unwrap(); println!("{}", re.replace_all("it costs 12 euros", "$1 dollars")); println!("{}", re.replace_all("it costs 12 euros", "$$$1")); println!("{}", re.replace("a1 b2 c3", "N"));}it costs 12 dollars eurosit costs $12 eurosaN b2 c3replace takes the first match only; replace_all takes all of them. The second line is $$ doing its job — one literal $ in front of the captured digits. The third shows the difference: replace stopped after a1.
Compile the pattern once
Section titled “Compile the pattern once”Regex::new builds the automaton that does the matching, and that work dwarfs the matching itself. The loop below compiles the same pattern a thousand times, then matches a thousand times against one compiled Regex:
use regex::Regex;use std::sync::LazyLock;use std::time::Instant;
static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\d+").unwrap());
fn main() { let text = "a1b22c333d".repeat(50); let start = Instant::now(); for _ in 0..1000 { let re = Regex::new(r"\d+").unwrap(); std::hint::black_box(&re); } println!("1000 compiles: {:?}", start.elapsed());
let start = Instant::now(); let mut count = 0; for _ in 0..1000 { if RE.is_match(&text) { count += 1; } } println!("1000 reuses: {:?} ({count} matches)", start.elapsed());}1000 compiles: 354.610458ms1000 reuses: 713.416µs (1000 matches)Those numbers come from a debug build (cargo run, how every block here was run) on the machine this page was written on. A release build shrinks both; a different machine moves both. The ratio is the point: compiling costs hundreds of times more than matching, so Regex::new inside a per-line or per-request loop is the mistake this page exists to prevent.
Hoisting the let re = Regex::new(…) above the loop fixes the local case. When the same pattern is needed from several places, LazyLock from std::sync compiles it on first use and hands out the same Regex after that.
A bad pattern fails at runtime
Section titled “A bad pattern fails at runtime”The Result from Regex::new is not ceremony. An unbalanced paren in the pattern is not a compile error — the compiler cannot see inside a string:
use regex::Regex;
fn main() { match Regex::new(r"(unclosed") { Ok(_) => println!("compiled"), Err(err) => println!("{err}"), }}regex parse error: (unclosed ^error: unclosed groupThe message points at the offending character; most typos are fixed at a glance. unwrap() on a literal in your own source is fine; on a pattern from a config file or a user it is a panic waiting for the right input, so match on the Err instead.
One job this page does not cover: swapping {name}-style slots for values in a template. That has its own article; a chain of replace calls covers the trivial version.
Related
Section titled “Related”- How to split a string — cutting text at a separator, no pattern needed
- How to format strings with println! and format! — when the text is yours to build
- Working with text — the rest of the chapter