How does regex::Regex::replace_all handle captured groups in replacement strings?

regex::Regex::replace_all substitutes all matches of a pattern with a replacement string, interpreting special syntax like $1, $2, and $name to reference captured groups from the match. The replacement string is processed to expand capture group references into their matched content, enabling dynamic replacements based on what was captured. Named capture groups use $name syntax, numbered captures use $1 through $99, and the entire match is referenced with $0. For complex replacement logic that can't be expressed in a string, replace_all accepts a closure that receives Captures and returns a Cow<str>. The replacement string syntax treats $ specially—use $$ to produce a literal $ character in the output.

Basic Replacement Without Captures

use regex::Regex;
 
fn main() {
    let text = "The quick brown fox jumps over the lazy dog.";
    
    // Simple replacement with literal string
    let re = Regex::new("fox").unwrap();
    let result = re.replace_all(text, "cat");
    println!("Result: {}", result);
    // "The quick brown cat jumps over the lazy dog."
    
    // Replace all occurrences
    let re = Regex::new("the").unwrap();
    let result = re.replace_all(text, "a");
    println!("Result: {}", result);
    // "a quick brown fox jumps over a lazy dog."
    // Note: 'the' is case-sensitive, matches "the" but not "The"
}

When the replacement contains no $ references, it's treated as a literal string.

Numbered Capture Groups

use regex::Regex;
 
fn main() {
    // Capture groups are referenced by $1, $2, etc.
    let text = "John Smith";
    let re = Regex::new(r"(\w+) (\w+)").unwrap();
    
    // $1 = first capture group, $2 = second capture group
    let result = re.replace_all(text, "$2, $1");
    println!("Swapped: {}", result);
    // "Smith, John"
    
    // Multiple matches - replace_all processes all
    let text = "apple banana cherry";
    let re = Regex::new(r"(\w)(\w+)").unwrap();
    
    // $1 = first letter, $2 = rest of word
    let result = re.replace_all(text, "$1-$2");
    println!("With dash: {}", result);
    // "a-pple b-anana c-herry"
    
    // $0 is the entire match
    let text = "hello world";
    let re = Regex::new(r"\w+").unwrap();
    let result = re.replace_all(text, "[$0]");
    println!("Bracketed: {}", result);
    // "[hello] [world]"
}

$1 through $99 reference numbered capture groups; $0 references the entire match.

Named Capture Groups in Replacement

use regex::Regex;
 
fn main() {
    // Named captures use $name syntax
    let text = "2024-03-15";
    let re = Regex::new(r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})").unwrap();
    
    // Reference named groups by name
    let result = re.replace_all(text, "$month/$day/$year");
    println!("Reformatted: {}", result);
    // "03/15/2024"
    
    // Named groups can also be referenced by number
    let result = re.replace_all(text, "$2/$3/$1");
    println!("By number: {}", result);
    // "03/15/2024"
    
    // Mix named and numbered references
    let text = "user@example.com";
    let re = Regex::new(r"(?P<user>\w+)@(?P<domain>[\w.]+)").unwrap();
    let result = re.replace_all(text, "$user at $domain");
    println!("Email: {}", result);
    // "user at example.com"
}

Named captures can be referenced by $name or by their position number.

Literal Dollar Signs

use regex::Regex;
 
fn main() {
    // $$ produces a literal $ in the replacement
    let text = "Price: 100";
    let re = Regex::new(r"(\d+)").unwrap();
    
    let result = re.replace_all(text, "$$$1");
    println!("With dollar sign: {}", result);
    // "Price: $100"
    
    // Without $$, $ would be interpreted as a capture reference
    let text = "item1 item2";
    let re = Regex::new(r"item(\d)").unwrap();
    
    // $$1 means literal $ followed by group 1's content
    let result = re.replace_all(text, "$$$1");
    println!("Escaped: {}", result);
    // "$1 $2"
    
    // $1 alone would reference the capture
    let result = re.replace_all(text, "$1");
    println!("Unescaped: {}", result);
    // "1 2"
}

Use $$ to include a literal $ in the replacement string.

Capture Group Ambiguity

use regex::Regex;
 
fn main() {
    // Ambiguity: $10 could mean group 10 or group 1 followed by "0"
    let text = "abcdefghij";
    let re = Regex::new(r"(.)").unwrap();
    
    // Regex resolves ambiguity by preferring larger numbers
    // If group 10 doesn't exist, it uses group 1 + literal "0"
    
    // Explicit group reference with braces
    let text = "abc";
    let re = Regex::new(r"(.)(.)(.)").unwrap();
    
    // ${1}0 means group 1 followed by literal 0
    let result = re.replace_all(text, "${1}0");
    println!("Explicit: {}", result);
    // "a0b0c0"
    
    // $10 would try group 10 (which doesn't exist)
    // Falls back to group 1 + literal "0"
    let result = re.replace_all(text, "$10");
    println!("Ambiguous: {}", result);
    // "a0b0c0" - same result in this case
    
    // Named groups avoid ambiguity
    let re = Regex::new(r"(?P<first>.)(?P<second>.)(?P<third>.)").unwrap();
    let result = re.replace_all(text, "$first$second$third");
    println!("Named: {}", result);
    // "abc"
}

Use ${n} to disambiguate when a number following a capture reference could be misinterpreted.

Replacement with Closures

use regex::Regex;
 
fn main() {
    // For complex logic, use a closure instead of a string
    let text = "The temperatures are 32F and 100C";
    let re = Regex::new(r"(\d+)F|(\d+)C").unwrap();
    
    let result = re.replace_all(text, |caps: &regex::Captures| {
        if let Some(fahrenheit) = caps.get(1) {
            let f: i32 = fahrenheit.as_str().parse().unwrap();
            let c = (f - 32) * 5 / 9;
            format!("{}C", c)
        } else if let Some(celsius) = caps.get(2) {
            let c: i32 = celsius.as_str().parse().unwrap();
            let f = c * 9 / 5 + 32;
            format!("{}F", f)
        } else {
            caps[0].to_string()
        }
    });
    
    println!("Converted: {}", result);
    // "The temperatures are 0C and 212F"
    
    // Closure receives Captures, returns String or Cow<str>
    let text = "multiply 5 by 3";
    let re = Regex::new(r"multiply (\d+) by (\d+)").unwrap();
    
    let result = re.replace_all(text, |caps: &regex::Captures| {
        let a: i32 = caps[1].parse().unwrap();
        let b: i32 = caps[2].parse().unwrap();
        format!("result {}", a * b)
    });
    
    println!("Computed: {}", result);
    // "result 15"
}

Closures enable complex replacement logic that can't be expressed with string interpolation.

Accessing Capture Groups in Closures

use regex::Regex;
 
fn main() {
    let text = "Name: John Doe, Age: 30";
    let re = Regex::new(r"Name: (\w+) (\w+), Age: (\d+)").unwrap();
    
    let result = re.replace_all(text, |caps: &regex::Captures| {
        // Access captures by index
        let first = &caps[1];
        let last = &caps[2];
        let age: u32 = caps[3].parse().unwrap();
        
        // Process and format
        let full_name = format!("{} {} ({})", first, last, age);
        full_name
    });
    
    println!("Formatted: {}", result);
    // "John Doe (30)"
    
    // Named captures in closures
    let text = "2024-03-15";
    let re = Regex::new(r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})").unwrap();
    
    let result = re.replace_all(text, |caps: &regex::Captures| {
        // Access by name
        let year = caps.name("year").unwrap().as_str();
        let month = caps.name("month").unwrap().as_str();
        let day = caps.name("day").unwrap().as_str();
        
        // Can also access by index: caps[1], caps[2], caps[3]
        format!("{}/{}/{}", month, day, year)
    });
    
    println!("Date: {}", result);
    // "03/15/2024"
    
    // Check if a capture matched
    let text = "color: red";
    let re = Regex::new(r"color: (\w+)(?:, shade: (\w+))?").unwrap();
    
    let result = re.replace_all(text, |caps: &regex::Captures| {
        let color = &caps[1];
        if let Some(shade) = caps.get(2) {
            format!("{} ({})", color, shade.as_str())
        } else {
            color.to_string()
        }
    });
    
    println!("Color: {}", result);
}

Use caps[n] for indexed access, caps.name("n") for named access, and caps.get(n) for optional captures.

replace vs replace_all

use regex::Regex;
 
fn main() {
    // replace() only replaces the first match
    let text = "aaa";
    let re = Regex::new("a").unwrap();
    
    let result = re.replace(text, "b");
    println!("replace: {}", result);
    // "baa"
    
    // replace_all() replaces all matches
    let result = re.replace_all(text, "b");
    println!("replace_all: {}", result);
    // "bbb"
    
    // With capture groups
    let text = "word1 word2 word3";
    let re = Regex::new(r"word(\d+)").unwrap();
    
    let result = re.replace(text, "WORD$1");
    println!("replace: {}", result);
    // "WORD1 word2 word3"
    
    let result = re.replace_all(text, "WORD$1");
    println!("replace_all: {}", result);
    // "WORD1 WORD2 WORD3"
}

replace modifies only the first match; replace_all modifies all matches.

Non-Matching Capture Groups

use regex::Regex;
 
fn main() {
    // Optional captures that didn't match are empty
    let text = "hello";
    let re = Regex::new(r"(\w+)(?: (\w+))?").unwrap();
    
    let result = re.replace_all(text, |caps: &regex::Captures| {
        let first = &caps[1];
        let second = caps.get(2).map(|m| m.as_str()).unwrap_or("N/A");
        format!("first: {}, second: {}", first, second)
    });
    
    println!("Result: {}", result);
    // "first: hello, second: N/A"
    
    // In string replacement, non-matching groups produce empty strings
    let text = "hello";
    let re = Regex::new(r"(\w+)( (\w+))?").unwrap();
    
    let result = re.replace_all(text, "[$1][$2][$3]");
    println!("Bracketed: {}", result);
    // "[hello][]" - group 2 and 3 are empty
    
    // The whole pattern matches, but optional groups may not
    let text = "value: 42";
    let re = Regex::new(r"value: (?:(\d+)|(\w+))").unwrap();
    
    let result = re.replace_all(text, |caps: &regex::Captures| {
        if let Some(num) = caps.get(1) {
            format!("number: {}", num.as_str())
        } else if let Some(word) = caps.get(2) {
            format!("word: {}", word.as_str())
        } else {
            "unknown".to_string()
        }
    });
    
    println!("Typed: {}", result);
    // "number: 42"
}

Optional captures that didn't participate in the match return None from get().

Multiple Matches with Captures

use regex::Regex;
 
fn main() {
    // Each match processes its own capture groups
    let text = "name: John, age: 30; name: Jane, age: 25";
    let re = Regex::new(r"name: (\w+), age: (\d+)").unwrap();
    
    let result = re.replace_all(text, "$1 (age $2)");
    println!("Formatted: {}", result);
    // "John (age 30); Jane (age 25)"
    
    // Captures are per-match, not global
    let text = "a=1 b=2 c=3";
    let re = Regex::new(r"(\w)=(\d)").unwrap();
    
    let result = re.replace_all(text, "[$1=$2]");
    println!("Bracketed: {}", result);
    // "[a=1] [b=2] [c=3]"
    
    // Each occurrence gets its own capture groups
}

Each match has its own set of capture group values; replacements are independent.

Complex Replacement Example

use regex::Regex;
 
fn main() {
    // Markdown links to HTML
    let text = "Check [example](https://example.com) and [docs](https://docs.rs)";
    let re = Regex::new(r"\[(?P<text>[^]]+)\]\((?P<url>[^)]+)\)").unwrap();
    
    let result = re.replace_all(text, r#"<a href="$url">$text</a>"#);
    println!("HTML: {}", result);
    // <a href="https://example.com">example</a> and <a href="https://docs.rs">docs</a>
    
    // Template variable substitution
    let text = "Hello {{name}}, your balance is {{amount}}";
    let re = Regex::new(r"\{\{(\w+)\}\}").unwrap();
    
    // In real code, look up variables in a map
    let result = re.replace_all(text, |caps: &regex::Captures| {
        let var = &caps[1];
        match var {
            "name" => "Alice".to_string(),
            "amount" => "$100.00".to_string(),
            _ => format!("{{{{{}}}}}", var),  // Keep unknown variables
        }
    });
    
    println!("Substituted: {}", result);
    // "Hello Alice, your balance is $100.00"
    
    // Code transformation
    let text = "function foo() { return 1; }";
    let re = Regex::new(r"function (\w+)\(\) \{ return (\d+); \}").unwrap();
    
    let result = re.replace_all(text, "fn $1() -> i32 { $2 }");
    println!("Transformed: {}", result);
    // "fn foo() -> i32 { 1 }"
}

Real-world replacements often combine named captures with structured output.

Escape Sequences in Replacement

use regex::Regex;
 
fn main() {
    // Only $ is special in replacement strings
    // Backslash escapes are NOT processed
    let text = "hello\\nworld";
    let re = Regex::new(r"\\n").unwrap();
    
    // This replaces literal \n with actual newline
    let result = re.replace_all(text, "\n");
    println!("With newline:\n{}", result);
    
    // But in the replacement string, \n is literal backslash + n
    // Use actual escape sequences in Rust strings
    
    // To get a literal backslash in output
    let text = "path/to/file";
    let re = Regex::new(r"/").unwrap();
    let result = re.replace_all(text, "\\");
    println!("Backslashes: {}", result);
    // "path\to\file"
    
    // Common confusion: $$ vs $
    let text = "price: 100";
    let re = Regex::new(r"(\d+)").unwrap();
    
    // Wrong: $ is interpreted as capture reference
    // "$100" would look for group 100
    
    // Correct: $$ for literal $
    let result = re.replace_all(text, "$$$1");
    println!("Money: {}", result);
    // "price: $100"
}

Only $ is special in replacement strings; backslash sequences must be actual Rust escape sequences.

Performance Considerations

use regex::Regex;
use std::time::Instant;
 
fn main() {
    let text = "The quick brown fox jumps over the lazy dog. ".repeat(1000);
    
    // String replacement is faster than closure for simple cases
    let re = Regex::new(r"(\w+)").unwrap();
    
    let start = Instant::now();
    let _ = re.replace_all(&text, "[$1]");
    println!("String replacement: {:?}", start.elapsed());
    
    // Closure has more overhead but enables complex logic
    let start = Instant::now();
    let _ = re.replace_all(&text, |caps: &regex::Captures| {
        format!("[{}]", &caps[1])
    });
    println!("Closure replacement: {:?}", start.elapsed());
    
    // For repeated replacements, pre-compile once
    let re = Regex::new(r"(\w+)").unwrap();
    
    // NoMatch substitution - fastest when replacing with captures
    let result = re.replace_all(&text, "$1");
    // Identity replacement, no actual change
}

String replacement is faster than closures; use closures only when necessary.

API Summary

use regex::Regex;
 
fn main() {
    let text = "hello world";
    let re = Regex::new(r"(\w+) (\w+)").unwrap();
    
    // String replacement with capture references
    let result = re.replace_all(text, "$2 $1");
    
    // Named captures
    let re = Regex::new(r"(?P<first>\w+) (?P<second>\w+)").unwrap();
    let result = re.replace_all(text, "$second $first");
    
    // Closure replacement
    let result = re.replace_all(text, |caps: &regex::Captures| {
        format!("{} {}", &caps[2], &caps[1])
    });
    
    // Literal $ with $$
    let text = "price: 100";
    let re = Regex::new(r"(\d+)").unwrap();
    let result = re.replace_all(text, "$$$1");
    
    // $0 for entire match
    let re = Regex::new(r"\w+").unwrap();
    let result = re.replace_all(text, "[$0]");
    
    // Disambiguate with ${n}
    let text = "abc";
    let re = Regex::new(r"(.)(.)(.)").unwrap();
    let result = re.replace_all(text, "${1}0");
}

Synthesis

Replacement string syntax:

Syntax Meaning
$0 Entire match
$1 through $99 Numbered capture groups
$name Named capture group
${name} Named capture (disambiguated)
${1} Numbered capture (disambiguated)
$$ Literal $

Capture group access in closures:

Method Returns Use case
caps[n] &str Direct access, panics if missing
caps.get(n) Option<Match> Optional captures
caps.name("n") Option<Match> Named captures

Key differences from other engines:

Feature Rust regex Others
Capture syntax $1, $name Some use \1
Escape $$ for literal $ Varies
Backslash escapes Not processed Some process \n, etc.

When to use each approach:

Approach Use when
String replacement Simple substitution with captures
Closure replacement Computation needed, conditional logic
Named captures Readability matters, many groups
Numbered captures Quick one-offs, standard groups

Key insight: replace_all processes the replacement string specially, interpreting $ as a capture group reference prefix. This is distinct from regex pattern syntax—within patterns, groups use (...) and backreferences use \1, but in replacement strings, captures use $1. The $$ escape produces a literal $ when the replacement needs a dollar sign. For transformations requiring computation (formatting, lookup, conditional logic), the closure variant receives Captures and returns Cow<str>, enabling arbitrary Rust code while still matching the pattern. Named captures make complex patterns readable in replacement strings ($year/$month/$day vs $1/$2/$3), and the get() method handles optional captures gracefully. The replace_all method applies to every non-overlapping match, while replace applies only to the first—both share the same replacement string syntax.