Learnixo
Back to blog
Backend Systemsadvanced

Span<T> and Memory<T> in C# — Zero-Copy Slicing and High-Performance Buffers

Master Span<T>, Memory<T>, ArrayPool<T>, and stackalloc in C#: zero-copy slicing, stack allocation, buffer pooling, avoiding allocations in hot paths, and safe interop with unmanaged memory.

Asma Hafeez KhanMay 26, 20268 min read
C#.NETPerformanceSpanMemoryArrayPoolZero-CopyHigh Performance
Share:𝕏

Span<T> and Memory<T> in C# — Zero-Copy Slicing and High-Performance Buffers

Span<T> is the most important performance API added to C# in recent years. It lets you work with contiguous regions of memory — arrays, stack-allocated buffers, unmanaged pointers — without copying, without allocating, and without unsafe code. Understanding when and how to use it separates engineers who can write high-throughput .NET from those who can only write correct .NET.

What you'll learn:

  • What Span<T> is and why it avoids allocations
  • Memory<T> — the async-compatible sibling
  • ArrayPool<T> for buffer reuse
  • stackalloc for stack allocation
  • ReadOnlySpan<T> for string parsing
  • Real-world patterns: parsing, slicing, zero-copy transforms

The Allocation Problem

Every time you create a substring in .NET, you allocate a new string on the heap:

C#
string line = "2026-05-26,ORDER-123,450.00,GBP";

// Each of these allocates a new string
string date   = line.Substring(0, 10);
string orderId = line.Substring(11, 9);
string amount = line.Substring(21, 6);
string currency = line.Substring(28, 3);

If you're parsing a 10-million-line CSV file, those four allocations per line create 40 million short-lived objects. The GC has to collect all of them. Span<T> eliminates this by giving you a view into existing memory.


1. What Is Span<T>?

Span<T> is a ref struct — it lives on the stack, never on the heap. It holds:

  • A pointer to the start of a memory region
  • A length

It does not own the memory. It is a window into memory owned by something else (an array, a string, the stack).

C#
int[] array = { 1, 2, 3, 4, 5, 6, 7, 8 };

Span<int> span = array;              // entire array
Span<int> slice = array.AsSpan(2, 4); // elements 2,3,4,5 — no allocation

// Read and write through the span
slice[0] = 99;          // modifies array[2]
Console.WriteLine(array[2]); // 99

// Iterate
foreach (int n in slice)
    Console.Write(n + " "); // 99 3 4 5

Slicing without allocation

C#
Span<int> left  = slice[..2];  // first 2 elements
Span<int> right = slice[2..];  // last 2 elements
// No memory copied, no GC pressure — just pointer arithmetic

2. ReadOnlySpan<string> for Parsing

ReadOnlySpan<char> is the zero-allocation alternative to string.Substring.

C#
// Parse a CSV line without allocating substrings
public static void ParseCsvLine(ReadOnlySpan<char> line)
{
    int fieldStart = 0;
    int fieldIndex = 0;

    for (int i = 0; i <= line.Length; i++)
    {
        if (i == line.Length || line[i] == ',')
        {
            ReadOnlySpan<char> field = line[fieldStart..i];
            ProcessField(fieldIndex, field);
            fieldStart = i + 1;
            fieldIndex++;
        }
    }
}

private static void ProcessField(int index, ReadOnlySpan<char> field)
{
    switch (index)
    {
        case 0:
            // Parse date — no string allocation
            if (DateTime.TryParse(field, out var date))
                Console.WriteLine(date);
            break;
        case 2:
            // Parse decimal — no string allocation
            if (decimal.TryParse(field, out var amount))
                Console.WriteLine(amount);
            break;
    }
}
C#
// Calling it — the string is implicitly converted to ReadOnlySpan<char>
ParseCsvLine("2026-05-26,ORDER-123,450.00,GBP");

The TryParse overloads on int, decimal, DateTime, Guid etc. all accept ReadOnlySpan<char> — no intermediate string needed.

Splitting without allocations

C#
// .NET 8+: MemoryExtensions.Split
var line = "alpha,beta,gamma,delta".AsSpan();
foreach (var range in line.Split(','))
{
    ReadOnlySpan<char> field = line[range];
    Console.WriteLine(field.ToString()); // ToString() only when you need a string
}

3. stackalloc — Stack Allocation

stackalloc allocates memory on the stack — no GC involvement at all. Safe to use with Span<T> without unsafe:

C#
// Allocate a 256-byte buffer on the stack
Span<byte> buffer = stackalloc byte[256];

// Use it like any span
buffer[0] = 0xFF;
buffer.Fill(0); // zero all bytes

// SHA256 hash without heap allocation (for small inputs)
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(inputBytes, hash);

Limits: Stack size is typically 1–4 MB. Use stackalloc for small, fixed-size buffers (under a few KB). For larger buffers, use ArrayPool<T>.

C#
// Safe pattern: use stack for small, pool for large
const int StackThreshold = 256;
int bufferSize = ComputeNeededSize();

Span<byte> buffer = bufferSize <= StackThreshold
    ? stackalloc byte[bufferSize]
    : new byte[bufferSize]; // or ArrayPool — see below

4. ArrayPool<T> — Reusable Heap Buffers

For larger buffers that can't fit on the stack, ArrayPool<T> rents and returns arrays from a pool, avoiding allocation:

C#
// Without ArrayPool — allocates 64KB on every call
public byte[] ProcessData(Stream stream)
{
    byte[] buffer = new byte[65536];
    int read = stream.Read(buffer, 0, buffer.Length);
    return Process(buffer.AsSpan(0, read));
}

// With ArrayPool — rents from pool, returns it when done
public byte[] ProcessData(Stream stream)
{
    byte[] rented = ArrayPool<byte>.Shared.Rent(65536);
    try
    {
        int read = stream.Read(rented, 0, rented.Length);
        return Process(rented.AsSpan(0, read));
    }
    finally
    {
        ArrayPool<byte>.Shared.Return(rented, clearArray: true);
    }
}

ArrayPool<T>.Shared is thread-safe. Rent(minimumLength) returns an array of at least that size (may be larger — always track the actual length you need separately). Return gives it back — the clearArray: true flag zeroes it before returning, preventing data leaks between callers.

With using pattern (custom wrapper)

C#
// A value type that returns the array on Dispose
public readonly ref struct PooledBuffer<T>
{
    private readonly T[] _array;
    public readonly Span<T> Span;

    public PooledBuffer(int size)
    {
        _array = ArrayPool<T>.Shared.Rent(size);
        Span = _array.AsSpan(0, size);
    }

    public void Dispose() => ArrayPool<T>.Shared.Return(_array, clearArray: true);
}

// Usage
using var buffer = new PooledBuffer<byte>(65536);
int read = stream.Read(buffer.Span);
Process(buffer.Span[..read]);

5. Memory<T> — The Async-Compatible Version

Span<T> is a ref struct — it cannot be stored in fields, captured in lambdas, or used across await boundaries. For async contexts, use Memory<T>:

C#
// Span<T> — cannot cross await
public async Task WrongAsync(Span<byte> data) // Compiler error: Span in async method
{
    await Task.Delay(1);
}

// Memory<T> — works across await
public async Task CorrectAsync(Memory<byte> data)
{
    await stream.WriteAsync(data);     // accepts Memory<byte>
    await Task.Delay(1);
    Process(data.Span);               // convert to Span when needed
}

Memory<T> wraps the same underlying memory as Span<T> but is a regular struct (heap-storable). Convert to Span<T> via .Span property when you need to do work with it.

C#
// Pipeline example: allocate once, pass Memory<T> through async pipeline
byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
Memory<byte> memory = buffer.AsMemory(0, 4096);

try
{
    int bytesRead = await stream.ReadAsync(memory);
    await ProcessAsync(memory[..bytesRead]);
    await WriteResultAsync(memory[..bytesRead]);
}
finally
{
    ArrayPool<byte>.Shared.Return(buffer);
}

6. MemoryMarshal and Unsafe Reinterpretation

For interop scenarios, reinterpret bytes as a struct without copying:

C#
// Reading a binary protocol header without copying
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct PacketHeader
{
    public ushort Magic;
    public byte Version;
    public byte Type;
    public int PayloadLength;
}

public PacketHeader ReadHeader(ReadOnlySpan<byte> bytes)
{
    // Zero-copy reinterpret — no allocation
    return MemoryMarshal.Read<PacketHeader>(bytes);
}

// Or as a span of structs
ReadOnlySpan<PacketHeader> headers = MemoryMarshal.Cast<byte, PacketHeader>(bytes);

7. Real-World: HTTP Request Body Parsing

ASP.NET Core exposes PipeReader for zero-copy request body reading:

C#
// Middleware that parses a custom binary protocol
public class BinaryProtocolMiddleware
{
    private readonly RequestDelegate _next;

    public async Task InvokeAsync(HttpContext context)
    {
        var reader = context.Request.BodyReader;

        while (true)
        {
            var result = await reader.ReadAsync();
            var buffer = result.Buffer;

            // Process complete frames from the buffer
            while (TryParseFrame(ref buffer, out var frame))
            {
                await ProcessFrameAsync(frame);
            }

            reader.AdvanceTo(buffer.Start, buffer.End);

            if (result.IsCompleted)
                break;
        }

        await _next(context);
    }

    private bool TryParseFrame(ref ReadOnlySequence<byte> buffer, out ReadOnlySpan<byte> frame)
    {
        frame = default;
        if (buffer.Length < 4) return false;

        // Read the 4-byte length prefix
        Span<byte> lengthBytes = stackalloc byte[4];
        buffer.Slice(0, 4).CopyTo(lengthBytes);
        int frameLength = BitConverter.ToInt32(lengthBytes);

        if (buffer.Length < 4 + frameLength) return false;

        frame = buffer.Slice(4, frameLength).ToArray(); // copy only when storing
        buffer = buffer.Slice(4 + frameLength);
        return true;
    }
}

8. Common Mistakes

Storing a Span in a field:

C#
// Does NOT compile — Span is a ref struct
public class MyService
{
    private Span<byte> _buffer; // Error: cannot use Span as field type
}

// Use Memory<T> or byte[] for fields
public class MyService
{
    private Memory<byte> _buffer;
    private byte[] _rawBuffer;
}

Returning a Span backed by a stack allocation:

C#
// DANGEROUS — stack frame is gone when the method returns
public Span<byte> GetBuffer()
{
    Span<byte> buffer = stackalloc byte[256]; // stack-allocated
    return buffer; // compiler error — correctly prevents this
}

Forgetting to return pooled arrays:

C#
// Memory leak — rented array never returned to pool
byte[] rented = ArrayPool<byte>.Shared.Rent(4096);
ProcessData(rented);
// Missing: ArrayPool<byte>.Shared.Return(rented)

Always return rented arrays in a finally block or via IDisposable.


When to Use Each Type

| Scenario | Use | |---|---| | Short-lived slice of array/string in sync code | Span<T> / ReadOnlySpan<T> | | Small fixed-size temp buffer | stackalloc + Span<T> | | Large temp buffer (reuse across calls) | ArrayPool<T> | | Pass memory across await | Memory<T> | | Store a buffer reference in a class field | Memory<T> or byte[] | | Binary protocol / struct reinterpret | MemoryMarshal | | High-throughput network I/O | System.IO.Pipelines (PipeReader/PipeWriter) |

Rule of thumb: if you find yourself calling string.Substring or array.Copy in a hot path, reach for Span<T> first. Measure with BenchmarkDotNet to confirm the improvement — not every allocation matters.

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.