I had a parser for a fixed-width settlement feed. Each line is 28 characters, no delimiters: an id, an amount, a quantity, all at known offsets. I needed three typed values out of every line and nothing else. So I wrote the obvious thing with Substring, it worked, and I moved on. Weeks later I was reading an allocation profile for an unrelated leak and this loop was sitting near the top, quietly handing the garbage collector 65 MB of strings I never kept.
Here's the loop, cleaned up:
foreach (var line in feed) { idSum += int.Parse(line.Substring(0, 10)); amtSum += decimal.Parse(line.Substring(10, 12), inv); qtySum += int.Parse(line.Substring(22, 6)); } Looks harmless. But Substring doesn't hand you a view into the original string, it cuts a brand-new one. Three calls per line means three fresh strings per record, each one alive just long enough to be parsed and then dropped. Over 500,000 lines that's 1.5 million little allocations, and the only thing I wanted out of the whole exercise was three running totals.
I measured it with GC.GetAllocatedBytesForCurrentThread() around the loop rather than a timer. Allocation counts are deterministic, so the number doesn't wobble between runs the way milliseconds do:
Substring + Parse(string): 64.8 MB gen0=0 (checksum 624999750000) AsSpan + Parse(span) : 0.0 MB gen0=0 (checksum 624999750000) 64.8 MB to produce three longs and a decimal. And notice the gen0=0 next to it. The collector hadn't even fired during my measured window, because this box has a roomy ephemeral budget and 65 MB didn't fill it. That's the part I want to be honest about: this isn't a GC-pauses horror story. The garbage is real, it just hadn't been collected yet. In a service parsing feeds all day long, that 65 MB-per-pass is exactly the kind of steady drip that turns into gen0 collections you do feel.
The fix is one method away
Every primitive parser in .NET has had a ReadOnlySpan<char> overload for years. AsSpan gives you a window over the characters already sitting in the original string, no copy. Feed that window straight to Parse:
foreach (var line in feed) { idSum += int.Parse(line.AsSpan(0, 10)); amtSum += decimal.Parse(line.AsSpan(10, 12), NumberStyles.Number, inv); qtySum += int.Parse(line.AsSpan(22, 6)); } Same offsets, same results, and the checksum is identical: 624999750000 both ways. The difference is that nothing gets allocated. The span is a struct that lives on the stack, and it points into the string you already have.
One wrinkle worth mentioning: the int.Parse span overload is a clean drop-in, but decimal.Parse(ReadOnlySpan<char>) doesn't have the two-argument shape the string version does, so I passed NumberStyles.Number explicitly alongside the culture. Small thing, but if you copy-paste blindly the compiler will complain.
Does it actually run faster?
The allocation win is the real story, but people always ask about wall-clock, so here's best-of-7 with a warmup first:
Substring + Parse(string): 44.2 ms (best of 7) AsSpan + Parse(span) : 33.9 ms (best of 7) About 10 ms faster, call it 1.3x. I'd trust the ratio more than the absolute numbers here — this is a container, not a lab, and the substring run wandered between 44 and 54 ms across attempts while the span run sat steady around 34. The allocation figure, meanwhile, was 64.8 MB every single time. That stability is the whole reason I measure allocations before I measure time.
When I wouldn't bother
If you're going to keep the substring anyway (stash it in a dictionary, log it, return it in a DTO), then you're allocating that string no matter what, and AsSpan saves you nothing. The span trick only pays when you parse a slice and immediately throw it away. That's the exact shape of this loop, which is why it worked so well, but it's not every loop.
And if you're parsing a few dozen lines on a button click, none of this matters. Sixty-five megabytes sounds scary until you remember it's spread across half a million iterations; per line it's 136 bytes, and for a handful of lines you'll never see it. This is a hot-path optimization, full stop. Reaching for it in a config loader that runs once at startup is just making the code harder to read for a win nobody will ever measure.
My actual opinion, though: once a method hands you a ReadOnlySpan<char> overload, calling Substring just to feed it is a reflex worth unlearning. You're allocating a string whose entire job is to be read once and die. The span overload is the same line of code minus the garbage.
If you want to poke at it, here's the core of it. Drop this in a console app targeting net10.0 and run it with dotnet run -c Release:
using System.Diagnostics; using System.Globalization; var inv = CultureInfo.InvariantCulture; const int LineCount = 500_000; var lines = BuildFeed(LineCount); _ = ParseWithSubstring(lines); // warm up both paths + the JIT _ = ParseWithSpan(lines); MeasureAlloc("Substring + Parse(string)", () => ParseWithSubstring(lines)); MeasureAlloc("AsSpan + Parse(span) ", () => ParseWithSpan(lines)); (long, decimal, long) ParseWithSubstring(string[] feed) { long idSum = 0, qtySum = 0; decimal amtSum = 0m; foreach (var line in feed) { idSum += int.Parse(line.Substring(0, 10)); amtSum += decimal.Parse(line.Substring(10, 12), inv); qtySum += int.Parse(line.Substring(22, 6)); } return (idSum, amtSum, qtySum); } (long, decimal, long) ParseWithSpan(string[] feed) { long idSum = 0, qtySum = 0; decimal amtSum = 0m; foreach (var line in feed) { idSum += int.Parse(line.AsSpan(0, 10)); amtSum += decimal.Parse(line.AsSpan(10, 12), NumberStyles.Number, inv); qtySum += int.Parse(line.AsSpan(22, 6)); } return (idSum, amtSum, qtySum); } static void MeasureAlloc(string label, Func<(long, decimal, long)> run) { GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); long before = GC.GetAllocatedBytesForCurrentThread(); int g0 = GC.CollectionCount(0); var r = run(); long bytes = GC.GetAllocatedBytesForCurrentThread() - before; int gen0 = GC.CollectionCount(0) - g0; Console.WriteLine($"{label}: {bytes / 1024.0 / 1024.0,6:F1} MB gen0={gen0} (checksum {r.Item1})"); } static string[] BuildFeed(int count) { var rng = new Random(1234); var feed = new string[count]; for (int i = 0; i < count; i++) { string id = (1_000_000 + i).ToString("D10", CultureInfo.InvariantCulture); string amt = (rng.Next(1, 9_999_999) / 100m) .ToString("00000000.00", CultureInfo.InvariantCulture).PadLeft(12, '0'); string qty = rng.Next(1, 500).ToString("D6", CultureInfo.InvariantCulture); feed[i] = id + amt + qty; // 28 chars } return feed; } Have you found one of these in your own code, a parse loop allocating strings it reads once and tosses? I keep stumbling on them in file importers. Drop one in the comments if you've got a good one.
— still counting allocations nobody asked me to count