Skip to content

Replacing part of a string

Level: 101 → 201 · working knowledge

One line: replace does not replace anything — a str cannot change length, so it builds and returns a new String; the two methods that really do edit in place take a byte range and a character predicate instead of a pattern, and that difference is the whole design.

The Book's strings chapter ends by pointing at contains and replace and saying, in effect, stop indexing and go read the method list. Searching without splitting is the first half of that. This is the second: what to do once you have found the thing.

The name is the trap. In most languages replace is a verb that happens to the string you called it on; here it is a function that returns a different string and leaves yours alone. Rust anticipated the mistake and marked the method #[must_use], so writing it as a statement is a warning whose note is the whole lesson:

warning: unused return value of `str::<impl str>::replace` that must be used
 --> replace_as_a_statement.rs:3:5
  |
3 |     s.replace(' ', "-");
  |     ^^^^^^^^^^^^^^^^^^^
  |
  = note: this returns the replaced string as a new allocation, without modifying the original
  = note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
help: use `let _ = ...` to ignore the resulting value
  |
3 |     let _ = s.replace(' ', "-");
  |     +++++++

Verified output of replacing_in_a_string.rs — regenerated by tools/run_examples.py, never hand-typed.

1. replace does not replace -- it returns a new String
   original  "config.old.toml"   <- untouched
   renamed   "config.new.toml"
   The receiver is a &str and a str cannot change length, so there
   is nowhere for an edit to go. The answer comes back as a String.

2. Every occurrence, unless you say how many
   replace(',', ";")       "a;b;c;d"
   replacen(',', ";", 1)   "a;b,c,d"
   replacen(',', ";", 99)  "a;b;c;d"
   There is no rreplacen: the count always runs from the left.

3. The needle is a Pattern; the replacement is plain text
   char      "sda1 | sdb2 | sdc3"
   &str      "hda1 hdb2 hdc3"
   [char]    "sdaN sdbN sdcN"
   closure   "sdaN sdbN sdcN"
   What you cannot do is refer to what matched: the replacement is a
   &str, not a template. No $1, no backreferences -- that is a regex
   engine, and std does not ship one.

4. It allocates even when nothing matched
   "one\ttwo" owned     <- a new String
   "one two"  borrowed  <- no allocation
   A bare replace() would have allocated on both rows. contains() is
   the cheap question that lets the second row skip the copy.

5. A chain of replaces is not simultaneous
   raw      "a < b & c"
   < first  "a &amp;lt; b &amp; c"   <- the & it just wrote got escaped again
   & first  "a &lt; b &amp; c"
   Ordering saved this pair. It does not scale: each new rule has to
   be checked against the output of every earlier one.

6. One pass, and the order stops mattering
   substitute(raw, table)     "a &lt; b &amp; c"
   substitute(raw, reversed)  "a &lt; b &amp; c"
   Same answer both ways, because the scan moves past what it wrote.
   Built from strip_prefix -- the search method from the page before.

7. Editing in place, for real
   find(char::is_numeric) = 10, then replace_range(10.., "60s")
   "timeout = 60s"
   replace_range takes a BYTE RANGE, not a pattern -- which is exactly
   the number find() hands you, and exactly the number that can panic.
   retain(is_ascii_digit) "15550109999"   <- deletion in place, no new String

8. The panic replace_range keeps
   "żółw".replace_range(0..1, "z")  -> panicked: 1 is inside 'ż'
   0..'ż'.len_utf8() instead        -> "zółw"
   replace() never has this problem: it only ever cuts where a match
   started, and a match starts on a character.

Which tool for which edit

the edit the method in place?
every occurrence of a pattern replace no — a new String
the first n of them replacen no
a byte range you already know String::replace_range yes
delete characters by predicate String::retain yes
strip repeats off one end trim_start_matches · trim_end_matches no — but it returns a view, not a copy
a table of rules, applied at once nothing in std write the one pass — see below

The split runs along ownership, as it does everywhere in this section. The two in-place methods are on String because they need to own the bytes; everything else is on str and therefore works on both.

The needle is a pattern; the replacement is not a template

replace takes the same four shapes every other search takes — a char, a &str, a list of characters, a closure — so s.replace(char::is_numeric, "N") is a real thing to write. The str method reference has them as a table.

The replacement side is a plain &str, and that is the boundary of what std will do. There is no way to refer to what matched — no $1, no backreference, no callback per match. A substitution that has to look at the text it replaces is a regex job, or a hand-written pass like the one below.

A chain of replaces is not a substitution table

fn main() {
    let s = "cat dog cat";
    println!("{:?}", s.replace("cat", "dog").replace("dog", "cat"));  // "cat cat cat"
    println!("{:?}", s.replace("dog", "cat").replace("cat", "dog"));  // "dog dog dog"
}

Neither order swaps them, and no third order exists. The second pass cannot tell the first pass's output from the original text, so anything a rule writes is available for the next rule to rewrite. HTML escaping is the everyday version: escaping < before & turns &lt; into &amp;lt;. That particular pair is fixed by putting & first — worth knowing, and not a strategy: every rule you add afterwards has to be checked against every rule already there.

One pass fixes it for good. At each position, take the first rule that matches, append what it produces, and continue the scan past it:

fn substitute(text: &str, table: &[(&str, &str)]) -> String {
    let mut out = String::with_capacity(text.len());
    let mut rest = text;
    'scan: while !rest.is_empty() {
        for (from, to) in table {
            // An empty needle matches everywhere and would never advance `rest`.
            if from.is_empty() {
                continue;
            }
            if let Some(tail) = rest.strip_prefix(from) {
                out.push_str(to);
                rest = tail;
                continue 'scan;
            }
        }
        let c = rest.chars().next().unwrap();
        out.push(c);
        rest = &rest[c.len_utf8()..];
    }
    out
}

fn main() {
    let table = [("&", "&amp;"), ("<", "&lt;"), (">", "&gt;")];
    println!("{:?}", substitute("a < b & c", &table));  // "a &lt; b &amp; c"
}

No crate, and the order of the table stops mattering. The guard in the middle is not decoration: an empty needle matches at every position, so without it the scan never advances. std answers the same question differently — "ab".replace("", "-") is "-a-b-", an empty match at every boundary. The strip_prefix in the middle is the search method from the page before, doing the one job it is best at: asking a question and handing back the remainder in the same call.

Editing in place, and the panic that comes with it

String::replace_range is the one that really edits — and it takes a byte range, not a pattern, which is exactly the number find gives you:

fn main() {
    let mut cfg = String::from("timeout = 30s");
    let at = cfg.find(char::is_numeric).unwrap();
    cfg.replace_range(at.., "60s");
    println!("{cfg:?}");  // "timeout = 60s"
}

It is also the only method on this page that can panic. A range endpoint inside a character is a run-time abort — String::from("żółw").replace_range(0..1, "z") panics, because byte 1 is inside ż. replace never has this problem: it only ever cuts where a match started, and a match starts on a character boundary by construction. So the rule is the section's usual one — an offset you computed is a promise you are making, and the way to keep it is to get the offset from a search or from char_indices, never from arithmetic on a count.

String::retain is the deletion-only sibling: it keeps the characters a predicate approves, in place, with no new allocation. It is handed one char at a time and never sees a substring, so it can express "delete every digit" and cannot express "delete every ab". That is the line between the two families in one sentence — retain filters characters, replace matches patterns.

Paying for the copy only when there is something to copy

replace allocates whether or not it matched anything: the return type is String, so a miss still copies the whole input. Where that matters, contains is the cheap question that lets you skip it, and Cow is the type that lets both branches have the same return type:

use std::borrow::Cow;

fn tabs_to_spaces(s: &str) -> Cow<'_, str> {
    if s.contains('\t') {
        Cow::Owned(s.replace('\t', "    "))
    } else {
        Cow::Borrowed(s)
    }
}

fn main() {
    println!("{}", matches!(tabs_to_spaces("one two"), Cow::Borrowed(_)));  // true
}

This is worth doing on a hot path and not worth doing anywhere else — a Cow in a signature is a cost paid by every caller who now has to think about it. When String is too slow is where that trade belongs.

If you are coming from another language

Python. str.replace behaves the same way — Python strings are immutable too, so s.replace(a, b) returns a new one and s is untouched. The habit transfers intact, including the third argument: s.replace(",", ";", 1) is s.replacen(',', ";", 1), counting from the left in both. What does not transfer is re.sub, whose replacement string is a template and whose callback form takes a function — neither exists in std. Python has no in-place string edit at all, so replace_range and retain have no counterpart to unlearn; the nearest thing is rebuilding through a list.

ABAP. Here the habit actively misleads. REPLACE ALL OCCURRENCES OF 'a' IN lv_text WITH 'b' modifies lv_text and reports through sy-subrc, so the reflex is to write the Rust call as a statement and expect the variable to have changed. That is the exact mistake #[must_use] was put there to catch, and the must_use warning at the top of this page is what you get instead of a silent no-op. Two more differences worth holding onto: ABAP's REPLACE accepts IN CHARACTER MODE offsets counted in characters, while replace_range counts bytes; and ABAP's regex forms (REPLACE ALL OCCURRENCES OF REGEX, with $1 in the replacement) are in the language, whereas in Rust that is a crate. (Not machine-checked — CI cannot run ABAP.)

Practice

replace, without replace. Write replace_all(s, from, to) without calling str::replace or replacen: find each match with find, copy the text before it and then the replacement into one new String, and carry on from just past the match. The three tests below are the specification. Then two things they never ask. Predict what replace_all("banana", "ana", "_") returns before you run it — the word holds "ana" twice, overlapping. And call it with an empty from: decide what the answer ought to be, and make sure your loop gets there instead of running forever.

// rustc --edition 2024 --test replace_all.rs -o t && ./t
// Without calling str::replace or str::replacen.
fn replace_all(s: &str, from: &str, to: &str) -> String {
    todo!()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_replace_all() {
        assert_eq!(replace_all("hello world", "world", "rust"), "hello rust");
        assert_eq!(replace_all("aaa", "a", "b"), "bbb");
        assert_eq!(replace_all("hello", "x", "y"), "hello");
    }
}
Solution

replace_all_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.

//! Kata solution: `replace` without `replace` — then the empty pattern that
//! never lets the loop move.
//!
//!   rustc --edition 2024 replace_all_kata.rs -o /tmp/rak && /tmp/rak
//!   rustc --edition 2024 --test replace_all_kata.rs -o /tmp/rakt && /tmp/rakt

/// Every non-overlapping `from`, left to right, copied into one new buffer.
/// `find` hands back a BYTE offset, and slicing there is safe: a match inside
/// a valid `&str` can only start and end on character boundaries.
fn replace_all(s: &str, from: &str, to: &str) -> String {
    if from.is_empty() {
        // `find("")` matches at the start of whatever is left, so the loop
        // below would never shorten `rest`. std's `replace` treats the empty
        // pattern as matching between every pair of characters; do the same.
        let mut out = String::with_capacity(s.len() + to.len() * (s.chars().count() + 1));
        out.push_str(to);
        for c in s.chars() {
            out.push(c);
            out.push_str(to);
        }
        return out;
    }
    let mut out = String::with_capacity(s.len());
    let mut rest = s;
    while let Some(at) = rest.find(from) {
        out.push_str(&rest[..at]);
        out.push_str(to);
        rest = &rest[at + from.len()..];
    }
    out.push_str(rest);
    out
}

/// The loop above with the empty-pattern guard removed, stopped after `cap`
/// turns so the demonstration can report what it did instead of hanging.
fn naive_turns(s: &str, from: &str, cap: usize) -> (usize, usize) {
    let mut rest = s;
    let mut turns = 0;
    while let Some(at) = rest.find(from) {
        rest = &rest[at + from.len()..];
        turns += 1;
        if turns == cap {
            break;
        }
    }
    (turns, rest.len())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_replace_all() {
        assert_eq!(replace_all("hello world", "world", "rust"), "hello rust");
        assert_eq!(replace_all("aaa", "a", "b"), "bbb");
        assert_eq!(replace_all("hello", "x", "y"), "hello");
    }
}

fn main() {
    println!("1. Adam's three cases, checked against std's own replace");
    for (s, from, to) in [("hello world", "world", "rust"), ("aaa", "a", "b"), ("hello", "x", "y")] {
        let mine = replace_all(s, from, to);
        assert_eq!(mine, s.replace(from, to));
        println!("   {:<15} {:<9} {:<8} -> {mine:?}", format!("{s:?}"), format!("{from:?}"), format!("{to:?}"));
    }

    println!();
    println!("2. Matches are taken left to right, and never overlap");
    for (s, from, to) in [("aaaa", "aa", "b"), ("aaa", "aa", "b"), ("banana", "ana", "_")] {
        let mine = replace_all(s, from, to);
        assert_eq!(mine, s.replace(from, to));
        println!("   {:<15} {:<9} {:<8} -> {mine:?}", format!("{s:?}"), format!("{from:?}"), format!("{to:?}"));
    }
    println!("   \"banana\" holds \"ana\" twice, overlapping at the middle \"a\". Once the");
    println!("   first match is consumed the second no longer exists: one replacement.");

    println!();
    println!("3. The empty pattern, and the loop that never moves");
    let (turns, left) = naive_turns("abc", "", 10);
    println!("   without the guard: stopped after {turns} turns, {left} bytes still left");
    println!("   \"abc\".find(\"\") = {:?}", "abc".find(""));
    println!("   std  {:?}", "abc".replace("", "-"));
    println!("   mine {:?}", replace_all("abc", "", "-"));
    assert_eq!(replace_all("abc", "", "-"), "abc".replace("", "-"));
    assert_eq!(replace_all("", "", "-"), "".replace("", "-"));
    println!("   An empty needle is found at offset 0 of every remainder, so cutting");
    println!("   from.len() bytes off the front cuts nothing. Adam's tests never pass");
    println!("   an empty `from`, which is exactly how this loop ships.");

    println!();
    println!("4. Byte offsets, and why slicing at them is safe");
    let s = "żółw i żółw";
    println!("   {s:?}.find(\"ół\") = {:?} — a byte offset, and a char boundary", s.find("ół"));
    println!("   {:?}", replace_all(s, "ół", "OL"));
    assert_eq!(replace_all(s, "ół", "OL"), s.replace("ół", "OL"));
}

Verified output of replace_all_kata.rs — regenerated by tools/run_examples.py, never hand-typed.

1. Adam's three cases, checked against std's own replace
   "hello world"   "world"   "rust"   -> "hello rust"
   "aaa"           "a"       "b"      -> "bbb"
   "hello"         "x"       "y"      -> "hello"

2. Matches are taken left to right, and never overlap
   "aaaa"          "aa"      "b"      -> "bb"
   "aaa"           "aa"      "b"      -> "ba"
   "banana"        "ana"     "_"      -> "b_na"
   "banana" holds "ana" twice, overlapping at the middle "a". Once the
   first match is consumed the second no longer exists: one replacement.

3. The empty pattern, and the loop that never moves
   without the guard: stopped after 10 turns, 3 bytes still left
   "abc".find("") = Some(0)
   std  "-a-b-c-"
   mine "-a-b-c-"
   An empty needle is found at offset 0 of every remainder, so cutting
   from.len() bytes off the front cuts nothing. Adam's tests never pass
   an empty `from`, which is exactly how this loop ships.

4. Byte offsets, and why slicing at them is safe
   "żółw i żółw".find("ół") = Some(2) — a byte offset, and a char boundary
   "żOLw i żOLw"

Escape it twice, then escape it once.

Start with "cat dog cat" and a table that swaps the two words. Chain two replace calls in both orders, predict each result before you run it, and then state in one sentence why no ordering can work.

Write substitute(text, table) — the single pass above — and prove the order stopped mattering by running it with the table reversed.

Then write replace_last_n(text, pat, to, n), the method replacen cannot give you because its count runs from the left. Use rmatch_indices and replace_range, and work back to front. Collect the same offsets and apply them front to back as well, so you can see what a stale offset does to a string that has already changed length.

Finish with deletion: delete every space from a String twice, once with retain and once with replace(' ', ""), and then find a job only one of the two can do.

Solution

replacing_in_a_string_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.

//! Kata solution: the substitution table no ordering can save, the "last n"
//! replacement std does not offer, and the difference between deleting with
//! `retain` and deleting with `replace(.., "")`.
//!
//!   rustc --edition 2024 replacing_in_a_string_kata.rs -o /tmp/risk && /tmp/risk

/// One pass, first rule wins, scan continues past what was written.
fn substitute(text: &str, table: &[(&str, &str)]) -> String {
    let mut out = String::with_capacity(text.len());
    let mut rest = text;
    'scan: while !rest.is_empty() {
        for (from, to) in table {
            // An empty needle matches everywhere and would never advance `rest`.
            if from.is_empty() {
                continue;
            }
            if let Some(tail) = rest.strip_prefix(from) {
                out.push_str(to);
                rest = tail;
                continue 'scan;
            }
        }
        let c = rest.chars().next().unwrap();
        out.push(c);
        rest = &rest[c.len_utf8()..];
    }
    out
}

/// The last `n` occurrences, which `replacen` counts from the wrong end.
/// Editing back to front is the whole trick: every offset still ahead of the
/// cursor is one you have not used yet, so none of them can go stale.
fn replace_last_n(text: &str, pat: &str, to: &str, n: usize) -> String {
    let mut out = String::from(text);
    for (at, found) in text.rmatch_indices(pat).take(n) {
        out.replace_range(at..at + found.len(), to);
    }
    out
}

fn main() {
    println!("1. A table no ordering can save");
    let s = "cat dog cat";
    println!("   {s:?}");
    println!("   cat->dog then dog->cat  {:?}", s.replace("cat", "dog").replace("dog", "cat"));
    println!("   dog->cat then cat->dog  {:?}", s.replace("dog", "cat").replace("cat", "dog"));
    println!("   Both orders collapse the two words into one. A chain cannot swap");
    println!("   anything, because the second pass reads the first pass's output.");
    println!("   substitute(...)         {:?}",
        substitute(s, &[("cat", "dog"), ("dog", "cat")]));
    println!("   One pass gets it right and does not care about the order:");
    println!("                           {:?}",
        substitute(s, &[("dog", "cat"), ("cat", "dog")]));

    println!("\n2. The last two, which replacen cannot count to");
    let log = "log a, log b, log c";
    println!("   {log:?}");
    println!("   {:<22} {:?}   <- counted from the left",
        "replacen(.., 2)", log.replacen("log", "LOGFILE", 2));
    println!("   {:<22} {:?}", "replace_last_n(.., 2)", replace_last_n(log, "log", "LOGFILE", 2));

    // The same offsets, applied front to back: each edit changes the length,
    // so every offset after it is measuring a string that no longer exists.
    let mut wrong = String::from(log);
    let mut ascending: Vec<usize> = log.rmatch_indices("log").take(2).map(|(at, _)| at).collect();
    ascending.reverse();
    for at in ascending {
        wrong.replace_range(at..at + 3, "LOGFILE");
    }
    println!("   {:<22} {:?}", "same offsets, forwards", wrong);
    println!("   The forward pass is not wrong about where the matches were. It is");
    println!("   wrong about what string it is editing after the first one lands.");

    println!("\n3. Two ways to delete, and only one is free");
    let messy = "  a b  c ";
    let mut owned = String::from(messy);
    owned.retain(|c| c != ' ');
    println!("   {:<22} {:?}   <- in place", "retain(|c| c != ' ')", owned);
    println!("   {:<22} {:?}   <- a new String", "replace(' ', \"\")", messy.replace(' ', ""));
    println!("   Same answer, different cost. Now the job retain cannot do:");
    let pairs = "abcba";
    let mut attempt = String::from(pairs);
    attempt.retain(|c| c != 'a' && c != 'b');
    println!("   {:<22} {:?}       <- every a and every b", "retain(not a, not b)", attempt);
    println!("   {:<22} {:?}     <- only the pair \"ab\"", "replace(\"ab\", \"\")", pairs.replace("ab", ""));
    println!("   retain is handed one char at a time and never sees a substring, so");
    println!("   it cannot express \"ab\". That is the line between the two families:");
    println!("   retain filters characters, replace matches patterns.");
}

Verified output of replacing_in_a_string_kata.rs — regenerated by tools/run_examples.py, never hand-typed.

1. A table no ordering can save
   "cat dog cat"
   cat->dog then dog->cat  "cat cat cat"
   dog->cat then cat->dog  "dog dog dog"
   Both orders collapse the two words into one. A chain cannot swap
   anything, because the second pass reads the first pass's output.
   substitute(...)         "dog cat dog"
   One pass gets it right and does not care about the order:
                           "dog cat dog"

2. The last two, which replacen cannot count to
   "log a, log b, log c"
   replacen(.., 2)        "LOGFILE a, LOGFILE b, log c"   <- counted from the left
   replace_last_n(.., 2)  "log a, LOGFILE b, LOGFILE c"
   same offsets, forwards "log a, LOGFILELOGFILE log c"
   The forward pass is not wrong about where the matches were. It is
   wrong about what string it is editing after the first one lands.

3. Two ways to delete, and only one is free
   retain(|c| c != ' ')   "abc"   <- in place
   replace(' ', "")       "abc"   <- a new String
   Same answer, different cost. Now the job retain cannot do:
   retain(not a, not b)   "c"       <- every a and every b
   replace("ab", "")      "cba"     <- only the pair "ab"
   retain is handed one char at a time and never sees a substring, so
   it cannot express "ab". That is the line between the two families:
   retain filters characters, replace matches patterns.

See also

  • STRINGS.md — the map: every string lesson, in reading order
  • Searching without splitting — the other half: contains, find, and the byte offset replace_range wants
  • Building a Stringpush_str and push, which is what the one-pass substitution is made of
  • String slices — why a range endpoint inside a character is a panic and not an error
  • String methods — the reference: replace_range, retain, insert_str, drain, truncate
  • str methods — the reference: replace, replacen, and the trim-matches family
  • The Book, ch. 8.2 ↗ — the paragraph that sends you here

Po polsku

Nazwa kłamie: replace niczego nie zamienia w miejscu. str nie może zmienić długości, więc metoda buduje nowy String i zwraca go, a oryginał zostaje nietknięty — kto zna ABAP-owe REPLACE ALL OCCURRENCES OF ... IN lv_text, ma tu nawyk dokładnie odwrotny do potrzebnego (tam zmienna jest modyfikowana, a wynik zgłaszany przez sy-subrc). Z Pythona nawyk przenosi się bez zmian, bo tam łańcuchy też są niezmienne. Naprawdę w miejscu działają tylko dwie metody String: replace_range, które bierze zakres bajtów (a nie wzorzec), i retain, które filtruje po znakach.

Dwie pułapki warte zapamiętania. Pierwsza: łańcuch wywołań replace to nie jest tabela podstawień — drugie wywołanie czyta wynik pierwszego, więc zamiana catdog nie uda się w żadnej kolejności, a przy escapowaniu HTML-a < przed & zamienia &lt; w &amp;lt;. Rozwiązaniem jest jeden przebieg: w każdej pozycji wygrywa pierwsza pasująca reguła, a skanowanie idzie dalej za tym, co właśnie zapisano — trzynaście linii na strip_prefix, bez żadnego crate'a. Druga: replace_range to jedyna z tych metod, która panikuje, gdy koniec zakresu wypada w środku litery — "żółw".replace_range(0..1, "z") przerywa program, bo bajt 1 siedzi w środku ż. Offset ma pochodzić z wyszukiwania albo z char_indices(), nigdy z arytmetyki na liczbie znaków.

I jedna rzecz, której w std nie ma: wyrażeń regularnych, a więc i szablonu w tekście zastępującym. Nie napiszesz $1 ani funkcji wywoływanej dla każdego dopasowania — to crate regex. Zamiennik jest zwykły &str i nic więcej.

Szukaj po polsku: zamiana tekstu w łańcuchu · rust String replace_range · rust retain · tabela podstawień w jednym przebiegu · escapowanie HTML kolejność reguł · rust regex crate