How to replace substring in Rust

Use the replace method on a String to create a new string with the specified substring swapped out.

When a simple swap isn't simple

You're building a CLI tool that reads a template file. The file contains placeholders like ${HOST} and ${PORT}. You need to swap those tokens with actual values before writing the config to disk. You grab the string, call .replace("${HOST}", "127.0.0.1"), and the compiler accepts it. But then you try to print the original string later, and it's gone. Or you try to modify the string in place and hit a wall of borrow checker errors.

Rust handles string replacement differently than Python or JavaScript. The difference isn't syntax. It's ownership. Strings in Rust are immutable by default. When you replace a substring, you aren't editing the existing buffer. You're creating a new string. This design prevents aliasing bugs where one part of the code mutates a string while another part still holds a reference to the old content. The trade-off is that you must handle the new value explicitly.

Strings are immutable, so replacement returns a new value

A String in Rust is a growable, heap-allocated buffer. Once created, its contents cannot change unless you mark it as mutable and use methods that mutate in place. The replace method does not mutate. It reads the input, finds matches, builds a new buffer with the replacements, and returns a fresh String.

Think of it like a version-controlled document. You don't edit the master copy directly. You create a new version with your changes. The old version remains intact until you explicitly discard it. This guarantees that any references to the original string stay valid. If replace mutated in place, every reference to that string would suddenly point to different data, breaking memory safety.

The method signature reflects this. replace takes &self, meaning it borrows the string immutably. It returns String, a new owned value. If you want to update a variable, you must reassign the result.

fn main() {
    // Create an owned string on the heap.
    let template = String::from("Hello ${NAME}");

    // replace returns a new String. The original template is untouched.
    let result = template.replace("${NAME}", "World");

    // Both values exist simultaneously.
    println!("Original: {template}");
    println!("New: {result}");
}

Reassigning the variable is the idiomatic way to "update" a string. The old String is moved out of the variable, dropped, and its memory is freed. The new String takes its place. This pattern is safe and predictable.

What happens under the hood

When you call replace, Rust allocates a new buffer. The size of the buffer depends on the input length and the replacement length. If the replacement is longer than the pattern, the buffer grows. If it's shorter, the buffer shrinks. The method iterates over the input string, copying segments that don't match and writing the replacement for segments that do.

This allocation happens every time you call replace. If you chain multiple replacements, each call allocates a new buffer. The intermediate strings are dropped immediately, but the allocation overhead adds up in tight loops. For one-off replacements, this cost is negligible. For high-performance text processing, you might need to pre-allocate or use a different strategy.

The iteration is Unicode-safe. replace works on character boundaries, not byte boundaries. If your string contains multi-byte characters like emojis or accented letters, replace handles them correctly. It won't split a character in half or corrupt the encoding. This safety comes from the Pattern trait, which drives the replacement logic.

Realistic example: Config substitution

In real code, you often need to replace multiple placeholders. A common pattern is to chain replacements. Since replace returns a String, and String implements Deref<Target=str>, you can call replace directly on the result. This lets you chain calls without intermediate variables.

fn build_config(user: &str, port: u16) -> String {
    // Start with a template literal. This is a &str.
    let template = "host=localhost\nuser=${USER}\nport=${PORT}";

    // Chain replacements. Each call returns a new String.
    // The previous String is dropped automatically.
    let config = template
        .replace("${USER}", user)
        .replace("${PORT}", &port.to_string());

    config
}

fn main() {
    let config = build_config("admin", 8080);
    println!("{config}");
}

Chaining on &str is efficient because you avoid creating a named intermediate String. The compiler optimizes the chain by reusing buffers where possible, though each replace still performs an allocation. If you have many replacements, consider collecting them into a single pass or using a template engine to reduce allocation pressure.

Convention aside: prefer chaining on &str when the template is a literal or a borrowed slice. It keeps the code concise and signals that you're building a new value from scratch. If you're modifying an existing String variable, reassigning is clearer: let s = s.replace(old, new);.

The Pattern trait unlocks more than substrings

The replace method is generic over a Pattern type. This means the first argument doesn't have to be a substring. It can be a character, a regular expression, or even a closure. The Pattern trait abstracts the matching logic, allowing replace to work with diverse inputs.

You can replace by character without quoting. This is handy for sanitizing input or normalizing text.

fn main() {
    let path = "C:\\Users\\Admin\\Documents";

    // Replace by char. No quotes needed for the pattern.
    // This normalizes Windows paths to Unix style.
    let unix_path = path.replace('\\', '/');
    println!("{unix_path}");
}

You can also use a closure as the pattern. The closure receives each character and returns true if it should be replaced. This turns replace into a filter.

fn main() {
    let text = "hello world 123";

    // Replace all digits with asterisks.
    // The closure acts as the pattern matcher.
    let redacted = text.replace(|c: char| c.is_ascii_digit(), "*");
    println!("{redacted}");
}

This flexibility makes replace a powerful tool. You don't need to import regex for simple character class replacements. A closure handles it with zero dependencies. The Pattern trait also supports Regex from the regex crate, so you can drop in complex patterns when needed.

Trust the Pattern trait. It's more capable than a simple substring swapper. Use closures for character-level logic, and reserve regex for complex structural matches.

Pitfalls: Byte indices, allocation, and limits

The byte index trap with replace_range

If you need to modify a string in place, replace won't help. It always returns a new value. For in-place mutation, Rust provides replace_range. This method takes a byte range and overwrites that slice with new content. It mutates the String directly.

fn main() {
    let mut s = String::from("hello world");

    // Replace bytes 6..11 with "Rust".
    // This modifies s in place.
    s.replace_range(6..11, "Rust");
    println!("{s}");
}

The danger is that replace_range uses byte indices, not character indices. If you calculate indices based on character count, you'll panic at runtime. Multi-byte characters break the alignment.

fn main() {
    let mut s = String::from("café");

    // 'é' is two bytes. Index 3 is the second byte of 'é'.
    // This panics because the range splits a character.
    // s.replace_range(3..4, "x");

    // Use char_indices to get safe byte offsets.
    let (byte_idx, _) = s.char_indices().nth(3).unwrap();
    s.replace_range(byte_idx.., "e");
    println!("{s}");
}

Byte indices are the enemy. Verify your bounds before calling replace_range. If you're working with character positions, convert them to byte offsets using char_indices. Never assume len() or character counts match byte positions.

Allocation pressure

Calling replace in a loop allocates a new string every iteration. If you're processing large files or high-throughput streams, this can thrash the allocator.

fn slow_replace(mut input: String) -> String {
    // This allocates a new String on every iteration.
    for _ in 0..1000 {
        input = input.replace("a", "b");
    }
    input
}

If the replacement might not happen, use Cow (Clone on Write). Cow borrows the original string if no replacement is needed, avoiding allocation.

use std::borrow::Cow;

fn maybe_replace(input: &str) -> Cow<'_, str> {
    if input.contains("secret") {
        // Replacement happens. Return an owned String.
        Cow::Owned(input.replace("secret", "****"))
    } else {
        // No replacement. Borrow the original.
        Cow::Borrowed(input)
    }
}

Cow shines when replacements are rare. It defers allocation until necessary. If replacements are frequent, Cow adds overhead without benefit. Profile your workload before reaching for Cow.

Limiting replacements

replace swaps all occurrences. If you only want to replace the first match, use replacen. This method takes a count argument.

fn main() {
    let text = "one two three one two";

    // Replace only the first occurrence.
    let limited = text.replacen("one", "zero", 1);
    println!("{limited}");
}

Use replacen when you need precise control over replacement count. It's common in template processing where you want to fill the first placeholder but leave others intact.

Decision: Choosing the right replacement tool

Use replace when you need to swap all occurrences of a substring, character, or pattern and don't mind allocating a new string. Use replacen when you only want to replace the first N matches and need to leave the rest untouched. Use replace_range when you have verified byte indices and need to mutate the string in place for performance reasons. Use regex::Regex::replace when the pattern involves complex logic like lookaheads, groups, or backreferences that closures can't express. Use Cow when the input might not require replacement and you want to avoid allocation in the common case.

Reach for plain replace in most scenarios. It's safe, Unicode-correct, and idiomatic. Reserve replace_range for hot paths where allocation is measured as a bottleneck and you can guarantee byte alignment. Treat replace_range as a low-level escape hatch. If you can't prove your indices are byte-safe, stick to replace.

Where to go next