I had an exporter writing CSV lines, one per row, millions of rows a night. Nothing clever in it: a string.Format call with a template and three values. It was slower than it should've been, so I put a profiler on it, and the call that lit up wasn't the file write or the decimal math. It was the formatting. Specifically, it was the part of string.Format that reads the template "{0},{1:yyyy-MM-dd HH:mm:ss},{2:F2}" and figures out where the holes are. That work was happening on every single call, and my template never changed.
The template gets reparsed every time
When you call string.Format(format, args), the runtime has to walk that format string, find the {0}, {1:...}, {2:...} placeholders, pull out the alignment and the format specifiers, and only then start stitching the output together. That parsing step is pure overhead if the template is a constant, which it almost always is. You wrote the template once in your source code. The runtime rediscovers its structure five million times.
CompositeFormat is the fix, and it's about as simple as it gets. You parse the template once into a CompositeFormat object, then hand that object to string.Format instead of the raw string. The holes are already located. The call just fills them.
const string Template = "{0},{1:yyyy-MM-dd HH:mm:ss},{2:F2}"; // Parsed once, up front. var parsed = CompositeFormat.Parse(Template); // Later, in the hot loop: string a = string.Format(ci, Template, id, when, amount); // reparses Template string b = string.Format(ci, parsed, id, when, amount); // reuses the parse So I counted. I formatted the same CSV line five million times through each path, best of five rounds, summing the line lengths so the compiler couldn't throw the work away, and tracking allocations with GC.GetAllocatedBytesForCurrentThread.
const int Iterations = 5_000_000; var when = new DateTime(2026, 11, 7, 8, 11, 0, DateTimeKind.Utc); const decimal amount = 1234.56m; var ci = CultureInfo.InvariantCulture; var parsed = CompositeFormat.Parse("{0},{1:yyyy-MM-dd HH:mm:ss},{2:F2}"); long sinkA = 0; for (int i = 0; i < Iterations; i++) sinkA += string.Format(ci, "{0},{1:yyyy-MM-dd HH:mm:ss},{2:F2}", i, when, amount).Length; long sinkB = 0; for (int i = 0; i < Iterations; i++) sinkB += string.Format(ci, parsed, i, when, amount).Length; Here's what came back:
Runtime: .NET 10.0.14 5,000,000 formats, best of 5 string.Format(string) 837.9 ms 167.6 ns/call 838.5 MB string.Format(CompositeFormat) 517.7 ms 103.5 ns/call 457.0 MB The time ratio bounced between about 1.4x and 1.6x depending on the round, which is honest for a microbenchmark running in a container I don't fully control. I care about the ratio, not the absolute milliseconds. But the allocation number didn't bounce at all: string.Format(string) churned 838.5 MB, the CompositeFormat path 457.0 MB. That's a steady 381 MB of garbage I was handing the GC for no reason other than relocating the same three braces over and over. Both paths produced byte-identical output, which is the whole point. Same answer, less work.
The allocation gap is the part worth sitting with. The reparsing doesn't just cost CPU; it allocates on the way through, and that allocation is what eventually turns into GC pauses in a service that's doing this constantly. Cutting it nearly in half for a one-line change is a good trade.
When this is a waste of time
Here's the caveat, and it's a real one: CompositeFormat only pays off when you reuse the same template a lot. If you're formatting a string once, or a handful of times, parsing it into a CompositeFormat first is strictly more work than just calling string.Format with the literal. You've added an allocation and a line of code to save nothing. This is a hot-path tool. A log line formatted per-request, a CSV row per record, a template rendered in a tight loop — those are where it earns its keep. The welcome email you send once? Leave it alone.
My honest opinion: for most code, interpolated strings ($"{id},{when:yyyy-MM-dd}") are already the right default, and the compiler handles them well. CompositeFormat isn't competing with those. It's for the specific case where your template lives in a variable or a resource file, gets reused heavily, and you were reaching for string.Format anyway. In that narrow spot it's close to free to adopt. The one gotcha: CompositeFormat.Parse validates the template up front, so a malformed template throws at parse time instead of at the first format call. I'd call that a feature.
The thing I like about it is how little it asks. You're not rewriting the formatting, not switching APIs, not learning a new builder. You pull the parse out of the loop. One line moves up, and a few hundred megabytes of garbage stop existing. That's the kind of change I'll take every time.
Have you found a spot in your own code where the template was constant but you were paying to rediscover it? I'd like to hear where it bit you.
— still benchmarking the things nobody asked me to benchmark