Zero-Allocation CSV Parsing with .NET Span<T>
Replace string.Split with Span<T> slicing to parse CSV lines without allocating per-field strings. Includes a working parser, benchmark setup, and the ref-struct constraints you must respect.
08 Feb 2026, 06:12 UTC

The allocation problem in everyday parsing
Every time you call string.Split(',') on a CSV line, the runtime allocates a new string array plus a separate string object for each field. In a hot path processing millions of rows, that pressure shows up as Gen 2 collections and measurable latency. Span<T> lets you slice the original buffer without copying, so the parser produces field views instead of new strings.
What makes Span different
Span<T> is a ref struct — a stack-only type that holds a pointer and a length. Because it cannot be boxed, stored in a heap field, or captured across an await, the compiler guarantees it never escapes the current stack frame. That restriction is exactly what enables zero-copy slicing: span.Slice(start, length) returns a new Span<T> pointing into the same memory, with no allocation.
You can create a span from a managed array, a stackalloc buffer, or a native pointer via MemoryMarshal.CreateSpan. The underlying memory must stay alive for the span's lifetime; using a span after its backing store is freed (for example, a stackalloc buffer after the method returns) is undefined behavior.
Worked example: parsing a CSV line
The following method takes a ReadOnlySpan<char> representing one line and yields each field as a ReadOnlySpan<char> without allocating:
public static IEnumerable<ReadOnlySpan<char>> ParseCsvLine(ReadOnlySpan<char> line)
{
int start = 0;
for (int i = 0; i < line.Length; i++)
{
if (line[i] == ',')
{
yield return line.Slice(start, i - start);
start = i + 1;
}
}
yield return line.Slice(start); // last field
}
Calling code can iterate the fields and process them directly — for example, int.Parse(field) works on a ReadOnlySpan<char> in .NET 7+. If you need a string for an API that doesn't accept spans, you allocate once at the boundary: field.ToString().
Trade-offs and limitations
- No async/await: Because
Span<T>is aref struct, it cannot be used inasyncmethods or iterator blocks that compile to state machines. The example above uses a synchronous iterator; for async pipelines you'd copy to aMemory<T>orstringat the boundary. - Lifetime coupling: The span is only valid while its source buffer lives. Parsing a line read from a
StreamReaderinto a pooledchar[]works if you process the line before returning the buffer to the pool. - No LINQ: Most LINQ operators expect
IEnumerable<T>and will box the spans. Write explicit loops or useSpan-aware libraries.
Verifying the allocation difference
Create a console app targeting .NET 6 or later. Benchmark the span parser against string.Split with BenchmarkDotNet:
[MemoryDiagnoser]
public class CsvBenchmarks
{
private readonly string _line = "field1,field2,field3,field4,field5";
[Benchmark(Baseline = true)]
public void StringSplit()
{
foreach (var f in _line.Split(',')) { /* use f */ }
}
[Benchmark]
public void SpanParse()
{
foreach (var f in ParseCsvLine(_line.AsSpan())) { /* use f */ }
}
}
The span version should report zero allocations in the Allocated column, while string.Split shows an array plus five strings per iteration.
When to reach for Span
Use Span<T> in synchronous, allocation-sensitive paths: parsing, formatting, buffer manipulation, or interop with native code. Keep the scope tight — parse a line, transform it, and move on. If the data must cross an await or be stored, materialize a string or Memory<T> at that point. The discipline pays off in predictable latency and simpler GC profiles.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.