.NET & C# Development · Lesson 11 of 229
Span<T> and Memory<T> — Zero-Copy Slicing and High-Performance Buffers
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 siblingArrayPool<T>for buffer reusestackallocfor stack allocationReadOnlySpan<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:
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).
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 5Slicing without allocation
Span<int> left = slice[..2]; // first 2 elements
Span<int> right = slice[2..]; // last 2 elements
// No memory copied, no GC pressure — just pointer arithmetic2. ReadOnlySpan<string> for Parsing
ReadOnlySpan<char> is the zero-allocation alternative to string.Substring.
// 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;
}
}// 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
// .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:
// 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>.
// 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 below4. 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:
// 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)
// 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>:
// 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.
// 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:
// 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:
// 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:
// 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:
// 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:
// 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.