Improve C# code performance with Span - NDepend Blog (original) (raw)

June 1, 2026 9 minutes read

Improve C# code performance with Span of T

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

Here is how Span<T> is declared:

| | 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.

"Span<T

Nowadays, Span<T> lies at the core of .NET, and the majority of .NET Base Class Library APIs support it.

C# Programming with Span

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

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>.

| | int[] numbers = new int[] { 1, 2, 3, 4, 5 };Span<int> numbersSpan = new Span<int>(numbers);// Modifying through the Span will modify the original arraynumbersSpan[0] = 99;foreach (var number in numbers) { Console.WriteLine(number); // Output: 99, 2, 3, 4, 5} | | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |

Slicing with Span

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>.

| | 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 SpanSpan<byte> slice = dataSpan.Slice(3, 5);// Display the contents of the sliceforeach (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

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":

| | string greeting = "123456789";ReadOnlySpan<char> span = greeting.AsSpan();// Access a slice of the string,// a bit like SubString() but with no new string allocationReadOnlySpan<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)uint i = uint.Parse(subStringSpan);// Output the sliceConsole.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:

| | // From an array: implicit conversion, no allocationint[] array = { 10, 20, 30, 40 };Span<int> fromArray = array;// From a List: CollectionsMarshal exposes the backing arrayList<int> list = new() { 10, 20, 30, 40 };Span<int> fromList = CollectionsMarshal.AsSpan(list);// From the stack: nothing touches the heapSpan<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 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 API including extension methods is extensive, with many overloaded methods. Here’s a simplified version:

12345678910111213141516171819 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:

| | 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 used everywhere in .NET API

Span vs. Array

How does Span<T> differ from standard arrays and ArraySegment?

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 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>:

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

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".

Here is a pseudo-code and some diagrams that summarize both approaches:

C# Span<T>

Benchmarking Span 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:

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293 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:

| | 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 | | | ----------------- | ----- | ------ | ---- | ----- | --------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -------- | -------- | -------- | - | -- | -- | -------------------- | -------- | -------- | -------- | - | -- | -- | --------------------- | --------- | -------- | -------- | - | ------ | ----- | |

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 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 source code shows that it contains two fields.

| | 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:

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, ref struct, C#11 ref fields and the scoped keyword

No stack-only restriction with Memory

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.

| | 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 and Span 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 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:

| | 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

What is Span 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 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 and ReadOnlySpan?

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 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 and Memory?

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 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.

This article is brought to you by the team behind NDepend — a proven .NET static analysis tool for improving code maintainability, security, and overall quality. Whether you’re modernizing a legacy .NET application or starting fresh in C#, get started with your free full-featured trial today!