StringBuilder.Replace in C#: Replace Text With Less Allocation
Use StringBuilder.Replace in C# to swap substrings in place and cut allocations versus repeated string.Replace, with a real BenchmarkDotNet comparison.
Call StringBuilder.Replace(oldValue, newValue) to swap every occurrence of a substring inside the builder’s buffer, instead of creating a new string for each string.Replace. Load your text into new StringBuilder(text), chain your Replace calls, and call ToString() once at the end. In my benchmark below, that allocated about 20% of what repeated string.Replace did.
Updated November 2026. These APIs are long-standing and apply to modern .NET.
Have you ever had to clean up your users’ input, only to realize the pain and aggravation it can lead to with all the unnecessary memory overhead? I recently had an “aha!” moment and thought I would share this tip with you.
This article will be short, but I’ll try to give you some context as we go along. If you care about allocations more broadly, I also wrote about hidden closure allocations in ConcurrentDictionary. Let’s get started.
Why does repeated string.Replace allocate so much?
I recently looked at a sample calculator project from MAUI developer Naweed Akram. He has some fantastic MAUI samples, and his Scientific Calculator is impressive. Check it out, seriously. He uses the NCalc library to parse string expressions and evaluate the result. It’s a very cool library but has a caveat. Functions are case-sensitive, so user input must be constrained or normalized. Naweed chose the normalization path, and his implementation is similar to the approach I would have taken myself. Let’s take a look at it.
private string NormalizeInputString(){ Dictionary<string, string> _opMapper = new() { {"×", "*"}, {"÷", "/"}, {"SIN", "Sin"}, {"COS", "Cos"}, {"TAN", "Tan"}, {"ASIN", "Asin"}, {"ACOS", "Acos"}, {"ATAN", "Atan"}, {"LOG", "Log"}, {"EXP", "Exp"}, {"LOG10", "Log10"}, {"POW", "Pow"}, {"SQRT", "Sqrt"}, {"ABS", "Abs"}, };
var retString = InputText; foreach (var key in _opMapper.Keys) { retString = retString.Replace(key, _opMapper[key]); }
return retString;}The method uses a Dictionary of keys and values to force specific casing to match what NCalc expects from the user. While functional, there is room to optimize performance.
In the previous code, the method call to Replace creates a new string in memory for each replacement. So, in a worst-case scenario, we could have 15 new strings allocated as we clean the user’s equation, one for each key and the original value. Yikes!
What should we do?!
How do you replace text with StringBuilder?
We commonly think of StringBuilder as a class to build up new string values, but it didn’t occur to me that StringBuilder could also be used to alter an existing string efficiently. So, let’s update the code sample.
private string NormalizeInputString(){ Dictionary<string, string> _opMapper = new() { {"×", "*"}, {"÷", "/"}, {"SIN", "Sin"}, {"COS", "Cos"}, {"TAN", "Tan"}, {"ASIN", "Asin"}, {"ACOS", "Acos"}, {"ATAN", "Atan"}, {"LOG", "Log"}, {"EXP", "Exp"}, {"LOG10", "Log10"}, {"POW", "Pow"}, {"SQRT", "Sqrt"}, {"ABS", "Abs"}, };
var retString = new StringBuilder(InputText); foreach (var key in _opMapper.Keys) { retString.Replace(key, _opMapper[key]); }
return retString.ToString();}Now we only generate two strings: the original input and the final result. You might be asking, “how much of a difference can this make, really?”. Well, every optimization is contextual and will depend on your use case. But, we can reduce some unnecessary memory pressure for a relatively simple change.
Is StringBuilder.Replace faster than string.Replace?
Let’s use Benchmarkdotnet to compare the previous implementation and the StringBuilder approach.
using System.Text;using BenchmarkDotNet.Attributes;using BenchmarkDotNet.Running;
BenchmarkRunner.Run<StringReplace>();
[MemoryDiagnoser][ShortRunJob]public class StringReplace{ readonly Dictionary<string, string> map = new() { { "×", "*" }, { "÷", "/" }, { "SIN", "Sin" }, { "COS", "Cos" }, { "TAN", "Tan" }, { "ASIN", "Asin" }, { "ACOS", "Acos" }, { "ATAN", "Atan" }, { "LOG", "Log" }, { "EXP", "Exp" }, { "LOG10", "Log10" }, { "POW", "Pow" }, { "SQRT", "Sqrt" }, { "ABS", "Abs" }, };
private const string target = "1 x 1 ÷ 1 * SIN(COS(TAN(LOG(ASIN(ATAN(EXP(POW(SQRT(ABS(1)))))))))) |" + "1 x 1 ÷ 1 * SIN(COS(TAN(LOG(ASIN(ATAN(EXP(POW(SQRT(ABS(1)))))))))) |" + "1 x 1 ÷ 1 * SIN(COS(TAN(LOG(ASIN(ATAN(EXP(POW(SQRT(ABS(1)))))))))) |" + "1 x 1 ÷ 1 * SIN(COS(TAN(LOG(ASIN(ATAN(EXP(POW(SQRT(ABS(1)))))))))) |" + "1 x 1 ÷ 1 * SIN(COS(TAN(LOG(ASIN(ATAN(EXP(POW(SQRT(ABS(1)))))))))) |" + "1 x 1 ÷ 1 * SIN(COS(TAN(LOG(ASIN(ATAN(EXP(POW(SQRT(ABS(1)))))))))) |" + "1 x 1 ÷ 1 * SIN(COS(TAN(LOG(ASIN(ATAN(EXP(POW(SQRT(ABS(1)))))))))) |" + "1 x 1 ÷ 1 * SIN(COS(TAN(LOG(ASIN(ATAN(EXP(POW(SQRT(ABS(1)))))))))) |" + "1 x 1 ÷ 1 * SIN(COS(TAN(LOG(ASIN(ATAN(EXP(POW(SQRT(ABS(1)))))))))) |";
[Benchmark] public string String() { var result = target; foreach (var key in map.Keys) { result = result.Replace(key, map[key]); }
return result; }
[Benchmark] public string StringBuilder() { var result = new StringBuilder(target); foreach (var key in map.Keys) { result.Replace(key, map[key]); }
return result.ToString(); }}Let’s see the results of the Benchmarkdotnet run.
| Method | Mean | Error | StdDev | Gen0 | Allocated ||-------------- |---------:|---------:|----------:|-------:|----------:|| String | 3.967 us | 1.673 us | 0.0917 us | 1.8082 | 11.11 KB || StringBuilder | 3.480 us | 2.472 us | 0.1355 us | 0.4082 | 2.52 KB |Wow! The StringBuilder implementation allocates about 20% of what the String implementation does. That’s a pretty significant performance optimization for code that looks almost identical. These increases can become more effective as the target string grows or the dictionary of keys/values expands.
StringBuilder.Replace FAQ
What does StringBuilder.Replace do?
StringBuilder.Replace(oldValue, newValue) replaces every occurrence of oldValue in the builder with newValue. It edits the builder’s internal buffer rather than creating a new string for each replacement.
Is StringBuilder.Replace faster than string.Replace?
The real win is allocations, not raw speed. In the benchmark above it allocated about 20% of what the string.Replace version did (2.52 KB versus 11.11 KB); the mean times were close enough that the run-to-run error overlaps, so treat them as roughly equivalent on speed. Results are contextual: they depend on string size, the number of replacements, and your runtime, so measure your own workload. The payoff is reduced Gen0 pressure and fewer GC pauses under load.
Does StringBuilder.Replace modify the StringBuilder in place?
Yes. It changes the existing StringBuilder and returns the same instance, so you don’t get a new object back. You only pay for a new string when you call ToString().
Can I chain multiple Replace calls on a StringBuilder?
Yes. Because Replace returns the same StringBuilder, you can write sb.Replace("SIN", "Sin").Replace("COS", "Cos"), or loop over a dictionary like the example above.
Conclusion
While you may think of StringBuilder as a means to build strings over several iterations, you can also think of StringBuilder as an alteration tool. Utilizing the Replace method could reduce memory overhead while keeping the code almost the same. Given the benchmark comparison, you can also see reduced Gen0 sizes, which should minimize garbage collection pauses over time. It’s a win-win.
I hope you enjoyed this blog post, and please feel free to share it with colleagues and friends. As always, thanks for reading.
Around the web
Comments
Loading comments from GitHub Discussions…