← Back to Blog
Series· Part 1 of 2

Zero-Allocation C#

Span<T>: Slicing Without Copying

June 25, 2026

The problem nobody profiles until it's too late

Your service is fast on your laptop. The tests fly. Then it ships, traffic ramps up, and the p99 latency graph starts to look like a heart monitor — flat, spike, flat, spike. You go looking for a slow query or a lock contention. Instead, a surprising slice of your CPU time is going to one thing: garbage collection.

Nothing in your code says "allocate." But every string.Split, every Substring, every .Trim() quietly puts a short-lived object on the heap. One call is free. A few thousand requests a second, each parsing and slicing strings, and you're generating megabytes of garbage per second. The GC keeps pausing your threads to clean it up — and those pauses are the spikes.

For most code, this genuinely doesn't matter, and you shouldn't lose sleep over it. But in the 5% of your codebase that runs on every single request — parsers, serializers, the guts of your request pipeline — allocation is the difference between a flat latency graph and a jagged one.

Span<T>, added back in .NET Core 2.1, is the resolution: the speed of pointer tricks with the safety and readability of ordinary C#. It lets you work with a slice of memory you already have — without copying it. The rest of this post is about what that buys you, and the three rules that bite if you don't know them.

See it in one function

Here's a function that almost every C# developer has written in some form. It sums a comma-separated list of numbers:

static int SumCsvNaive(string line)
{
    int sum = 0;
    foreach (string field in line.Split(','))
        sum += int.Parse(field.Trim());
    return sum;
}

It's correct. It's readable, but it allocates 1,952 bytes of garbage every time you call it — a string[] from Split, one substring per field, and another string from each .Trim(). Call it 100,000 times,s and you've handed the GC 195 MB of work for a number you could have computed without allocating a single byte.

Here's the same function with Span<T>. The output is identical. The allocations are zero:

static int SumCsv(ReadOnlySpan<char> line)
{
    int sum = 0;
    while (!line.IsEmpty)
    {
        int comma = line.IndexOf(',');
        ReadOnlySpan<char> field = comma < 0 ? line : line[..comma];
        if (int.TryParse(field.Trim(), out int n))   // span overload — no substring
            sum += n;
        line = comma < 0 ? ReadOnlySpan<char>.Empty : line[(comma + 1)..];
    }
    return sum;
}
MethodAllocated per call
SumCsvNaive1,952 B
SumCsv0 B

The concept: a window, not a copy

A substring is a photocopy. "muslum@example.com".Substring(0, 6) allocates a brand-new string and copies six characters into it.

A Span<T> is a finger pointing at a passage in a book you already have open. Under the hood, it's just a reference and a length — it borrows memory that already exists and never copies it. ReadOnlySpan<char> over a string lets you treat any slice of that string's characters as if it were its own string, with no allocation.

Slicing a span is therefore almost free:

ReadOnlySpan<char> text = "muslum@example.com";
ReadOnlySpan<char> user = text[..6];     // points at the same chars
ReadOnlySpan<char> host = text[7..];     // also points at the same chars

No new strings. Just two more windows over the original.

The reason the parser above allocates nothing is that the modern BCL is full of methods that accept spans: int.TryParse, int.Parse, double.Parse, DateTime.TryParse, and many more all have ReadOnlySpan<char> overloads. You slice instead of substring, hand the slice to a span-aware API, and the allocation simply never happens.

Catch #1: you can't return a tuple of spans

Try to write a clean helper that returns both halves of an email,l and the compiler stops you:

// ❌ does not compile
static (ReadOnlySpan<char>, ReadOnlySpan<char>) SplitEmail(ReadOnlySpan<char> email)

Span<T> is a ref struct — a type that's only ever allowed to live on the stack. A ValueTuple is generic, and a ref struct cannot be used as a generic type argument, so (Span, Span) is impossible. The fix is out parameters:

static void SplitEmail(ReadOnlySpan<char> email,
                       out ReadOnlySpan<char> user,
                       out ReadOnlySpan<char> host)
{
    int at = email.IndexOf('@');
    user = email[..at];
    host = email[(at + 1)..];
}

This is the first sign that Span<T> lives under stricter rules than a normal type. It buys its speed by promising the runtime it will never escape to the heap — and the compiler holds it to that promise. (Hold onto that idea; it's the whole reason Part 2 exists.)

Catch #2: stackalloc is fast, until it isn't

When you need a small scratch buffer, you can put it on the stack instead of the heap with stackalloc:

static string MakeId(int n)
{
    Span<char> buffer = stackalloc char[16];        // on the stack, no GC
    "USR-".CopyTo(buffer);
    n.TryFormat(buffer[4..], out int written, "D5");
    return new string(buffer[..(4 + written)]);     // the only allocation
}

MakeId(42) produces "USR-00042" with a single allocation — the final string — instead of the chain of intermediate strings you'd get from concatenation.

But stackalloc char[n] with an unbounded n is a stack overflow waiting to happen. The stack is small (about 1 MB), and overflowing it crashes the process — you don't get a catchable exception. The production-grade pattern is to stack-allocate when the size is small and rent from a pool otherwise:

static string Repeat(char c, int count)
{
    const int StackLimit = 256;
    char[]? rented = null;
    Span<char> buffer = count <= StackLimit
        ? stackalloc char[StackLimit]
        : (rented = ArrayPool<char>.Shared.Rent(count));
    try
    {
        buffer[..count].Fill(c);
        return new string(buffer[..count]);
    }
    finally
    {
        if (rented is not null) ArrayPool<char>.Shared.Return(rented);
    }
}

stackalloc for the common small case, ArrayPool<T> as a safe fallback for the large one. This pattern shows up all over the .NET runtime itself.

Takeaway

  • Reach for Span<T> in synchronous hot paths — parsing, slicing, scratch buffers — where you'd otherwise allocate substrings or temporary arrays.
  • The stricter rules — no tuples of spans, bounded stackalloc — aren't arbitrary. They're the runtime enforcing the one promise that makes Span<T> free: it never leaves the stack.
  • You don't need to span-ify your whole codebase. You need it in the 5% of code that runs on every request, where zero allocations is the difference between a flat latency graph and a spiky one.

But that "never leaves the stack" promise has a sharp edge. The moment you try to use a Span<T> in an async method, the compiler refuses outright — and that single restriction is the entire reason Memory<T> exists. That's Part 2.

One question for you: What's the most allocation-heavy hot path in your codebase right now — a parser, a serializer, or request handling — and have you ever profiled what it actually costs you in GC?