Portfolio

span

Span<T> in C#: stop allocating for work you can borrow

A practical introduction to Span<T>, ReadOnlySpan<T>, Memory<T>, and pooled buffers for low-allocation C# code.

January 17, 2026 7 min read Marvin Drude
C#.NETSpanMemoryPerformanceGC

The allocation problem

Most slow .NET code is not slow because C# is slow. It is slow because the code asks the runtime to create temporary objects over and over again.

That is especially easy to miss when the code looks harmless:

  • splitting strings
  • slicing arrays with copies
  • building temporary buffers
  • parsing text through intermediate strings
  • allocating fresh byte arrays for every request

The garbage collector is very good, but it is not magic. If a hot path allocates on every call, the GC eventually has to clean that work up. In high-throughput systems, that cleanup becomes noise in your latency profile.

Span<T> gives you a way to express a simple idea: I want to look at a contiguous piece of memory without owning or copying it.

What Span actually is

Span<T> is a stack-only view over contiguous memory. It can point at:

  • an array
  • a slice of an array
  • stack memory
  • unmanaged memory
  • a string through ReadOnlySpan<char>

The important part is that slicing a span does not allocate a new array or string. It creates another view over the same memory.

C#
Span<int> numbers = stackalloc int[] { 1, 2, 3, 4 };

Span<int> tail = numbers[2..];
tail[0] = 99;

// numbers is now: 1, 2, 99, 4

Because Span<T> is a ref struct, it cannot be stored on the heap. You cannot put it in a normal class field, capture it in a lambda, or keep it across await. Those restrictions are the point: they let the compiler prove that the view does not outlive the memory it points to.

Span versus Memory

Use Span<T> when the work is synchronous and short-lived.

Use Memory<T> when ownership or async boundaries matter.

Memory<T> can live on the heap and can cross async calls. When you need to work with the data directly, you access its span:

C#
public static async Task FillAsync(Memory<byte> buffer, Stream stream)
{
    var read = await stream.ReadAsync(buffer);
    Span<byte> received = buffer.Span[..read];

    // Work with received synchronously here.
}

A good mental model:

TypeBest forCan cross async?Can be a field?
Span<T>temporary synchronous worknono
ReadOnlySpan<T>temporary read-only viewsnono
Memory<T>owned or async-capable memoryyesyes
ReadOnlyMemory<T>owned async-capable read-only memoryyesyes

A real string parsing example

String parsing is where hidden allocations show up constantly.

Imagine an input like this:

C#
private const string Example = "USERNAME:marvin|ID:testid";

A common implementation uses Split:

C#
public static string GetUserIdNaive()
{
    var parts = Example.Split('|');
    return parts[1].Split(':')[1];
}

That reads nicely, but it allocates arrays and strings just to find a small slice of text.

The span-based version keeps the input as one string and only creates views into it:

C#
public static ReadOnlySpan<char> GetUserId()
{
    ReadOnlySpan<char> input = Example;

    var idSegment = input[(input.IndexOf('|') + 1)..];
    return idSegment[(idSegment.IndexOf(':') + 1)..];
}

No new string is created. No array from Split is created. The result is just a view into the original input.

If an API eventually needs a string, you can still call ToString() at the boundary. The win is that you decide where the allocation happens instead of letting every parsing step allocate.

Benchmarking the difference

The nice part about this kind of optimization is that it is easy to measure. A tiny BenchmarkDotNet setup is enough:

C#
[MemoryDiagnoser]
public class UserIdBenchmarks
{
    private string _example = string.Empty;

    [Params(10, 100, 1000)]
    public int N { get; set; }

    [GlobalSetup]
    public void Setup()
    {
        _example = $"USERNAME:{new string('m', N)}|ID:testid";
    }

    [Benchmark]
    public int NaiveExample()
    {
        var parts = _example.Split('|');
        return parts[1].Split(':')[1].Length;
    }

    [Benchmark]
    public int SpanExample()
    {
        ReadOnlySpan<char> input = _example;
        var idSegment = input[(input.IndexOf('|') + 1)..];
        return idSegment[(idSegment.IndexOf(':') + 1)..].Length;
    }
}

The exact numbers depend on the machine, runtime, and benchmark shape, but the signal should look like this:

MethodNMeanGen0Allocated
NaiveExample1058.64 ns0.0049248 B
SpanExample1017.38 ns--
NaiveExample10059.37 ns0.0049248 B
SpanExample10015.07 ns--
NaiveExample100073.12 ns0.0049248 B
SpanExample100014.38 ns--

That table tells a useful story. The span version is faster here, but the more important result is the last column: the parsing path does not allocate. Once that is true, the GC no longer has to participate in this operation.

Dictionary lookups without creating a string

Newer .NET APIs make this pattern even more useful. For example, alternate lookup support can let a dictionary keyed by string accept a ReadOnlySpan<char> lookup.

That means parsing can stay allocation-free even when you need to look up a value:

C#
private readonly Dictionary<string, string> _usersPerId = [];

public string LookupUser(ReadOnlySpan<char> userId)
{
    var lookup = _usersPerId.GetAlternateLookup<ReadOnlySpan<char>>();
    return lookup[userId];
}

That is the shape you want in hot paths:

  1. Parse using spans.
  2. Keep slices as slices.
  3. Allocate only if the boundary requires ownership.

Stackalloc is powerful, but keep it small

stackalloc creates memory on the stack instead of the heap:

C#
Span<byte> scratch = stackalloc byte[256];
scratch.Clear();

That is perfect for small temporary buffers. It is not a general replacement for arrays.

The stack is limited. Large stack allocations can hurt reliability. A practical rule is to use stackalloc only for small bounded buffers where the size is obvious and controlled.

For larger buffers, use pooling.

Pools for larger temporary buffers

When the buffer is too large for the stack, move the lifetime decision into a pool. The two common choices are ArrayPool<T> and MemoryPool<T>.

ArrayPool for large temporary buffers

ArrayPool<T> lets you rent an array and return it later. The array may be larger than requested, but it avoids repeated heap allocation in hot paths.

C#
using System.Buffers;

public static void ProcessPayload(ReadOnlySpan<byte> payload)
{
    var pool = ArrayPool<byte>.Shared;
    var buffer = pool.Rent(payload.Length);

    try
    {
        var destination = buffer.AsSpan(0, payload.Length);
        payload.CopyTo(destination);

        // Process destination here.
    }
    finally
    {
        pool.Return(buffer, clearArray: true);
    }
}

The finally matters. A rented array is not yours forever. Return it even when processing throws.

Also be careful with sensitive data. Pooled arrays are reused. If a buffer can contain secrets, clear it before returning it.

MemoryPool when ownership matters

MemoryPool<T> gives you an owner object. That owner controls the lifetime of the rented memory.

C#
using System.Buffers;

public static void UseMemoryPool()
{
    using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(4096);

    Memory<byte> memory = owner.Memory[..4096];
    Span<byte> span = memory.Span;

    span[0] = 42;
}

This is useful when APIs need Memory<T>, especially in pipeline or async-oriented code. The owner makes the lifetime explicit.

API design guidelines

Span-friendly code is not just about local variables. It changes how you design APIs.

Prefer this for read-only parsing:

C#
public static bool TryParseHeader(ReadOnlySpan<char> input, out Header header)

Prefer this for writing into caller-owned memory:

C#
public static bool TryFormat(Header header, Span<char> destination, out int charsWritten)

Avoid returning new arrays or strings from hot-path helpers unless ownership is the entire point of the method.

Good low-allocation APIs usually follow this pattern:

  • ReadOnlySpan<T> for input
  • Span<T> for caller-provided output
  • Try... methods instead of throwing for normal parse failure
  • explicit out int written or out int consumed
  • Memory<T> only when async or ownership is needed

C# 14 makes span APIs less noisy

Older span-heavy code often had a usability tax. You wrote good APIs, then had to add extra overloads or call AsSpan() everywhere so arrays and strings could flow into them cleanly.

C# 14 improves that by treating Span<T> and ReadOnlySpan<T> more like first-class language citizens. The important conversions become more natural:

  • T[] to Span<T>
  • T[] to ReadOnlySpan<T>
  • Span<T> to ReadOnlySpan<T>
  • string to ReadOnlySpan<char>

That matters because good API design becomes easier to live with. A method that accepts ReadOnlySpan<char> can still be pleasant to call with a string. You get the low-allocation contract without forcing every caller to stare at conversion ceremony.

What to measure

Do not rewrite everything because spans exist.

Measure first:

  • allocated bytes per operation
  • Gen0 frequency
  • p95 and p99 latency
  • throughput under real input sizes
  • complexity added by the optimization

Span<T> is a sharp tool. It is worth using when the code is on a hot path or when allocation behavior is part of the contract. It is not worth making every simple business method harder to read.

Conclusion

Span<T> is not about writing clever code. It is about being honest with memory.

If you only need to look at a slice, do not copy it. If you need a temporary buffer, consider the stack for small fixed sizes and pools for larger sizes. If the data has to cross async boundaries, reach for Memory<T>.

That gives you a clean model:

  • borrow memory with Span<T>
  • own memory with arrays, strings, or pooled owners
  • allocate at boundaries, not in the middle of the hot path

Used that way, spans are one of the simplest ways to make .NET code faster, calmer under load, and easier to reason about when performance actually matters.

An unhandled error has occurred. Reload 🗙

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please retry or reload the page.