Katabench
Start free
← All posts

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

A copy pays the allocator on every field of every line; a slice is just an offset and a length into memory you already own.

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 an await. 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.
  • stackalloc is 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 (or ArrayPool<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 Split version 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.

Get new puzzles and .NET tips in your inbox

A short note when fresh kata land, plus the C# and performance tricks behind the grading. No spam, unsubscribe anytime.