I had a nightly job that reads a partner CSV and sums the amount column, just to sanity-check a total before the real import runs. Boring code. row.Split(','), grab field one, parse it, add it up. It worked the day I wrote it and I forgot about it.

Then the job's memory graph grew a sawtooth I didn't like, and I opened a profiler expecting to find the database client or the file reader. Nope. The garbage collector was working full time on nothing but comma splitting. For a loop that reads one column out of four.

string.Split is generous with memory

Here's what I'd stopped seeing. string.Split(',') doesn't just find the commas. It allocates a string[] to hold the pieces, and then a brand-new string for every field it carved out. Four columns means five allocations per row before I've parsed a single number. And I was throwing away three of those strings immediately, because I only wanted field one.

Multiply that by a couple million rows and the GC has plenty to do.

The newer Span<char>.Split overloads take a different deal. You hand them a Span<Range> to fill, they write the boundaries of each field into it, and nothing gets copied. You slice the original string yourself when you actually need a piece. double.Parse happily takes a ReadOnlySpan<char>, so I never have to materialize the substring at all.

static double SumWithSpanSplit(string[] rows, CultureInfo culture) { double total = 0; Span<Range> fields = stackalloc Range[8]; // 4 columns, room to spare foreach (string row in rows) { ReadOnlySpan<char> line = row; int count = line.Split(fields, ','); total += double.Parse(line[fields[1]], culture); } return total; } 

The stackalloc Range[8] lives on the stack and gets reused on every iteration, so the per-row cost is zero. Compare that to the version I actually shipped first:

static double SumWithStringSplit(string[] rows, CultureInfo culture) { double total = 0; foreach (string row in rows) { string[] fields = row.Split(','); total += double.Parse(fields[1], culture); } return total; } 

I counted it

I don't trust my gut on allocations, so I built two million CSV-ish rows in memory (id,amount,region,active), then summed the amount column both ways. A stopwatch, a warmup call per path, best time out of seven, and the allocation counter read around each pass. This is a container, not a lab bench, so I only care about the ratio.

Rows: 2,000,000 Runtime: .NET 10.0.14 string.Split 231.8 ms 383,039,160 bytes sum=9960642900.00 span Split 122.5 ms 40 bytes sum=9960642900.00 time: span is 1.9x faster alloc: span allocated 40 bytes sums match: True 

365 megabytes versus 40 bytes. The 40 bytes is the harness breathing, not the loop. And the loop got about 1.8x faster across repeated runs, which is the part I didn't expect, I went in thinking I'd just save memory. Turns out all that allocating and collecting isn't free on the clock either.

The sums matching is the line I care about most. A faster parser that quietly reads the wrong column is worse than a slow correct one.

When I'd leave string.Split alone

Most of the time, honestly. If I'm reading a 30-line config file once at startup, string.Split is clearer and the allocations vanish into the noise. I'm not going to stack-allocate a range buffer to save microseconds on something that runs once. The span version is also fussier: you cap the number of fields at the size of your Span<Range>, and if a row has more commas than you planned for, the extra columns fold into the last range instead of throwing. For my fixed-shape partner feed that's fine. For arbitrary user input I'd check the returned count before trusting fields[1].

My rule of thumb: reach for span Split when the same parse runs in a hot loop over a lot of rows, and leave the friendly version everywhere else. The readability cost is real, so I want the allocation win to be real too.

The rest of the harness, if you want to run it

I usually link these out to a repo, but the push to my samples repo was blocked on this run, so here's the whole thing in one place. Drop it in a net10.0 console project (<TargetFramework>net10.0</TargetFramework>) and dotnet run -c Release. The two Sum... methods above go right below this.

using System.Diagnostics; using System.Globalization; const int RowCount = 2_000_000; var culture = CultureInfo.InvariantCulture; // Build the rows once: id,amount,region,active e.g. "1048576,37.52,emea,1" string[] rows = new string[RowCount]; for (int i = 0; i < RowCount; i++) { double amount = (i % 9973) + (i % 100) / 100.0; string region = (i % 4) switch { 0 => "emea", 1 => "apac", 2 => "amer", _ => "latam" }; rows[i] = string.Create(culture, $"{i},{amount:0.00},{region},{i & 1}"); } static (double ms, long bytes, double result) Measure(Func<double> pass) { double result = pass(); // warm up the JIT and the code paths double best = double.MaxValue; long bytesBest = 0; for (int run = 0; run < 7; run++) { long before = GC.GetTotalAllocatedBytes(precise: true); var sw = Stopwatch.StartNew(); result = pass(); sw.Stop(); long after = GC.GetTotalAllocatedBytes(precise: true); if (sw.Elapsed.TotalMilliseconds < best) { best = sw.Elapsed.TotalMilliseconds; bytesBest = after - before; } } return (best, bytesBest, result); } var a = Measure(() => SumWithStringSplit(rows, culture)); var b = Measure(() => SumWithSpanSplit(rows, culture)); Console.WriteLine($"string.Split {a.ms,8:0.0} ms {a.bytes,14:N0} bytes sum={a.result:0.00}"); Console.WriteLine($"span Split {b.ms,8:0.0} ms {b.bytes,14:N0} bytes sum={b.result:0.00}"); 

If you've got a parsing loop that shows up on your allocation graph, this is a cheap swap to try. What's the worst allocation hot spot you've found hiding behind a one-line convenience method?

Catch you in the next one — still putting stopwatches on code nobody asked me to time.