C# Span<T> and ReadOnlySpan<T>, which C# 7.2 introduced in 2017 and which .NET Core fully supports, are highly efficient structures for working with contiguous memory regions in a type-safe way.
Span<T> lets you work with sequences of elements on the thread’s stack, the object’s heap, or even unmanaged memory, all through the same API. Just as importantly, Span<T> removes the runtime overhead of creating substrings or subarrays from existing strings and arrays (a string is, after all, essentially a character array). That combination of versatility and zero-allocation slicing is what makes Span<T> an essential tool for high-performance data processing in C# applications.
In this guide we work through practical Span<T> examples, real BenchmarkDotNet numbers, and the performance reasoning behind them. You will see why Span<T> frequently beats traditional C# code, where it does not help, and how to use it to write faster, more memory-efficient applications.
Understanding Span<T>
Here is how Span<T> is declared:
|
1 2 3 4 5 |
public readonly ref struct Span<T> { private readonly ref T _pointer; private readonly int _length; // ... } |
Let’s notice that Span<T> in C# is not just any structure. Declared as a ref struct, it lives on the stack only, which boosts performance but imposes limitations. This design choice prevents you from using Span<T> as a class field or inside asynchronous methods. In fact, C# 7.2 also introduced the ref struct concept, primarily to enable the proper implementation of Span<T>.
The ref field allows passing values by reference, like a C pointer, creating a ref T on the stack. This makes operations as efficient as arrays since indexing a span doesn’t require extra computations: it inherently tracks the pointer and the offset.
A Span is merely a window on underlying continuous data. It is not a way to allocate data. Span<T> allows read-write access, while ReadOnlySpan<T> is read-only. Multiple spans on the same array create separate views of the same memory.
Nowadays, Span<T> lies at the core of .NET, and the majority of .NET Base Class Library APIs support it.
C# Programming with Span<T>
In this section, we’ll dive into the practical use of Span<T> in C# programming by exploring a few code samples. This will help us understand how Span<T> enhances code performance through more efficient data manipulation and memory management.
Basic Usage of Span<T>
Let’s look at a simple example that demonstrates how to initialize and use Span<T> for basic operations. In this example, we create Span<int> from an array of integers. We then modify the first element of the Span, which also modifies the original array, demonstrating the by-reference nature of Span<T>.
|
1 2 3 4 5 6 7 8 9 |
int[] numbers = new int[] { 1, 2, 3, 4, 5 }; Span<int> numbersSpan = new Span<int>(numbers); // Modifying through the Span will modify the original array numbersSpan[0] = 99; foreach (var number in numbers) { Console.WriteLine(number); // Output: 99, 2, 3, 4, 5 } |
Slicing with Span<T>
Span<T> excels in creating slices of T data without allocating new memory.
Here’s how you can create slices. This example showcases how to slice a Span<byte> to focus on a specific segment of the array without copying the data, demonstrating the efficiency of Span<T>.
|
1 2 3 4 5 6 7 8 9 10 |
byte[] data = new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; Span<byte> dataSpan = new Span<byte>(data); // Create a slice of the original Span Span<byte> slice = dataSpan.Slice(3, 5); // Display the contents of the slice foreach (var val in slice) { Console.WriteLine(val); // Output: 3, 4, 5, 6, 7 } |
The key point is that Slice() returns another Span<byte> that points into the same backing array. No bytes are copied and nothing new lands on the heap. The slice carries only a reference and a length, both living on the stack.
String and ReadOnlySpan<char>
ReadOnlySpan<char> enables efficient, read-only string operations in C# without extra memory allocation. Here’s a simple example of extracting a substring using ReadOnlySpan<char>. In contrast, String.Substring(1, 3) would allocate a new string object containing "234":
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
string greeting = "123456789"; ReadOnlySpan<char> span = greeting.AsSpan(); // Access a slice of the string, // a bit like SubString() but with no new string allocation ReadOnlySpan<char> subStringSpan = span.Slice(1, 3); // Parse the subString as an UInt without having allocated any new string // Notice how convenient is uint.Parse(ReadOnlySpan<char>) uint i = uint.Parse(subStringSpan); // Output the slice Console.WriteLine(i); // Output: 234 |
We use ReadOnlySpan<char> rather than Span<char> here because the content of a .NET string is immutable. The compiler enforces that: you cannot obtain a writable Span<char> over a string, only a read-only view. This is the single most common reason to reach for spans in everyday code, since text parsing, tokenizing and formatting are everywhere.
Where a Span can point
A Span<T> does not care where the memory lives. The same type wraps a managed array, a slice of the stack, or a block of native memory. A few of the common sources:
|
1 2 3 4 5 6 7 8 9 10 |
// From an array: implicit conversion, no allocation int[] array = { 10, 20, 30, 40 }; Span<int> fromArray = array; // From a List<T>: CollectionsMarshal exposes the backing array List<int> list = new() { 10, 20, 30, 40 }; Span<int> fromList = CollectionsMarshal.AsSpan(list); // From the stack: nothing touches the heap Span<byte> fromStack = stackalloc byte[256]; |
A word of caution on CollectionsMarshal.AsSpan(list): the span points directly at the list’s internal array, so you must not add to or remove from the list while the span is alive. If the list resizes, it abandons its old array and your span now references stale memory. Read or overwrite existing elements only, and keep the span short-lived.
Span<T> APIs
We’ve seen that Span<T> excels at optimizing string operations by enabling substring manipulation without memory allocation. However, as a generic type, it works with various data types, including byte. The complete Span<T> API including extension methods is extensive, with many overloaded methods. Here’s a simplified version:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
ref struct Span<T> { Span(T[]? array); Span(T[]? array, int startIndex); Span(T[]? array, int startIndex, int length); unsafe Span(void* memory, int length); int Length { get; } ref T this[int index] { get; set; } Span<T> Slice(int start); Span<T> Slice(int start, int length); public T[] ToArray(); void Clear(); void Fill(T value); void CopyTo(Span<T> destination); bool TryCopyTo(Span<T> destination); } |
Notice above the unsafe constructor that takes a void* pointer. Span can work on any kind of memory including unmanaged memory. Thus Span<T> represents a simple way to work with pointers and unmanaged memory like in this code sample. Also this code is safe, and it does not need the unsafe keyword:
|
1 2 3 4 5 |
Span<byte> stackMemory = stackalloc byte[1024]; IntPtr unmanagedHandle = Marshal.AllocHGlobal(1024); Span<byte> unmanaged = new Span<byte>(unmanagedHandle.ToPointer(), 1024); Marshal.FreeHGlobal(unmanagedHandle); |
Above we were able to call uint i = uint.Parse(subStringSpan); because an overload of uint.Parse(ReadOnlySpan<char>) exists in the .NET Base Class Library (BCL). What truly sets Span<T> and ReadOnlySpan<T> apart is their widespread integration into the BCL. The screenshot below illustrates this. It shows NDepend analyzing the .NET 10 framework in the directory C:\Program Files\dotnet\shared\Microsoft.NETCore.App\10.0.0:
Span<T> vs. Array
How does Span<T> differ from standard arrays and ArraySegment<T>?
Span<T>interacts with the GC differently, making it more efficient in stack-only scenarios.- Unlike
ArraySegment<T>,Span<T>supports both managed and unmanaged memory. ArraySegment<T>lacks a read-only equivalent, whileReadOnlySpan<T>provides one.
The confusion arises because Span<T> is just a view on data, and an array usually backs that data. While arrays remain essential, Span<T> offers a more flexible way to work with them.
When Span<T> is not the right tool
The stack-only nature of Span<T> is exactly what makes it fast, and it is exactly what makes it restrictive. It pays to know the boundaries before refactoring a hot path. The compiler refuses to let a Span<T>:
- live as a field of a class or of a non-
refstruct, since the containing object could sit on the heap; - escape into a lambda or a local function closure;
- cross an
awaitor ayield return, so it cannot survive an asynchronous boundary; - undergo boxing, or serve as a generic type argument.
When you hit one of these walls, the data simply needs to live somewhere that outlasts a single stack frame, and Memory<T> (which we cover below) handles that job. Reach for Span<T> in synchronous, tight, allocation-sensitive code; reach for Memory<T> when you must store the buffer or pass it through async code.
Improving some C# code performance with Span<T>
Now let’s put Span<T> to work and see how it can significantly boost performance in a practical, real-world scenario.
In this section, we will use Span<T> to obtain an array of uint from the string "163,496,691,1729".
- Without
Span<T>one would use"163,496,691,1729".Split(','). This call allocates four strings and an array to reference these four strings. Thenuint.Parse(string)parses each sub-string. - Actually, we will use
ReadOnlySpan<char>because the content of a string is immutable. - With
ReadOnlySpan<T>we slice the input string into four spans. BecauseReadOnlySpan<T>is aref struct, each of its instances occupies only a few bytes on the current thread stack. Stack allocation is super fast and it does not impact the GC. Thenuint.Parse(ReadOnlySpan<char>)parses each slice.
Here is a pseudo-code and some diagrams that summarize both approaches:
Benchmarking Span<T> performance gain
Below is the complete code; paste it into a C# Program.cs source file. To run this benchmark you need to reference the NuGet package BenchmarkDotNet. Here is the github project BenchmarkDotNet. Before digging into Benchmark.NET results, let’s note that:
- A third approach with the method
GetUIntArrayWithAstuteParsing()presents an optimized method for parsing"163,496,691,1729"without the requirement of usingSpan<T>. - In the real world, you may not know the number of
uintin the comma-separated string input in advance. Typically, you would store the parseduintvalues in aList<uint>until you obtain all of them. But here we want to demonstrate thatSpan<T>makes no allocation. Thus, to avoid cluttering the performance result, we pre-allocateuint[] arrayToFillwith the proper length.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 |
using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Order; using BenchmarkDotNet.Running; BenchmarkRunner.Run<UIntParserBenchmarks>(); [RankColumn] [Orderer(SummaryOrderPolicy.FastestToSlowest)] [MemoryDiagnoser] public class UIntParserBenchmarks { // We want to avoid allocating arrays to fill during benchmarks // thus s_NbUInt pre-determines their length const int s_NbUInt = 4; const string s_CommaSeparatedUInt = "163,496,691,1729"; uint[] m_ArrayToFill1 = new uint[s_NbUInt]; [Benchmark(Baseline = true)] public void GetUIntArrayWithSplit() { GetUIntArrayWithStringSplit(s_CommaSeparatedUInt, m_ArrayToFill1); } uint[] m_ArrayToFill2 = new uint[s_NbUInt]; [Benchmark] public void GetUIntArrayWithSpan() { GetUIntArrayWithSpan(s_CommaSeparatedUInt, m_ArrayToFill2); } uint[] m_ArrayToFill3 = new uint[s_NbUInt]; [Benchmark] public void GetUIntArrayWithAstuteParsing() { GetUIntArrayWithAstuteParsing(s_CommaSeparatedUInt, m_ArrayToFill3); } static uint[] GetUIntArrayWithStringSplit(string commaSeparatedUInt, uint[] arrayToFill){ // Split() allocates an array and 4x strings string[] arrayOfString = commaSeparatedUInt.Split(','); var length = arrayOfString.Length; for (int i = 0; i < length; i++) { arrayToFill[i] = uint.Parse(arrayOfString[i]); } return arrayToFill; } static void GetUIntArrayWithSpan(string commaSeparatedUInt, uint[] arrayToFill) { // View the string as a span, so we can slice it in loop ReadOnlySpan<char> span = commaSeparatedUInt.AsSpan(); int nextCommaIndex = 0; int insertValAtIndex = 0; bool isLastLoop = false; while (!isLastLoop) { int indexStart = nextCommaIndex; nextCommaIndex = commaSeparatedUInt.IndexOf(',', indexStart); isLastLoop = (nextCommaIndex == -1); if (isLastLoop) { nextCommaIndex = commaSeparatedUInt.Length; // Parse last uint } // Get a slice of the string that contains the next uint... ReadOnlySpan<char> slice = span.Slice(indexStart, nextCommaIndex - indexStart); // ... and parse it uint valParsed = uint.Parse(slice); // Then insert valParsed in arrayToFill arrayToFill[insertValAtIndex] = valParsed; insertValAtIndex++; // Skip the comma for next iteration nextCommaIndex++; } } static void GetUIntArrayWithAstuteParsing(string commaSeparatedUInt, uint[] arrayToFill){ var length = commaSeparatedUInt.Length; int insertValAtIndex = 0; int valParsed = 0; // Don't use a uint to avoid casting in astute parsing formula for (int i = 0; i < length; i++) { char @char = commaSeparatedUInt[i]; if (@char != ',') { // Astute Parsing: Modify valParsed from the actual @char valParsed = valParsed * 10 + (@char - '0'); continue; } // A comma is an opportunity to insert valParsed in arrayToFill arrayToFill[insertValAtIndex] = (uint)valParsed; insertValAtIndex++; valParsed = 0; } // Insert last valParsed arrayToFill[insertValAtIndex] = (uint)valParsed; } } |
Reading the benchmark results
For each case, Benchmark.NET measures both memory allocation and duration. Here is how it presents the results:
|
1 2 3 4 5 |
Method | Mean | Error | StdDev | Rank | Gen 0 | Allocated | ----------------------------- |----------:|---------:|---------:|-----:|-------:|----------:| GetUIntArrayWithAstuteParsing | 18.46 ns | 0.162 ns | 0.151 ns | 1 | - | - | GetUIntArrayWithSpan | 79.99 ns | 1.247 ns | 1.166 ns | 2 | - | - | GetUIntArrayWithSplit | 129.36 ns | 1.464 ns | 1.369 ns | 3 | 0.0293 | 184 B | |
GetUIntArrayWithAstuteParsing()is the fastest way and doesn’t allocate anything. The performance gain comes from the fact that we wrote our own dedicateduintparsing implementation. This clearly illustrates that, despite the presence of new features in the framework, the best performance often results from well-thought-out algorithms.GetUIntArrayWithSpan()is 38% faster thanGetUIntArrayWithSplit(). This is already a significant win. However, the core of performance gain is that there is no heap allocation. In a real-world scenario where this method parses millions ofuintvalues, you would save a lot of GC pressure.
It is worth doing the arithmetic on that last point, because the headline percentage undersells it. The Split() version allocates 184 bytes every call. Parse one million such lines, something a log processor or a CSV importer does without blinking, and you have handed the garbage collector roughly 184 MB of short-lived garbage to collect. The span version hands it nothing. The CPU time saved is nice; the collections that never happen are what you feel under sustained load, when GC pauses would otherwise show up as latency spikes.
Note too that the hand-written parser still wins. Span<T> is not magic pixie dust you sprinkle on slow code, it is a tool that removes the allocation tax from the natural, slicing-based way of expressing a problem. When you can also remove the per-character work, as the astute parser does, do both.
Explanations About the Magic Behind Span<T> Implementation
Many articles discussing Span<T> tend to conclude at this point. We’ve introduced an efficient approach to sidestep the need for allocating sub-strings. However, the critical aspect lies in the substantial runtime modifications necessary to achieve this performant implementation of Span<T>. Let’s explain what happened.
The Span<T> source code shows that it contains two fields.
|
1 2 3 4 5 6 7 8 |
public readonly ref struct Span<T> { //A managed pointer (ref field is a new C#11 feature) internal readonly ref T _reference; //The number of elements this Span contains. private readonly int _length; ... } |
The _length value is internally multiplied by sizeof(T) to obtain the offset address of the slice. Thus the slice in memory is the range [_reference, _reference + _length*sizeof(T)].
_reference is a managed pointer field (or ref field). C# 11 and .NET 7.0 added the ref field feature. Before that, the implementation of Span<T> (in .NET 6.0 and before…) used an internal trick to reference a managed pointer through an internal ref struct struct named ByReference<T>.
Span<T> carries the ref struct modifier. A structure marked with ref is special: it can live only on the thread stack. This way it can hold a managed pointer as a field (ref field explained above).
The advantages of managed pointers
C# 7.2 introduced ref struct just to make the implementation of Span<T> through a managed pointer possible. If the .NET team achieved all these efforts this is because the Span<T> implementation, which builds on a managed pointer, has significant advantages:
- Safe: Managed pointers are pointers but they belong to the safe world. There is no need to declare an
unsafescope to work withSpan<T>. - Performance wise: The performance overhead of
Span<T>is nearly negligible. This is because managed pointers, even though they are managed, are essentially regular pointers. Consequently, they incur minimal overhead. The management of these pointers includes two key aspects:- A) the C# compiler refuses code that could lead to a managed pointer pointing to an invalid memory and
- B) if a managed pointer points to an object on the heap, the runtime automatically handles the updating of such pointers in the event of the GC relocating the referenced object
- Flexibility: A managed pointer can point to various types of memory, including objects on the heap, unmanaged buffer, value on the stack, field within an object, a slot within an array, or a position within a string. The
Span<T>implementation benefits from this flexibility making its API and implementation concise. Because the pointed-to memory has the typeref T, you need not bother whether it’s a string, a slot of an array or a location on the stack. - Thread safe: A fortunate consequence of being stack-only is that a
Span<T>instance belongs to a single thread. This makesSpan<T>de-facto thread-safe.
Managed pointer, ref struct , ref field, extended usage of the keyword ref, is an interesting topic and we dedicated an entire article to it: Managed pointers, Span<T>, ref struct, C#11 ref fields and the scoped keyword
No stack-only restriction with Memory<T>
The same release that shipped System.Span<T> and System.ReadOnlySpan<T> also brought the structures System.Memory<T> and System.ReadOnlyMemory<T>.
Memory<T> shares similarities with Span<T> but it is a regular structure. It doesn’t have the ref struct stack-only restrictions. This makes it suitable for use as a field in a class, for instance. However, this lack of constraint also means Memory<T> doesn’t have this special relation with the GC. Consequently, it is slightly less performant. This performance loss arises from the fact that its implementation has 3x fields instead of 2x: instead of having a special ref pointer, Memory<T> needs to reference both the _object and then the _index in the object.
|
1 2 3 4 5 6 7 8 |
public readonly struct Memory : IEquatable<Memory> { // NOTE: With the current implementation, Memory and ReadOnlyMemory must have the same layout, // as code uses Unsafe.As to cast between them. private readonly object? _object; private readonly int _index; private readonly int _length; ... } |
I wanted to benchmark the comma-separated string code above with Memory<T>. Then I realized that there is no uint.Parse(Memory<T>) API which suggests Memory<T> didn’t get as much love as Span<T>. The intended pattern is to store and pass around the Memory<T>, then call its .Span property at the last moment, inside the synchronous code that actually touches the bytes. The official Memory<T> and Span<T> usage guidelines from Microsoft put it as a simple rule: prefer Span<T> for synchronous parameters, fall back to Memory<T> only when the buffer must cross an asynchronous boundary or live on the heap, and use the ReadOnly variants whenever the callee should not write.
Span<T> and the .NET Framework
Because Span<T> and ref fields imply significant updates on the runtime GC, the .NET team never ported them to the .NET Framework. They only run on the .NET Core runtime (.NET 7, .NET 8…) since version 2.1. Here is a Microsoft engineers discussion about it: “Fast Span is too fundamental change to be quirklable in reasonable way.”.
However the implementation of Span<T> exists for .NET Framework. Developers refer to it as slow span. To use it, reference the Nuget package System.Memory from your .NET Framework project. This implementation is similar to the Memory<T> implementation with 3x fields:
|
1 2 3 4 5 6 |
public readonly ref partial struct Span<T> { private readonly Pinnable<T> _pinnable; private readonly IntPtr _byteOffset; private readonly int _length; ... } |
Also when referencing the System.Memory package from a .NET Framework project you won’t get APIs similar to uint.Parse(Span<T>) which makes it less attractive.
Frequently Asked Questions about Span<T>
What is Span<T> in C#?
Span<T> is a ref struct that C# 7.2 introduced; it represents a type-safe, allocation-free window over a contiguous block of memory. That memory can be a managed array, a slice of a string, a region of the stack obtained with stackalloc, or an unmanaged buffer. The span itself stores only a managed pointer and a length, so creating one or slicing it never touches the heap.
Does Span<T> really improve performance?
Yes, in the right places. Its main contribution is eliminating allocations: slicing a string or an array with a span produces no new object, so the garbage collector has nothing extra to track or collect. In the benchmark above the span-based parser runs about 38% faster than string.Split and allocates zero bytes versus 184 bytes per call. The gain grows with volume, since the allocations you avoid are allocations the GC never has to reclaim.
What is the difference between Span<T> and ReadOnlySpan<T>?
Both are stack-only views over contiguous memory. Span<T> allows reading and writing through its indexer, whereas ReadOnlySpan<T> only allows reading. You must use ReadOnlySpan<char> for strings, because string content is immutable and the runtime will not hand out a writable view over it.
Can Span<T> be used in async methods?
No. Because Span<T> is a ref struct that must live on the stack, it cannot survive an await or a yield return, and it cannot be a field of a class. When you need a buffer that outlives a single synchronous call, or that crosses an async boundary, use Memory<T> (or ReadOnlyMemory<T>) and obtain a Span<T> from its .Span property at the moment you process the data.
What is the difference between Span<T> and Memory<T>?
Span<T> is stack-only and slightly faster, with two fields (a managed pointer and a length). Memory<T> is an ordinary struct that can live on the heap, sit in a class field and travel across async calls, at the cost of a third field and a small amount of overhead. Use Span<T> in synchronous, allocation-sensitive code; use Memory<T> when you must store the buffer or pass it through asynchronous code.
Is Span<T> available in .NET Framework?
A version of it is. The fast, runtime-integrated Span<T> only exists on .NET Core and later (.NET Core 2.1, .NET 7, .NET 8, .NET 9…). On the older .NET Framework you can reference the System.Memory NuGet package to get a “slow span” implementation that works but lacks the runtime support and many of the convenient BCL overloads.
Conclusion
In this article, we explored Span<T> and ReadOnlySpan<T> and their role in optimizing performance.
These structures are integral to the .NET Base Class Library, requiring significant runtime changes to enhance efficiency in performance-critical scenarios. While not essential for every use case, they can be a game-changer for those who need them.




Excellent article !