Regex Performance in Rust and Avoiding Catastrophic Cases
Rust's regex crate guarantees linear-time matching, so there is no catastrophic backtracking. The real costs are compiling in a loop, unanchored scans and capture groups. Learn what to cache and when to use regex-lite.

Rust's regex crate is built on a different design from the backtracking engines in most languages. That gives it a guarantee people rarely get: matching cannot blow up exponentially. It also means its performance traps are different, and mostly about construction. Everything here compiles on Rust 1.90 with edition 2024 and regex 1.11.
The guarantee and its price
A pattern like (a+)+$ against a long string of as followed by b takes exponential time in a backtracking engine such as Perl or Python's re. In Rust it finishes in time proportional to the text. The price is the feature set: no backreferences (\1) and no look-ahead or look-behind. If you need those, the fancy-regex crate adds them and brings back the backtracking risk for those patterns.
Mistake 1: compiling in a loop
use regex::Regex;
fn count_slow(lines: &[&str]) -> usize {
lines
.iter()
.filter(|l| Regex::new(r"^\d{4}-\d{2}-\d{2}").unwrap().is_match(l)) // compiled per line
.count()
}
use std::sync::LazyLock;
static DATE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\d{4}-\d{2}-\d{2}").unwrap());
fn count_fast(lines: &[&str]) -> usize {
lines.iter().filter(|l| DATE.is_match(l)).count()
}Regex::new parses the pattern, builds automata and prepares literal searches. On a hot path that dominates the actual matching by a wide margin. A compiled Regex is cheap to share across threads; clone it or put it in a static. The standard library's LazyLock (Rust 1.80) removes the need for once_cell here (const and static).
Mistake 2: asking for more than you need
use regex::Regex;
fn demo(re: &Regex, text: &str) {
let yes = re.is_match(text); // cheapest: no match positions
let span = re.find(text); // match start and end
let caps = re.captures(text); // capture groups, most work
let _ = (yes, span, caps);
}Capture groups need the engine to track submatch positions, which is slower than finding the overall span. Use is_match for filters, find for extraction of the whole match, and captures only when you need the groups. Use non-capturing groups (?:...) where you only need grouping.
Many patterns: RegexSet
use regex::RegexSet;
fn main() {
let set = RegexSet::new([r"^ERROR", r"timeout", r"\bpanic\b"]).unwrap();
let hits: Vec<usize> = set.matches("ERROR: connection timeout").into_iter().collect();
println!("{hits:?}");
}[0, 1]A RegexSet scans the input once and reports which patterns matched, instead of one scan per pattern. It does not report positions; follow up with the individual regex only for the lines that matched. This is the shape of a log filter with a list of rules.
Anchoring and literals
An anchored pattern (^) lets the engine fail fast. A pattern that begins with a literal prefix or contains a required literal gets a fast substring search (the engine uses vectorized routines from the memchr crate). Leading wildcards such as .*foo hide the literal and force a full scan; foo alone finds the same line with a faster search when you only need to know whether it matches.
Bytes and Unicode
use regex::bytes::Regex;
fn main() {
let re = Regex::new(r"(?-u)\xFF[\x00-\x7F]+").unwrap();
println!("{}", re.is_match(b"\xFFabc"));
}trueregex::bytes matches on &[u8], which avoids a UTF-8 validation pass for binary or mixed data. Unicode-aware classes like \w and \d match thousands of code points; if you only mean ASCII, write [0-9] or disable Unicode with (?-u) for smaller, faster automata.
Limits for untrusted patterns
use regex::RegexBuilder;
fn compile_user_pattern(p: &str) -> Result<regex::Regex, regex::Error> {
RegexBuilder::new(p).size_limit(1 << 20).dfa_size_limit(1 << 22).build()
}When users supply patterns (a search box, a rules file), a huge counted repetition like (\w{100}){100} can use lots of memory to compile. Set size limits and fail with an error (resource limits).
regex-lite and benchmarks
For a CLI where binary size and build time matter more than peak speed, regex-lite offers the same API subset with a smaller footprint, with limited Unicode support. Measure with realistic data and a harness such as Criterion or Divan before changing anything (benchmarking with Criterion and Divan); regex performance depends heavily on the pattern and input shape. For a worked CLI example, see the log analyzer.


