Skip to content
Select theme

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"

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..2
aNbNcNd
(?i)hello matches HeLLo: true

find 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.

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 -> localhost
port -> 8080
debug -> true

captures_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.

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 euros
it costs $12 euros
aN b2 c3

replace 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.

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.610458ms
1000 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.

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 group

The 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.