Span<T>, Memory<T>, and stackalloc: when zero-allocation C# is worth it
There's a phase every .NET developer goes through after discovering Span<T>. Suddenly every
substring is a betrayal, every string.Split is an outrage, and you find yourself rewriting a
config parser that runs twice at startup to be zero-allocation. The instinct is good. The judgment
is not yet there.
Span<T>, ReadOnlySpan<T>, Memory<T>, and stackalloc are some of the sharpest tools in the
language. They let you look at a slice of memory (a chunk of an array, part of a string, a buffer on
the stack) without copying it or allocating anything new. Used in the right place, they turn a hot
path that churned the GC into one that allocates nothing at all. Used everywhere, they make code
harder to read and write for no measurable gain. The skill isn't knowing the API. It's knowing where
the lesson actually applies.
The allocation you didn't know you were making
Start with the everyday version. You're parsing a line of CSV-ish data, summing three numbers:
public int SumFields(string line)
{
var parts = line.Split(',');
return int.Parse(parts[0]) + int.Parse(parts[1]) + int.Parse(parts[2]);
}
Correct, obvious, fine for code that runs occasionally. But look at what it allocates: Split
allocates a string[], plus a new string for every field. Three fields, four allocations, every
call. Run this over a million-line file and you've allocated four million objects to read some
integers you immediately throw away.
int.Parse(parts[0]) is the tell. You allocated a substring purely so you could hand it to a parser
that doesn't need a string at all.
string.Split, then Parse
a fresh copy per field
allocated across the file: ~190 MB
Span slice
a window over the same buffer: offset + length
allocated across the file: 0 B
The zero-allocation version
int.Parse (and most parsing APIs in modern .NET) has an overload that accepts a ReadOnlySpan<char>.
So you can slice the original string in place, never materializing the substrings:
public int SumFields(ReadOnlySpan<char> line)
{
var total = 0;
var start = 0;
while (start < line.Length)
{
var comma = line[start..].IndexOf(',');
var end = comma < 0 ? line.Length : start + comma;
total += int.Parse(line[start..end]);
start = end + 1;
if (comma < 0)
{
break;
}
}
return total;
}
line[start..end] is a slice, not a copy: it's a ReadOnlySpan<char> that points into the same
underlying characters, with an offset and a length. No substring is allocated. int.Parse reads
straight out of that window. Across the whole method, on the whole file, this allocates nothing on the
heap. Call it with SumFields(myString.AsSpan()) and a string flows in for free, because AsSpan
is also just a view.
For a buffer you control, stackalloc takes it further: you can grab scratch space on the stack
instead of the heap.
Span<char> buffer = stackalloc char[32];
value.TryFormat(buffer, out var written);
// use buffer[..written], zero heap allocation
The contrast on a million-line parse, illustratively:
| Version | Allocated (roughly) | Time (illustrative) |
|---|---|---|
Split + int.Parse |
~190 MB | ~140 ms |
ReadOnlySpan<char> slicing |
~0 B | ~45 ms |
Zero bytes, and faster too, because the GC never has to run at all.
Be honest about the limits
Now the part the tutorials skip. Span<T> is a ref struct, and that comes with real constraints
that exist for a good reason: a span can point at stack memory, so the runtime won't let it escape to
anywhere that might outlive the stack frame.
- You can't use a
Span<T>across anawait. No spans living through async boundaries. If your hot path is async, this whole approach often doesn't apply, and that's fine. - You can't put one in a field of a regular class, capture it in a lambda, use it in an iterator
(
yield), or box it. It lives on the stack and stays there. stackallocis for small, bounded sizes. Stack space is limited;stackalloc char[10_000_000]is a stack overflow, not an optimization. Use it for tens or low hundreds of elements, and fall back to the heap (orArrayPool<T>) above that.
Memory<T> exists precisely for the cases where Span<T> can't go: it's a heap-safe handle you can
store in a field and carry across await, and you call .Span to get a span only at the moment you
actually touch the data, inside a synchronous stretch.
The right question is never "can I make this zero-allocation?" It's "is this on a hot path, and is allocation what's actually costing me?" On a config parser that runs once, the
Splitversion is the correct, readable choice. Rewriting it with spans is a worse codebase for an improvement nobody can measure.
Knowing where it's worth it
That judgment (when zero-allocation parsing earns its complexity and when it's just flexing) is the
hard part, and it's not something you read your way into. You learn it by working a problem where the
budget actually forces the question, where a Split-based solution comes in over a time or memory
limit and the span-based rewrite slides under it, on the same input.
That's where deliberate practice beats blog posts, including this one. When the allocation and time budgets are real, you find out fast whether the slicing was worth it or whether you complicated the code for nothing. The seven tracks and how each one grades are laid out in the tracks overview; the performance ones are where spans stop being a party trick and start being the difference between passing and timing out.
If you want to feel that line for yourself instead of guessing at it, the full set of performance-graded tracks is on Pro. Bring your sharpest tools, and find out where they actually cut.