In Go, when does strings.NewReplacer behave differently from chained strings.ReplaceAll calls?
answer
- how many passes over the text
- does the output get read again
- chained calls can cascade
- one traversal, earliest match wins
- build the replacer once, share it
basics
~20 sA strings.Replacer scans the input once, so text it has just written is never re-examined. Chained strings.ReplaceAll calls run in sequence, so a later call can rewrite what an earlier one produced — replacements cascade.
solid answer
~40 s`strings.ReplaceAll(s, old, new)` makes one full pass per call, so chaining two of them is two passes and the second sees the first one's output. Replace `a` with `b` and then `b` with `c`, and every original `a` ends up as `c`. `strings.NewReplacer("a", "b", "b", "c")` builds a single replacer from pairs and `Replace` walks the input once: at each position it takes the match that starts earliest, ties broken by argument order, copies the replacement to the output and moves past it, never re-scanning what it wrote. So `a` becomes `b` and stays `b`. A `*strings.Replacer` is also documented as safe for concurrent use and does its matcher construction in `NewReplacer`, so build it once at package level rather than inside the loop.
code
go · 10 liness := "ab"
// two passes: the 'b' written by the first call is rewritten by the second
fmt.Println(strings.ReplaceAll(strings.ReplaceAll(s, "a", "b"), "b", "c"))
// cc
// one pass: 'a' becomes 'b' and is never re-examined
r := strings.NewReplacer("a", "b", "b", "c")
fmt.Println(r.Replace(s))
// bcgo deeper
Know that ReplaceAll is Replace with a count of -1, that matches are non-overlapping and found left to right, and that these functions return a new string rather than editing the original.
Explain the single-pass property of a Replacer against the pass-per-call behaviour of chaining, and show the escaping or swap example where chaining cannot be made correct by reordering.
Talk about lifetime and cost: hoist the replacer to package level, rely on its documented concurrency safety instead of a lock, and stream through WriteString when the output is headed for a writer anyway.
Frame it as an interface decision for shared normalisation code: expose one configured replacer that callers apply, rather than a sequence of exported replace helpers that every team composes in its own order and gets subtly different results from.
## The two APIs `strings.Replace(s, old, new string, n int) string` returns a copy of `s` with the first `n` non-overlapping instances of `old` replaced by `new`. The count argument is the part people forget: - `n < 0` means no limit — every occurrence. - `n == 0` means no replacements at all; you get `s` back. - `n > 0` means at most that many, taken from the left. - If `old` is the empty string, it matches at the beginning of the string and after each UTF-8 sequence, so a k-rune string yields up to k+1 replacements. `strings.Replace("banana", "", "-", -1)` is `"-b-a-n-a-n-a-"`. `strings.ReplaceAll(s, old, new)` is exactly `Replace` with `n = -1`; it exists so the common case does not need a magic number. Matches are **non-overlapping** and found left to right, which is why `strings.Replace("aaaa", "aa", "b", -1)` is `"bb"` and not `"b"` or `"bab"`. `strings.NewReplacer(oldnew ...string) *strings.Replacer` takes an even number of arguments read as old, new, old, new… and panics if given an odd count. The returned value has `Replace(s string) string` and `WriteString(w io.Writer, s string) (n int, err error)`. ## Single pass versus cascade — the semantic difference The important property of a `Replacer` is that replacement happens **in one traversal of the input**. At each position it finds the earliest-starting match among all the `old` strings, with ties broken by the order the pairs were given to `NewReplacer`, emits the corresponding `new` text into the output, and continues *after* the matched input. The emitted text is output, never input again. Chained `ReplaceAll` calls have the opposite property. Each call is an independent pass over the whole string, and pass two sees pass one's output as ordinary text. The canonical case where this matters is any transformation that is really a *mapping* rather than a *sequence of edits* — escaping being the obvious one. To escape `&` as `&` and `<` as `<`, chaining fails immediately: escape `<` first and then `&`, and the ampersand you just wrote gets double-escaped into `&lt;`. Order the calls the other way and you have merely moved the bug. A single `strings.NewReplacer("&", "&", "<", "<")` is correct regardless of order, because its output is never re-read. The same reasoning applies to a swap: turning every `a` into `b` and every `b` into `a` is impossible with chained calls and trivial with one replacer. ## Cost, reuse and concurrency `NewReplacer` does real work: depending on the pairs it builds a specialised matcher — a byte lookup table when every old and new value is a single byte, a trie in the general case. That construction is per-`NewReplacer`, not per-`Replace`. Building the replacer inside the loop that processes each document throws that work away every iteration; hoisting it to a package-level `var` costs one construction for the process lifetime. That hoisting is safe because a `*strings.Replacer` is documented as safe for concurrent use by multiple goroutines, so several document workers can share one without a mutex. The replacer holds no per-call state. `Replacer.WriteString(w, s)` is the streaming form: it writes the transformed text straight to an `io.Writer` — a response, a file, a hash — instead of materialising an intermediate string. In a normaliser that is going to write the result out anyway, that removes one full copy of every document. ## The neighbouring tool: strings.Map When the transformation is per-rune rather than per-substring, `strings.Map(mapping func(rune) rune, s string) string` is the right primitive. It applies the mapping to every rune, and — the part worth remembering — **if the mapping returns a negative value the rune is dropped from the result with no replacement**. That makes "delete every character outside an allowed set" a three-line function with no regular expression and no intermediate slice: ```go keep := func(r rune) rune { if r == '-' || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { return r } return -1 } ``` And like the trim family, all of this has a `bytes` mirror (`bytes.Replace`, `bytes.ReplaceAll`, `bytes.NewReplacer`, `bytes.Map`) for input that arrives as `[]byte`. ## Choosing One fixed substring, once: `ReplaceAll`. A fixed set of literal substitutions applied together: build a package-level `Replacer`. A per-rune rule or a deletion filter: `strings.Map`. Only when the pattern is not a literal at all does a regular expression from the `regexp` package earn its cost — it is far slower than a replacer for a known set of literals.
- What does strings.Replace do when the old string is empty?It matches at the beginning of the string and after each UTF-8 sequence, so a k-rune string gets up to k+1 replacements: `strings.Replace("banana", "", "-", -1)` returns `"-b-a-n-a-n-a-"`. It is a documented behaviour rather than a no-op, and it is a nasty surprise when the `old` value comes from configuration that happened to be blank.
- Is a *strings.Replacer safe to share across goroutines?Yes — it is documented as safe for concurrent use by multiple goroutines, and it carries no per-call state. All the setup cost is in `NewReplacer`, so the intended shape is a package-level `var` built once and used by every worker. Constructing one per document rebuilds its internal matcher for nothing.
- How do you avoid materialising the rewritten document as a string at all?Use `Replacer.WriteString(w io.Writer, s string)`, which streams the transformed text straight into any writer — a file, a response, a hash. In a pipeline that is going to write the result out anyway, that removes a whole extra copy of every document from the heap.
Chained replacements are like proofreading the page again after each correction; a replacer is like copying the page out once, substituting as you go.
saying these in an interview costs you the question
- Expects chained ReplaceAll calls not to see each other's output
- Thinks ReplaceAll matches overlapping occurrences
- Believes strings.Replace with n of 0 replaces everything
- Calls strings.NewReplacer inside the per-document loop
- Guards a shared *strings.Replacer with a mutex it does not need
- Reaches for a regular expression to swap two fixed literals