AlgoMaster Logo

Span<T> & Memory<T>

Medium Priority20 min readUpdated June 6, 2026

Span<T> and Memory<T> are how modern C# describes a region of contiguous memory without allocating a new one. They let you slice arrays, strings, and native buffers into views, pass those views around, and parse or transform them in place. This lesson focuses on the memory model: what a span actually is at the byte level, why Span<T> is restricted to the stack, when Memory<T> is the right escape hatch, and the lifetime rules the compiler enforces so neither type ever dangles.

The Problem They Solve

Most code that works with a "piece of a larger buffer" pays for the privilege. string.Substring(2, 5) allocates a new string. array[1..4] allocates a new array. list.GetRange(0, 10) allocates a new list. Each call seems cheap on its own, but when a parser, serializer, or request handler does it thousands of times per second, the garbage collector starts to notice.

Consider a cart receipt that arrives as one long string:

A naive parser splits by |, then splits each row by ,, then converts each field. That is one string[] plus many substrings per receipt. For a checkout service handling a few hundred receipts per second, the GC pressure is real and avoidable.

Span<T> exists so the parser can describe "the price field of the second row" as a window into the original string instead of copying it out. No new string, no new array. The original string stays where it is, and the parser just remembers where the price field starts and how long it is.

string.Substring(start, length) allocates a fresh string and copies every character. someString.AsSpan(start, length) allocates nothing. Same range, zero heap traffic.

Memory<T> solves the same problem in places Span<T> cannot reach: fields of classes, async locals across await, and any value that needs to outlive a single method.

What Span<T> Is at the Byte Level

A Span<T> is a small value type holding two pieces of data: a managed reference to the first element of a region, and an integer length. That is the whole story. The runtime treats it as a ref struct, which means the CLR refuses to put it anywhere other than a stack frame or a register.

The interesting word in that diagram is reference. The span doesn't store a numeric address or a length-prefixed copy. It stores a real managed reference that the garbage collector knows about. If the array moves during a GC compaction, the runtime fixes the span's reference along with every other reference to the array. From the outside, the span just keeps working.

The same span shape can point into different kinds of memory. The most common backing store is a managed array, but a span can also describe:

  • A region inside a string (read-only only, since strings are immutable).
  • A buffer allocated on the stack with stackalloc.
  • A pinned region of native memory (interop with C libraries).
  • A region inside a pooled buffer rented from ArrayPool<T>.

The span itself doesn't care which one. The cost of reading and writing through it is the same as direct array access plus a bounds check, and the JIT often inlines that check away when it can prove the index is valid.

A Span<T> is two CPU words on the stack: a reference and a length. Creating one or slicing one is "set two registers," not an allocation. That is why span operations can run inside the tightest loops without showing up in GC traces.

ReadOnlySpan<T> and Why Strings Need It

Span<T> is writable. Anything you assign through it lands in the underlying memory. That is the right behaviour for arrays and stack buffers, but it would be wrong for a string, because string is an immutable type the runtime can intern and share. If two string references happened to point at the same interned literal, writing through a span would silently mutate both.

The fix is a separate type: ReadOnlySpan<T>. Same shape, same performance, no setter on the indexer. The compiler rejects writes at the call site.

string.AsSpan() returns a ReadOnlySpan<char>, never a writable Span<char>. That is a deliberate API choice: it makes the immutability of string impossible to violate by accident.

A writable Span<T> converts implicitly to ReadOnlySpan<T>, which means a method declared as void Foo(ReadOnlySpan<int> values) accepts both writable and read-only spans. The reverse direction is blocked, as it should be.

Pick ReadOnlySpan<T> for parameters that don't mutate. It allows callers to pass a writable span or a string slice. Picking Span<T> for a parameter that only reads forces every caller to start with a writable buffer for no reason.

Why Span<T> Cannot Leave the Stack

The most surprising rule about Span<T> is that you cannot put one in a field of a class, you cannot box it, and you cannot keep it alive across an await. That set of restrictions follows from one decision: Span<T> is declared as a ref struct.

A ref struct is a value type the CLR pins to a stack frame. It exists for types that hold a managed reference into the interior of another object, like the middle of an array. Letting such a value float around on the heap would break the garbage collector. When the GC compacts the heap, it needs to find every reference into a moving object and rewrite it. Heap-stored references live in known places (object headers, fields, static slots). Stack-stored references live in well-defined stack frames the GC scans during collection. A ref struct is welcome in both. A regular struct sitting inside some List<> on the heap, holding an interior reference, would be invisible to the GC's interior-reference machinery and a bug waiting to happen.

The CLR's answer is to forbid all the places a ref struct could end up on the heap. The compiler enforces a list of rules:

RestrictionReason
Cannot be a field of a classClass instances live on the heap
Cannot be a field of a non-ref structThe enclosing struct could itself be boxed or stored in a class
Cannot be boxed (no object cast)Boxing puts the value on the heap
Cannot be a generic type argumentGeneric instantiations might box, or store the type in heap fields
Cannot be captured by a lambda or local function that escapesCaptures lift locals into a heap closure object
Cannot be a local in an async methodThe state machine for await is a heap object
Cannot be a local in an iterator (yield return)Iterator state is also stored on the heap
Cannot be passed to or returned from async/iterator methodsSame reason

Every one of those rules has the same root cause: anywhere the value would survive past a single stack frame, the runtime can't guarantee the interior reference is still valid.

What you can do is plenty: pass a span as a parameter (it travels down the stack), return one (it travels up the stack to a known caller), assign it to a local variable, store it in another ref struct, and read or write through it as much as you like inside one synchronous method.

The same SumPrices method takes a full array, a slice of an array, or any other contiguous source. The method body never knows or cares which one the caller used.

Slicing Without Copying

Three sources matter in practice: arrays, strings, and lists. Each one has an extension that hands you a span without copying.

A few things deserve attention. T[].AsSpan(start, length) is in the System namespace and works on any single-dimensional array. string.AsSpan(start, length) returns ReadOnlySpan<char>, never the writable form. CollectionsMarshal.AsSpan(List<T>) reaches into the list's private backing array and hands you a span over it; that is fast but fragile, because any subsequent Add or Remove can resize the backing array and invalidate the span. Use the CollectionsMarshal form inside a tight read-only block, not as a long-lived view.

Two patterns combine these sources beautifully. The first is parsing a delimited field with no allocations.

The whole parse runs on slices of the original string. decimal.TryParse(ReadOnlySpan<char>, out decimal) and int.TryParse(ReadOnlySpan<char>, out int) accept the spans directly and read straight from the string's underlying memory. The only allocation is name.ToString(), and that exists because we want to print the name; if we only needed the price math, even that would go away.

string.Split(',') allocates one string[] plus one fresh string per field. Slicing with spans allocates zero. For a CSV file with a million rows and four fields, that is four million substrings the GC never has to chase.

The second pattern is transforming an array in place through a writable span. The next section walks through that.

Reading and Writing Through a Span

Writes through a Span<T> land in the underlying memory. The span is a view, not a copy.

The view[i] *= 0.9m line writes through the span, which writes into cart. There is no intermediate buffer. This is how high-performance code applies transformations to large arrays without a Select(...).ToArray() round-trip.

Span<T> also offers a small handful of methods that operate on the whole region efficiently: Fill, Clear, CopyTo, Reverse, Sort, IndexOf, SequenceEqual, Contains, and a few more. Each one is implemented to take advantage of contiguous memory and the JIT's vectorization.

view.Sort() rearranged the entire cart array in place. view.Slice(0, 5).Reverse() then flipped the first half. No new arrays were involved, and the cart variable saw every change because it shares storage with view.

What Memory<T> Adds

Memory<T> is the heap-friendly counterpart to Span<T>. It carries the same idea (a reference to contiguous memory plus a length) but is itself a regular value type, not a ref struct. That single difference flips all the restrictions:

A Memory<T> can sit in a field, get captured by a lambda, travel across an await, and live in any generic collection. When the code actually needs to read or write, it takes the value's .Span property and works through that span inside a synchronous block. The span is stack-only, the memory is not.

Here is the same capability table side by side.

CapabilitySpan<T>Memory<T>
Field of a classNoYes
Field of a regular structNoYes
Boxed as objectNoYes
Generic type argument (List<...>)NoYes
Captured by escaping lambdaNoYes
Local in async methodNoYes
Local in iteratorNoYes
Read or write directlyYesNo (go through .Span)
Stored only on the stackYesNo

Memory<T> and ReadOnlyMemory<T> mirror the writable and read-only span pair. ReadOnlyMemory<char> is what you get from string.AsMemory(), for the same reason that ReadOnlySpan<char> is what you get from string.AsSpan().

A Span<char> in the Body property would have been a compile error: classes can't hold spans. ReadOnlyMemory<char> is fine because it's a normal value type. The CountItemsAsync method awaits, which means the span has to be taken after the await completes; if Body.Span were captured before the await, the compiler would reject the method for the same reason it rejects spans across awaits. Take the span when you need it, use it inside one synchronous block, let it go.

A Memory<T> is a little heavier than a Span<T>. It stores an object reference plus an index plus a length, and reading .Span goes through a small dispatch (array vs string vs MemoryManager<T>). Inside a tight inner loop, take .Span once and reuse it.

The third source of Memory<T> is pooling. ArrayPool<T>.Shared.Rent(size) hands back an array that may be larger than requested, and the pattern is to wrap the useful slice in a Memory<T> for safe access. The _Object Pooling & ArrayPool_ lesson covers that workflow in detail.

Converting Between Memory<T> and Span<T>

Three conversions cover almost every situation.

Memory<T> -> Span<T> is the .Span property. It's stack-only, so it can be assigned to a local, passed to a method, or used inside one synchronous block. It can't survive an await.

Span<T> -> Memory<T> does not exist as a general operation, and that's intentional. A Span<T> can point at stackalloc memory or other strictly stack-bound storage; promoting it to a heap-storable Memory<T> would let it outlive the stack frame it depends on. If you have a Span<T> and want to hand a Memory<T> to another caller, the answer is to copy the data into a fresh array (or pool buffer) and wrap that.

Memory<T> -> ReadOnlyMemory<T> is implicit. Just assign. Same shape, fewer privileges.

A method that accepts ReadOnlyMemory<T> accepts a writable Memory<T> from the caller, mirroring the spans rule.

Lifetime Rules in Practice

The compiler watches for two specific mistakes around ref struct types and rejects them at build time.

Mistake 1: returning a span that points at local stack memory.

The stackalloc memory belongs to MakeBuffer's stack frame. Once the method returns, that frame is gone and any span pointing at it would be a dangling reference. The compiler refuses, even though the syntax looks fine.

Mistake 2: capturing a span across an `await`.

async methods compile to a state machine object on the heap. Any local that has to survive an await lives as a field on that state machine, and a ref struct can't be a field. The fix is to use Memory<T> for the long-lived value and take .Span after the await finishes:

The Memory<int> value survives the await. The ReadOnlySpan<int> is created after the await completes, used inside one synchronous loop, and discarded before the method returns. Both lifetime rules are satisfied.

A third rule, less obvious, applies to lambdas. A lambda is compiled into a hidden class with one field per captured local. Because Span<T> can't be a class field, it also can't be captured.

A scoped local function (one declared inside the method, not captured) can still take a span as a parameter, because that one stays on the stack. The line that fails is the one that lifts the span into a closure object.

The compiler enforces span lifetime at build time, not runtime. A ref struct violation never crashes a program: it refuses to compile. That's the design point.

Pinning and GetPinnableReference (Briefly)

Span<T> plays nicely with the fixed statement, which pins managed memory so a native pointer remains valid for the duration of a block. The mechanism is GetPinnableReference, a method on Span<T> and ReadOnlySpan<T> that returns a ref T to the first element. The C# compiler recognises that signature and lets fixed (T* p = span) compile.

The fixed block tells the GC "do not move this array while the pointer is alive." Outside the block, the pointer is invalid. Pinning is what makes interop with C libraries work without surprise: the native code can hold the pointer for the duration of the call, and the GC won't compact the array out from under it.

MemoryMarshal and MemoryManager<T> round out the picture for advanced scenarios: building a Memory<T> over native (non-managed) memory, casting one span type to another (Span<byte> over a Span<int>), and dealing with raw bytes for serialization. Those APIs sit at the boundary between safe C# and interop, and most application code never touches them. They exist for the libraries that do, like System.Text.Json and System.IO.Pipelines, which use them to make zero-copy parsing possible end to end.

A Worked Example: Parsing a Cart Receipt

Bringing it together. A cart receipt arrives as one string, fields separated by | and ,. The parser walks the string once and returns the total price, allocating only the receipt ID (because the caller wants one as a string).

Every slice in that method is a span of the original string. IndexOf on a span returns the index without copying. decimal.TryParse and int.TryParse both have ReadOnlySpan<char> overloads that read directly from the slice's memory. The only allocation in the whole parse is idField.ToString(), which exists because the caller wants the ID back as a string. If the rest of the program kept the ID as a ReadOnlySpan<char> too (rare but possible), even that would disappear.

For comparison, the equivalent Split-based parser allocates one string[] for the row split, one string[] per row for the field split, and several substring string instances per row. On a receipt with five items, that is roughly fifteen heap allocations versus one. Multiply by however many receipts per second the service handles.

Choosing Between Span and Memory

The pair of types covers a wide range of scenarios. A quick decision guide:

ScenarioUse
A view into an array or string inside one methodSpan<T> or ReadOnlySpan<T>
A method that accepts arrays, slices, or stack buffers uniformlyReadOnlySpan<T> parameter
A view stored as a field of a classMemory<T> or ReadOnlyMemory<T>
A view that survives an await in async codeMemory<T> then .Span after the await
A view that gets captured by a lambda escapeMemory<T>
Read or write on the buffer (the actual work)Take .Span from the memory, work inside one synchronous block
Pinning for interop with C codefixed (T* p = span)
A heap-backed view that needs to be returned from an async Task<T>Memory<T>

For ordinary cart logic that runs once per checkout, neither type is required: a plain array or List<T> is clearer and just as fast. The pair is useful in parsers, serializers, file readers, request pipelines, and code that's called millions of times per second.

Quiz

Span & Memory Quiz

10 quizzes