Span<T>, ReadOnlySpan<T> and Memory<T> let .NET code work with slices of arrays, strings, stack memory and native buffers without copying them or allocating new objects. They are the foundation of the performance gains in modern .NET, from Kestrel and System.Text.Json to int.Parse. This guide is for developers who write performance-sensitive code: it explains how spans relate to the stack and the heap, why Span<T> is a ref struct and what rules that imposes, how to parse text and use stackalloc safely, when to switch to Memory<T> and ArrayPool<T>, and what SearchValues, collection expressions and the C# 14 implicit span conversions change.
What Are Span<T> and Memory<T>?#
A Span<T> is a view over a contiguous region of memory: essentially a reference to the first element plus a length. Creating one, whether with AsSpan(), Slice, a range expression or a collection expression, never copies the underlying data. The memory behind a span can be:
- a managed array (
T[]), or part of one; - a string, through
ReadOnlySpan<char>; - stack memory from
stackalloc; - native memory from a pointer, for interop scenarios.
ReadOnlySpan<T> is the read-only counterpart, and the one you should accept in APIs that only read. Memory<T> and ReadOnlyMemory<T> represent the same idea in a form that can live on the heap, so they work in fields, collections, lambdas and async methods. Code converts a Memory<T> into a Span<T> through its .Span property whenever it needs to do the actual work.
How Spans Work: Stack, Heap and Why Span Is a Ref Struct#
In .NET, class instances live on the garbage-collected heap, while locals and parameters, including struct values, typically live on the stack or in registers. Heap allocations are cheap to make but not free: every object adds work for the garbage collector, and short-lived garbage in hot paths shows up as more frequent collections. Spans exist to process data in place, whatever memory it lives in, without creating new heap objects for each substring or sub-array.
Internally, Span<T> is declared as a readonly ref struct that holds a ref T field and an int length. The ref T is a managed pointer that can point into the middle of an array, a string or a stack frame. That design explains every restriction on spans:
- It can point to stack memory. A span over a
stackallocbuffer becomes invalid when the method returns, so the compiler must prevent it from escaping to anywhere that outlives the method. - It holds a managed interior pointer. The garbage collector tracks such pointers on the stack, not inside heap objects, so a span cannot be stored in a class field.
- It is a multi-field struct. If a span could live on the heap, another thread could observe a torn value with the pointer of one span and the length of another, breaking memory safety.
The ref struct modifier makes the compiler enforce these rules, and C# 11 added ref fields and the scoped modifier so that library authors can write their own span-like types with the same guarantees. The runtime keeps improving on the heap side as well: .NET 9 and .NET 10 taught the JIT to stack-allocate some objects that provably do not escape, such as small arrays and certain delegates. That is a welcome bonus, but it is an optimization you cannot rely on, whereas spans give you guarantees you control.
Parsing Text with ReadOnlySpan<char>#
Parsing is where spans pay off most. A typical string.Split approach allocates an array plus one string per field on every call. A span-based parser slices the input and hands each slice straight to a span-aware parser:
using System.Globalization;
public readonly record struct OrderLine(int ProductId, int Quantity, decimal UnitPrice);
public static class OrderLineParser
{
// Parses "1042;3;19.99" without allocating substrings or arrays.
public static bool TryParse(ReadOnlySpan<char> line, out OrderLine result)
{
result = default;
Span<Range> fields = stackalloc Range[4]; // one spare slot detects extra fields
if (line.Split(fields, ';') != 3)
{
return false;
}
var culture = CultureInfo.InvariantCulture;
if (!int.TryParse(line[fields[0]], NumberStyles.None, culture, out var productId) ||
!int.TryParse(line[fields[1]], NumberStyles.None, culture, out var quantity) ||
!decimal.TryParse(line[fields[2]], NumberStyles.Number, culture, out var price))
{
return false;
}
result = new OrderLine(productId, quantity, price);
return true;
}
}MemoryExtensions.Split with a destination Span<Range> arrived in .NET 8 and writes the boundaries of each field into the stack buffer. When the number of fields is unknown, .NET 9 adds Split overloads that return an enumerator of Range values, so you can foreach over segments without any buffer at all. .NET 9 also lets dictionaries and hash sets look up string keys with a ReadOnlySpan<char> through GetAlternateLookup, so counting words or caching by key no longer forces a string allocation per lookup:
var counts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var lookup = counts.GetAlternateLookup<ReadOnlySpan<char>>();
ReadOnlySpan<char> text = "red green RED blue green red";
foreach (Range range in text.Split(' '))
{
ReadOnlySpan<char> word = text[range];
// A string key is created only the first time a word is seen.
lookup[word] = lookup.TryGetValue(word, out int count) ? count + 1 : 1;
}
Console.WriteLine(counts["red"]); // 3stackalloc and ArrayPool: Temporary Buffers Without Garbage#
stackalloc gives you a buffer that costs almost nothing to create and disappears when the method returns. Assigned to a Span<T>, it needs no unsafe context. The stack is small, though, and a StackOverflowException terminates the process, so the standard pattern is to use the stack for small sizes and rent from ArrayPool<T> for larger ones:
using System.Buffers;
using System.Text;
public static class HexEncoder
{
private const int StackLimit = 256;
public static string ToHex(string input)
{
int maxBytes = Encoding.UTF8.GetMaxByteCount(input.Length);
byte[]? rented = null;
Span<byte> buffer = maxBytes <= StackLimit
? stackalloc byte[StackLimit]
: (rented = ArrayPool<byte>.Shared.Rent(maxBytes));
try
{
int written = Encoding.UTF8.GetBytes(input, buffer);
return Convert.ToHexString(buffer[..written]);
}
finally
{
if (rented is not null)
{
ArrayPool<byte>.Shared.Return(rented); // clearArray: true for sensitive data
}
}
}
}A few rules keep this pattern safe. Use a constant size for stackalloc rather than a size computed from input, and never allocate on the stack inside a loop; allocate once and reuse the buffer. Treat stack memory as uninitialized until you write to it. ArrayPool<T>.Rent may return an array larger than you asked for, and its contents are not cleared, so always slice to the length you actually use. Once you return an array, you give up ownership: never touch it again, and never return it twice.
Ref Struct Rules in Practice#
Because Span<T> is a ref struct, any type that contains a span must be a ref struct too. That lets you build zero-allocation helpers such as tokenizers, with the compiler guarding every use:
public ref struct TokenReader
{
private ReadOnlySpan<char> _remaining;
public TokenReader(ReadOnlySpan<char> text) => _remaining = text;
public bool TryRead(out ReadOnlySpan<char> token)
{
_remaining = _remaining.TrimStart(' ');
if (_remaining.IsEmpty)
{
token = default;
return false;
}
int end = _remaining.IndexOf(' ');
token = end < 0 ? _remaining : _remaining[..end];
_remaining = end < 0 ? default : _remaining[end..];
return true;
}
}
// Usage: var reader = new TokenReader("GET /orders HTTP/1.1");
// while (reader.TryRead(out var token)) { ... }
//
// class Holder { TokenReader _reader; } // error: ref struct field in a class
// object boxed = new TokenReader("a b"); // error: a ref struct cannot be boxed
// Func<int> f = () => reader.GetHashCode(); // error: lambdas cannot capture a ref structThe complete rule set for ref struct types:
- They cannot be array elements, fields of classes or of ordinary structs, or boxed to
objectorValueType. - They cannot be captured by lambdas or local functions.
- Since C# 13, they can be used in
asyncmethods and iterators, but not across anawaitor ayield return. Before C# 13 they were banned from both entirely. - Since C# 13, they can implement interfaces, although they can never be converted to the interface type, because that would box them.
- Since C# 13, generic code can accept them when the type parameter declares
where T : allows ref struct. .NET 9 annotated many APIs this way, which is what makes span-keyed dictionary lookups possible.
When the compiler rejects a span usage, it is protecting you from a dangling reference, not being pedantic. The fix is usually to switch to Memory<T> or to restructure the code so the span stays within one synchronous method.
Memory<T> for Asynchronous Code#
A Span<T> local cannot live across an await, because the state machine that preserves locals between awaits is a heap object. Memory<T> fills that gap: store it, pass it to async methods, then take .Span inside the synchronous parts:
using System.Buffers;
public static class LineCounter
{
public static async Task<long> CountLinesAsync(Stream stream, CancellationToken ct)
{
byte[] rented = ArrayPool<byte>.Shared.Rent(64 * 1024);
try
{
Memory<byte> buffer = rented; // Memory<T> may cross an await; Span<T> may not
long lines = 0;
int read;
while ((read = await stream.ReadAsync(buffer, ct)) > 0)
{
lines += buffer.Span[..read].Count((byte)'\n'); // synchronous work on a span
}
return lines;
}
finally
{
ArrayPool<byte>.Shared.Return(rented);
}
}
}The official usage guidelines boil down to a few rules. Accept Span<T> or ReadOnlySpan<T> in synchronous APIs and Memory<T> or ReadOnlyMemory<T> in asynchronous ones. A method that receives a Memory<T> must not keep using it after it returns, or after its returned task completes, unless ownership was explicitly transferred. Ownership is expressed with IMemoryOwner<T>, for example from MemoryPool<T>.Rent: whoever holds the owner must dispose of it or hand it off, but never both. For high-throughput streaming, System.IO.Pipelines builds on these types and manages buffers for you.
SearchValues, Collection Expressions and params Spans#
Recent releases added APIs and language features that make span-based code both faster and more natural to write.
SearchValues<T> (.NET 8) precomputes an optimized, usually vectorized, search strategy for a fixed set of values. Create it once in a static field and pass it to IndexOfAny, ContainsAny and related methods. .NET 9 extended it to multiple strings with a chosen StringComparison:
using System.Buffers;
public static class LogScrubber
{
private static readonly SearchValues<char> Separators = SearchValues.Create(",;|\t");
private static readonly SearchValues<string> SecretMarkers =
SearchValues.Create(["password=", "secret=", "apikey="], StringComparison.OrdinalIgnoreCase);
public static int FirstSeparator(ReadOnlySpan<char> line) => line.IndexOfAny(Separators);
public static bool MayContainSecret(ReadOnlySpan<char> line) =>
line.IndexOfAny(SecretMarkers) >= 0;
}Collection expressions (C# 12) target spans directly, and the compiler picks the cheapest storage. A ReadOnlySpan<T> of constants can be created without allocating an array, and a Span<T> can use stack storage when that is ref-safe. C# 13 extended params to spans, and .NET 9 added more than 60 params ReadOnlySpan<T> overloads, so calls like string.Join(", ", a, b, c) stop allocating a hidden array once you recompile:
ReadOnlySpan<int> primes = [2, 3, 5, 7, 11]; // no array allocation
Span<int> counters = [0, 0, 0, 0]; // may live on the stack
ReadOnlySpan<byte> gifHeader = "GIF89a"u8; // UTF-8 literal in the assembly's data
Console.WriteLine(Sum(1, 2, 3)); // arguments passed on the stack, not in an array
Console.WriteLine(string.Join(", ", "a", "b", "c")); // binds to params ReadOnlySpan<string?>
static int Sum(params ReadOnlySpan<int> values)
{
var total = 0;
foreach (var value in values)
{
total += value;
}
return total;
}Implicit Span Conversions in C# 14#
C# 14 makes Span<T> and ReadOnlySpan<T> first-class citizens of the language. Implicit conversions between T[], Span<T>, ReadOnlySpan<T> and string now participate in extension method lookup and generic type inference, so span-based APIs work on arrays without explicit AsSpan() calls:
int[] values = [5, 3, 8];
bool hasEight = values.Contains(8); // C# 14: binds to MemoryExtensions.Contains on a span
PrintAll(values); // C# 14: T inferred through the array-to-span conversion
static void PrintAll<T>(ReadOnlySpan<T> items)
{
foreach (var item in items)
{
Console.WriteLine(item);
}
}The feature is mostly invisible, but its specification documents a few behavior changes worth knowing when you upgrade to C# 14:
Reverseon arrays.array.Reverse()could bind to the in-placeMemoryExtensions.Reverse, which returnsvoid. .NET 10 adds an array-specificEnumerable.Reverseoverload to keep existing code working.- Expression trees. Inside an expression tree,
array.Contains(x)now binds to the span-basedMemoryExtensions.Contains. The expression interpreter cannot execute that, and LINQ providers must recognize the new method. CallEnumerable.Containsexplicitly if you hit this. - New ambiguities and covariance. Some overload sets, for example in test assertion libraries, can become ambiguous, and span overloads throw
ArrayTypeMismatchExceptionfor covariant arrays..AsSpan()or.AsEnumerable()resolves either case.
The upcoming .NET 11 continues the theme on the runtime side: its JIT removes more bounds checks in span code, for example after a !span.IsEmpty guard and for index-from-end access such as values[^1].
Benchmarking Span-Based Code#
Spans are a tool for hot paths, not a style to apply everywhere, so measure before and after with BenchmarkDotNet and its memory diagnoser:
using System.Globalization;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run<ParsingBenchmarks>();
[MemoryDiagnoser]
public class ParsingBenchmarks
{
private const string Line = "1042;3;19.99";
[Benchmark(Baseline = true)]
public decimal WithSplit()
{
string[] parts = Line.Split(';');
return int.Parse(parts[1], CultureInfo.InvariantCulture)
* decimal.Parse(parts[2], CultureInfo.InvariantCulture);
}
[Benchmark]
public decimal WithSpans() =>
OrderLineParser.TryParse(Line, out var line) ? line.Quantity * line.UnitPrice : 0m;
}By construction, the Split version allocates a string[] and one string per field on every call, while the span version should report no managed allocations in the Allocated column. Whether the time difference matters depends on how often the code runs, so run the benchmark in Release mode on hardware close to production. The BenchmarkDotNet guide covers methodology, and the high-performance .NET guide puts allocation reduction in context with other techniques.
Best Practices#
- Accept the most general read-only type. Use
ReadOnlySpan<T>parameters in synchronous APIs andReadOnlyMemory<T>in asynchronous ones; callers can pass arrays, strings and slices. - Offer
Trymethods that write into a caller-provided span, such asTryFormat(Span<char>, out int), so callers control buffers. - Keep
stackallocsmall and constant, with anArrayPool<T>fallback for larger inputs. - Create
SearchValuesonce and store it in astatic readonlyfield. - Return pooled arrays in
finallyblocks, and clear them when they held sensitive data. - Prefer spans in hot paths only. Readable LINQ and string code remains the right choice for the vast majority of code, as the garbage collection guide shows when allocation actually costs you.
Common Pitfalls#
- Unbounded
stackalloc. Sizes derived from user input can overflow the stack and crash the process. - Using a rented array after returning it, or returning it twice. Both corrupt other users of the pool.
- Ignoring the rented length.
Rentcan return a larger array; slice to the requested size. - Holding a span over a
List<T>while it changes. A span fromCollectionsMarshal.AsSpanpoints at the old backing array once the list grows. - Calling
ToString()orToArray()in loops. They allocate, quietly undoing the benefit of spans. - Upgrading to C# 14 without running tests. Watch for the
Reverse, expression tree and ambiguity changes described above.
Span vs Memory vs Arrays: When to Use Each#
| Type | Kind | Can be stored in fields | Usable across await | Typical backing memory | Best for |
|---|---|---|---|---|---|
T[] | Class | Yes | Yes | Managed heap | Owning storage |
Span<T> | ref struct | Only in a ref struct | No | Array, stackalloc, native memory | Synchronous hot paths that write |
ReadOnlySpan<T> | ref struct | Only in a ref struct | No | Array, string, constant data, stack | Parsing and read-only APIs |
Memory<T> | Struct | Yes | Yes | Array or MemoryManager<T> | Async I/O and stored buffers |
ReadOnlyMemory<T> | Struct | Yes | Yes | Array, string or MemoryManager<T> | Async read-only data |
ArraySegment<T> | Struct | Yes | Yes | Array only | Older APIs that predate spans |
Frequently Asked Questions#
Why can't I use Span<T> in an async method?#
Locals that live across an await are stored in a heap-allocated state machine, and a span must never live on the heap because it may point to stack memory. Since C# 13 you can use spans in async methods as long as they are not live across an await. For data that must survive an await, use Memory<T> and take .Span in the synchronous parts.
What is the difference between Span<T> and Memory<T>?#
Both describe a contiguous region of memory without copying it. Span<T> is a ref struct that is faster and can wrap stack or native memory, but it is limited to the stack. Memory<T> is a regular struct that can be stored in fields and used across await, and it exposes a Span property for the actual processing.
Is stackalloc safe to use in C#?#
Yes, when assigned to a Span<T>, stackalloc needs no unsafe code and the compiler prevents the span from escaping the method. The risks are size-related: keep allocations small and constant, avoid them in loops, and fall back to ArrayPool<T> for large or input-dependent sizes.
Does using Span<T> automatically make code faster?#
No. Spans remove copies and allocations, which helps hot paths that process lots of data, but they do not speed up code that is dominated by I/O or rarely executed. Measure with BenchmarkDotNet before and after, and keep simpler code where the gain is negligible.
What changed for spans in C# 14?#
C# 14 adds implicit span conversions that work with extension methods and generic type inference, so span-based APIs apply directly to arrays. The change can alter overload resolution in a few cases, such as Reverse on arrays and Contains inside expression trees, so recompile and run your tests when upgrading.
Summary#
Span<T>andReadOnlySpan<T>are zero-copy views over arrays, strings, stack and native memory;Memory<T>is their heap-friendly counterpart for async code.ref structrules exist because spans can point to stack memory and contain managed interior pointers.- Parse with
ReadOnlySpan<char>, usestackallocfor small constant buffers andArrayPool<T>for large ones. SearchValues, span-basedSplit, alternate dictionary lookups andparams ReadOnlySpan<T>remove allocations in common scenarios.- C# 14 makes span conversions implicit and first-class; review the documented behavior changes when upgrading.