Trust is earned, not given

A different perspective

2023-08-19 · Projects

Use Span in .NET to Achieve Higher Performance with Less Memory Usage

Most .NET performance problems have the same shape: code that is correct but quietly allocates. Every substring is a new string, every array slice is a new array, every parse makes the garbage collector work harder. Span<T> (added in .NET Core 2.1) is the type that fixes this class of problem: it lets you work with a window over existing memory β€” a slice of an array, a string, or even stack memory β€” without copying anything. This article explains what it is, how it works under the hood, and where it pays off, with commented examples you can drop into a console project.

The problem: slicing copies

// The everyday way: every one of these lines allocates a NEW object.
string log = "2026-09-28T12:34:56Z,GET,/api/orders,200,148";

string datePart = log.Substring(0, 10);        // allocates a new 10-char string
string pathPart = log.Substring(16, 11);       // allocates another string
string[] parts  = log.Split(',');              // allocates an array + 5 strings

int len = pathPart.Length;                     // fine β€” but we already copied the data

For one line of a log file, that is harmless. For a parser chewing through a million log lines, it is a hundred million allocations β€” and the GC has to clean up every one. The data was already in memory; we just wanted to look at parts of it.

The fix: a window, not a copy

Span<char> (and ReadOnlySpan<char> for strings) is a small struct β€” two fields: where the memory starts, and how many elements it covers. It holds no data itself. Slicing it just adjusts those two fields:

ReadOnlySpan<char> log = "2026-09-28T12:34:56Z,GET,/api/orders,200,148".AsSpan();

ReadOnlySpan<char> datePart = log.Slice(0, 10);   // no allocation: just a window
ReadOnlySpan<char> pathPart = log.Slice(16, 11);  // another window over the same chars

// Spots can be compared without materializing strings:
bool isGet = log.Slice(23, 3).SequenceEqual("GET");   // char-by-char, zero copies

// And they can be parsed without strings:
int status = int.Parse(log.Slice(35, 3));             // "200" -> 200, no allocation

How it works: a span is a ref struct β€” a struct the runtime forces to stay on the stack. That restriction is what makes it safe: because a span cannot be boxed, stored in a field of a normal class, or captured by an async method, it can never outlive the memory it points at. You get pointer-like power with GC-visible safety. When you do need to heap-allocate the window (e.g. to store it in a class), you use Memory<T> instead β€” the same idea with fewer restrictions and slightly more overhead.

Example 1: parsing without allocating

A realistic log-parsing routine, written the span way:

// Parses "date,method,path,status,size" without allocating a single string.
// Returns the parsed fields through out params as spans into the original text.
public static bool TryParseLogLine(ReadOnlySpan<char> line,
                                   out ReadOnlySpan<char> date,
                                   out ReadOnlySpan<char> method,
                                   out ReadOnlySpan<char> path,
                                   out int status)
{
    date = method = path = default;
    status = 0;

    // IndexOf on a span scans memory directly β€” no char[] copy, no substring.
    int c1 = line.IndexOf(',');
    if (c1 < 0) return false;
    date = line.Slice(0, c1);          // window 1: everything before the comma

    int c2 = line.Slice(c1 + 1).IndexOf(',') + c1 + 1;
    if (c2 < c1) return false;
    method = line.Slice(c1 + 1, c2 - c1 - 1);   // window 2: the method

    int c3 = line.Slice(c2 + 1).IndexOf(',') + c2 + 1;
    if (c3 < c2) return false;
    path = line.Slice(c2 + 1, c3 - c2 - 1);     // window 3: the path

    // int.Parse accepts a ReadOnlySpan<char> directly β€” no string needed.
    return int.TryParse(line.Slice(c3 + 1, 3), out status);
}

// Usage:
foreach (string raw in File.ReadLines("access.log"))
{
    if (TryParseLogLine(raw.AsSpan(), out var date, out var method, out var path, out var status))
    {
        // process the windows β€” the source string stays the only allocation
        if (status >= 500) Console.WriteLine($"{date.ToString()} {method.ToString()}{path.ToString()} -> {status}");
    }
}

Calling .ToString() on a span does allocate (it materializes a string) β€” but only at the boundary where you actually need one. Inside the parser, zero allocations.

Example 2: slicing arrays the same way

Spans are not just for strings. Any array slices for free:

byte[] frame = ReadNetworkFrame();          // e.g. [type:1][length:2][payload:N][crc:2]

Span<byte> span = frame.AsSpan();
byte type = span[0];                        // header byte β€” direct memory access
ushort length = BitConverter.ToUInt16(span.Slice(1, 2));   // window over 2 bytes
Span<byte> payload = span.Slice(3, length); // the payload β€” again, no copy
Span<byte> crc = span.Slice(3 + length, 2); // the trailing CRC

// Write into a window just like an array (this is Span, not ReadOnlySpan):
payload[0] = 0xFF;

// Bulk copies between windows use fast block operations:
Span<byte> target = new byte[length];
payload.CopyTo(target);                     // memcpy under the hood

The classic use is protocol/binary parsing: header, length, payload, checksum β€” each a window into one buffer that was allocated once. Compare with the pre-span approach of Array.Copy into scratch buffers at every step.

Example 3: stackalloc β€” buffers without the heap

Small short-lived buffers can live on the stack entirely:

// Turn an int into its digit characters without a string in between.
public static int WriteDigits(Span<char> dest, int value)
{
    value.ToString(dest, out int charsWritten);   // formats directly into dest
    return charsWritten;
}

// Caller: allocate on the stack β€” freed when the method returns, zero GC cost.
Span<char> buf = stackalloc char[20];
int n = WriteDigits(buf, 123456);
Console.WriteLine(buf.Slice(0, n).ToString());    // "123456"

stackalloc with spans is safe in a way the old raw-pointer version never was: if the buffer is bigger than the stack budget, the runtime silently falls back to a heap array. Combine with ArrayPool<T>.Shared for larger buffers and you can build hot paths that allocate essentially never.

Example 4: the API surface worth memorizing

MemberWhat it does
span.Slice(start, length)a window into the window β€” O(1), no copy
span.IndexOf(...) / LastIndexOfsearch without materializing
span.SequenceEqual(other)compare contents β€” replaces == on strings
span.StartsWith(...)prefix check, no copy
int.Parse(span) / TryParsenumeric parsing straight from chars
span.Trim() / TrimStart() / TrimEnd()whitespace handling without new strings
span.CopyTo(dest) / span.TryCopyTo(...)block copy between spans
span.Fill(value)set every element (e.g. zero a buffer)
string.Create(...)build a string by writing into its memory once β€” no intermediate builders
MemoryMarshal.Cast<TFrom,TTo>(span)reinterpret bytes as structs (the safe reinterpret_cast)

Rules, limits, and gotchas

Where this shows up in the framework itself

Once you see spans you see them everywhere in modern .NET: string.Split has a SpanCharEnumerator-style overload that avoids allocations, the JSON serializer's UTF-8 path is span-based end to end, StreamReader, Socket, and Pipelines accept spans, and Base64/HashCode/Utf8Parser all operate on spans. The BCL uses them so hot paths can be allocation-free β€” and now your code can too.

Try it

The examples above compile as-is in a modern .NET console app. Benchmark with BenchmarkDotNet before/after on a real workload: the usual result on parsing-heavy code is fewer allocations (often to zero) and double-digit-percent throughput gains β€” the same work, the same correctness, just without asking the garbage collector to keep up.