Span<T> and Memory<T> let you work with contiguous memory without allocating new objects, but their real magic is in how they force you to think about memory ownership and lifetimes.

Let’s see this in action. Imagine you have a large byte array, and you want to parse a fixed-size header from it.

byte[] data = new byte[1024];
// ... populate data with some bytes ...

// Without Span<T>
byte[] headerBytes = new byte[16];
Array.Copy(data, 0, headerBytes, 0, 16);
// Now headerBytes is a new allocation.
// You can parse headerBytes here.

// With Span<T>
Span<byte> headerSpan = data.AsSpan(0, 16);
// headerSpan is a view into the original data array. No new allocation.
// You can parse headerSpan directly.

The problem Span<T> and Memory<T> solve is the overhead of creating temporary objects for memory manipulation. When you slice an array or a string in older C# versions, you often ended up with a new array or string, which means a heap allocation and subsequent garbage collection pressure.

Span<T> represents a ref struct that provides a type-safe view into a contiguous region of memory. This region can be an array, a string, a native memory pointer, or even a stack-allocated buffer. The key is "contiguous." Span<T> itself lives on the stack, and it holds a pointer to the start of the memory region and a length. It does not own the memory; it merely points to it.

Memory<T> is similar but can also represent non-contiguous memory and can be stored on the heap. It’s often used when you need to pass memory around that might outlive the current stack frame, or when interacting with APIs that expect an object that can be pinned. Memory<T> has a SpanAccessor property that returns a Span<T>, but this accessor might involve a small overhead if the memory isn’t already contiguous.

Here’s how you might parse that header using Span<T> and some common parsing methods:

// Assuming data is populated and headerSpan is data.AsSpan(0, 16);

// Parse an integer (e.g., a version number) from the first 4 bytes
int version = System.Buffers.Binary.BinaryPrimitives.ReadInt32LittleEndian(headerSpan.Slice(0, 4));

// Parse a short (e.g., a port number) from bytes 4 to 6
short port = System.Buffers.Binary.BinaryPrimitives.ReadInt16LittleEndian(headerSpan.Slice(4, 2));

// Parse a GUID from bytes 8 to 24 (assuming header is 24 bytes for this example)
// Note: You'd need to adjust the initial span length accordingly.
// Guid guid = new Guid(headerSpan.Slice(8, 16).ToArray()); // ToArray() causes allocation
// A better way if the GUID structure is known:
// Guid guid = new Guid(
//     MemoryMarshal.Read<int>(headerSpan.Slice(8, 4)),
//     MemoryMarshal.Read<short>(headerSpan.Slice(12, 2)),
//     MemoryMarshal.Read<short>(headerSpan.Slice(14, 2)),
//     headerSpan.Slice(16, 8).ToArray() // Still an allocation for the byte array part
// );
// The most efficient way involves direct memory reading if the GUID layout matches
// the memory layout, which it usually does.
Guid guid = new Guid(headerSpan.Slice(8, 16).ToArray()); // For simplicity in demo, but be mindful of allocation here.
// A truly zero-allocation GUID parse would require more intricate MemoryMarshal usage.

The Slice method on Span<T> is crucial. It returns a new Span<T> that represents a sub-section of the original span. Crucially, Slice itself is a stack-only operation; it just adjusts the internal pointer and length of the Span<T> struct. No new memory is allocated.

The System.Buffers.Binary namespace is your best friend here. BinaryPrimitives provides static methods for reading primitive types from spans in various endiannesses without any heap allocations.

The constraint that Span<T> is a ref struct means it cannot be boxed, cannot be used as a field in a class (only in other ref structs or value types), and cannot be converted to object. This is how the runtime guarantees it stays on the stack and avoids heap allocations. Memory<T> can be used in classes and boxed, but when you access its Span property, you are still getting a stack-allocated Span<T> that points into the Memory<T>'s underlying buffer.

The real power comes when you combine Span<T> with System.IO.Pipelines or network streams. Many modern .NET APIs, especially those dealing with high-performance I/O, are designed to work with ReadOnlySpan<byte> or Span<byte>. This allows them to read directly from the network buffer or a file stream without copying data into intermediate managed arrays.

One thing that trips people up is the distinction between Span<T> and Memory<T> when dealing with asynchronous operations. You can’t directly return a Span<T> from an async method because Span<T> is a ref struct and cannot be part of the state machine generated for async methods. If you need to hold onto a memory region across an await, you must use Memory<T>. However, even Memory<T> doesn’t guarantee zero allocation if the underlying memory it represents is not contiguous or if you need to create a Span<T> from it in a context where its lifetime is uncertain. MemoryOwner<T> from Microsoft.Buffers can help manage the lifetime of potentially large, contiguous buffers, allowing you to Rent and Return them to a pool, further reducing allocations.

The next step is understanding how to integrate these with System.IO.Pipelines for truly zero-copy network processing.

Want structured learning?

Take the full Csharp course →