C# Span<T> and Memory<T> — zero-allocation slicing and high-performance buffers

Span<T> (C# 7.2+) is a stack-only, ref struct that points to a contiguous block of memory — an array, a string, a stack-allocated buffer, or native memory — without copying any data. You get a view into the original memory, not a new object.

Memory<T> is the heap-safe sibling: it can be stored in fields, captured in lambdas, and used across await boundaries where Span<T> cannot.

Use them whenever you want to eliminate intermediate array or string allocations in hot paths: parsers, serializers, protocol implementations, and any code that slices large buffers repeatedly.

Why Span<T> Exists

Without Span<T>, every slice creates a new allocation:

C# Example Code
string line = "Alice,30,Engineer";

// Old approach — two new string allocations
string[] parts = line.Split(',');  // allocates string[] + 3 strings
string name = parts[0];            // points to an already-allocated string

// Span approach — zero allocations
ReadOnlySpan<char> span = line.AsSpan();
ReadOnlySpan<char> nameSpan = span[..span.IndexOf(',')]; // no new string
Console.WriteLine(nameSpan.ToString()); // Alice  (allocation only when you actually need a string)

Creating a Span<T>

C# Example Code
// From an array
int[] numbers = { 1, 2, 3, 4, 5 };
Span<int> all   = numbers;              // implicit conversion
Span<int> slice = numbers.AsSpan(1, 3); // { 2, 3, 4 } — no copy

// From a portion of an array using range syntax
Span<int> first3 = numbers.AsSpan()[..3]; // { 1, 2, 3 }
Span<int> last2  = numbers.AsSpan()[^2..]; // { 4, 5 }

// Modifying through a Span modifies the original array
slice[0] = 99;
Console.WriteLine(numbers[1]); // 99  ← original changed

ReadOnlySpan<T> for Strings

Strings are immutable, so use ReadOnlySpan<char> when slicing text.

C# Example Code
string csv = "2026-05-02,USD,1234.56";

ReadOnlySpan<char> remaining = csv.AsSpan();

// Parse date segment
int comma1 = remaining.IndexOf(',');
ReadOnlySpan<char> datePart = remaining[..comma1];
remaining = remaining[(comma1 + 1)..];

// Parse currency segment
int comma2 = remaining.IndexOf(',');
ReadOnlySpan<char> currency = remaining[..comma2];
remaining = remaining[(comma2 + 1)..];

// Parse amount — directly parse numeric value, no string conversion needed
decimal amount = decimal.Parse(remaining); // .NET 6+ overload accepts ReadOnlySpan<char>

Console.WriteLine(datePart.ToString()); // 2026-05-02
Console.WriteLine(currency.ToString()); // USD
Console.WriteLine(amount);              // 1234.56

Stack Allocation with stackalloc

Span<T> is the only safe way to use stackalloc outside an unsafe context. Stack memory is extremely fast and produces zero GC pressure.

C# Example Code
// Allocate 128 bytes on the stack — no heap allocation, no GC
Span<byte> buffer = stackalloc byte[128];
buffer.Fill(0); // zero-initialize

// Use it like a regular span
buffer[0] = 0x48; // 'H'
buffer[1] = 0x69; // 'i'

Console.WriteLine(buffer.Length); // 128

// Rule of thumb: keep stackalloc small (< 1 KB) to avoid stack overflow
// For larger or variable-size buffers, use ArrayPool<T> instead

ArrayPool<T> — Reusable Heap Buffers

When you need a buffer larger than a few hundred bytes, rent it from ArrayPool<T> instead of allocating.

C# Example Code
using System.Buffers;

byte[] rented = ArrayPool<byte>.Shared.Rent(4096); // reused from pool
try
{
    Span<byte> buffer = rented.AsSpan(0, 4096);
    // ... fill and use buffer ...
    buffer.Fill(42);
    Console.WriteLine(buffer[0]); // 42
}
finally
{
    ArrayPool<byte>.Shared.Return(rented); // return to pool — must always return
}

Memory<T> — the Async-Compatible Alternative

Span<T> is a ref struct, which means it cannot:

  • Be a field in a class or regular struct
  • Be captured in a lambda or local function
  • Be used across an await boundary

Use Memory<T> when you need any of those capabilities.

C# Example Code
// Memory<T> can be stored in a class field, passed to async methods, etc.
public class DataProcessor
{
    private readonly Memory<byte> _buffer;

    public DataProcessor(byte[] data)
    {
        _buffer = data.AsMemory(); // wrap once, reuse freely
    }

    public async Task ProcessAsync()
    {
        // .Span property gives a Span<T> for synchronous work
        Span<byte> sync = _buffer.Span;
        sync[0] = 0xFF;

        await Task.Delay(1); // can cross await — Span<T> could not

        // Slice Memory<T> just like Span<T>
        Memory<byte> firstHalf = _buffer[..(_buffer.Length / 2)];
        Console.WriteLine(firstHalf.Length);
    }
}

Slicing Syntax Recap

Both Span<T> and Memory<T> support the same range/index operators introduced in C# 8.

C# Example Code
int[] data = { 10, 20, 30, 40, 50 };
Span<int> s = data;

// Index from end
Console.WriteLine(s[^1]); // 50 — last element

// Range slice (returns a new Span pointing into the same memory)
Span<int> middle  = s[1..4];  // { 20, 30, 40 }
Span<int> fromTwo = s[2..];   // { 30, 40, 50 }
Span<int> toThree = s[..3];   // { 10, 20, 30 }

// The Slice() method is equivalent and explicit
Span<int> explicit = s.Slice(1, 3); // start=1, length=3 → { 20, 30, 40 }

Span<T> vs Memory<T> vs Array — Quick Comparison

T[] (array)Span<T>Memory<T>
AllocationHeapNone (view)None (view)
GC pressureYesNoNo
Stackalloc compatibleNoYesNo
Can be a class fieldYesNoYes
Works across awaitYesNoYes
Works with stringsVia ToCharArray()Via AsSpan()Via AsMemory()
PerformanceGoodBestVery good
Available sinceAlwaysC# 7.2C# 7.2

Span<T> Limitations

C# Example Code
// ❌ Cannot be a field in a class
public class Bad
{
    private Span<int> _span; // compile error: 'Span<int>' cannot be used as a field
}

// ❌ Cannot cross an await boundary
async Task BadAsync()
{
    Span<int> span = stackalloc int[10];
    await Task.Delay(1); // compile error: cannot use Span<T> in async method
}

// ✅ Use Memory<T> instead for async methods
async Task GoodAsync()
{
    int[] buffer = new int[10];
    Memory<int> mem = buffer;
    await Task.Delay(1); // fine
}

// ✅ Or limit Span<T> to the synchronous portion
async Task AlsoGoodAsync()
{
    int[] buffer = new int[10];
    FillBuffer(buffer.AsSpan()); // synchronous helper uses Span<T>
    await SaveAsync(buffer);     // pass the array across await, not the span
}

Real-World Example: Zero-Allocation CSV Row Parser

A practical parser that splits a CSV line into fields without allocating a string[] or any intermediate strings.

C# Example Code
using System;
using System.Collections.Generic;

/// <summary>
/// Splits a CSV line into ReadOnlyMemory&lt;char&gt; segments — zero string allocations.
/// </summary>
static List<ReadOnlyMemory<char>> ParseCsvRow(string line, char delimiter = ',')
{
    var fields = new List<ReadOnlyMemory<char>>();
    ReadOnlyMemory<char> remaining = line.AsMemory();

    while (remaining.Length > 0)
    {
        int idx = remaining.Span.IndexOf(delimiter);
        if (idx < 0)
        {
            fields.Add(remaining);
            break;
        }
        fields.Add(remaining[..idx]);
        remaining = remaining[(idx + 1)..];
    }

    return fields;
}

// Usage
var fields = ParseCsvRow("Alice,30,Engineer,London");
foreach (var field in fields)
    Console.WriteLine(field); // Prints each field without creating new strings
// Alice
// 30
// Engineer
// London

// Only allocate a string when actually needed downstream
string name = fields[0].ToString(); // one allocation, on demand

When to Use Each

  • Span<T> — synchronous hot paths, stack-based scratch buffers, slicing arrays/strings inside a single method call.
  • Memory<T> — async I/O pipelines, data stored in class fields, anything that needs to survive await.
  • ArrayPool<T> — large or variable-size heap buffers that should be reused across calls.
  • stackalloc — tiny fixed-size scratch space (< ~512 bytes) where you want zero heap allocation.