
Learn C# Span
If you have ever profiled a hot code path in .NET and watched the garbage collector eat 30% of your CPU time, you already understand why C# Span<T> exists. Introduced in .NET Core 2.1 and now central to the entire BCL, Span<T> and its heap-friendly sibling Memory<T> let you slice, parse, and transform contiguous memory without allocating a single byte. This tutorial covers how both types work, when to use each, runnable benchmarks that prove the gains, and the pitfalls that trip up even senior developers.
Why C# Span<T> Matters for Performance Optimization
Most everyday C# code allocates far more than developers realise. Calling Substring, Split, ToArray, or Skip().Take() copies data onto the managed heap. Each copy is cheap in isolation, but in a web API handling 5,000 requests per second, those copies become gen0 collections, which become GC pauses, which become p99 latency spikes.
The traditional fix was unsafe pointers — fast, but dangerous and unverifiable. Span<T> gives you the same performance profile with full type and bounds safety. It is a ref struct: a tiny value type holding a reference to the start of a memory block plus a length. Creating a span copies nothing; slicing a span copies nothing. It is a window over memory you already have.
Crucially, a Span<T> can point at three different kinds of memory with one unified API:
- Managed heap — arrays, strings,
List<T>backing stores - Stack memory — via
stackalloc, with zero GC involvement - Unmanaged memory — native buffers from interop or
NativeMemory.Alloc
That unification is the real win. You can write one parsing method that works on all three sources without overloads, without copying, and without unsafe.
C# Span<T> Tutorial: Your First Zero-Allocation Code
Start with the classic allocation trap — parsing a CSV line with Split:
// SLOW: allocates a string[] plus one string per field, every call
public static decimal ParseTotalSlow(string line)
{
string[] parts = line.Split(',');
return decimal.Parse(parts[2]) * int.Parse(parts[3]);
}
For a 4-field line, that is five allocations per call. Now the span version:
using System;
using System.Globalization;
public static decimal ParseTotalFast(ReadOnlySpan<char> line)
{
// Slice off field 0 and field 1 without allocating
for (int i = 0; i < 2; i++)
{
int comma = line.IndexOf(',');
line = line[(comma + 1)..];
}
int next = line.IndexOf(',');
ReadOnlySpan<char> priceSpan = line[..next];
ReadOnlySpan<char> qtySpan = line[(next + 1)..];
decimal price = decimal.Parse(priceSpan, CultureInfo.InvariantCulture);
int qty = int.Parse(qtySpan, CultureInfo.InvariantCulture);
return price * qty;
}
// Usage - implicit conversion from string to ReadOnlySpan<char>
decimal total = ParseTotalFast("SKU-88,Widget,19.99,3");
Total allocations: zero. The Parse overloads that accept ReadOnlySpan<char> have existed since .NET Core 2.1 precisely for this pattern. Note the range syntax line[..next] — C# ranges work natively on spans and are compiled to a Slice call with no copy.
Using stackalloc for Small Buffers
When you need a scratch buffer, stackalloc paired with a span keeps it entirely off the heap:
public static string ToHex(ReadOnlySpan<byte> bytes)
{
// Safe, verifiable stackalloc - no 'unsafe' keyword needed
Span<char> buffer = stackalloc char[bytes.Length * 2];
for (int i = 0; i < bytes.Length; i++)
{
int b = bytes[i];
buffer[i * 2] = GetHexChar(b >> 4);
buffer[i * 2 + 1] = GetHexChar(b & 0xF);
}
return new string(buffer); // one allocation, only for the result
}
private static char GetHexChar(int value) =>
(char)(value < 10 ? '0' + value : 'a' + value - 10);
Critical rule: never stackalloc inside a loop, and never stackalloc a size derived from untrusted input. The stack is typically 1 MB per thread; overflowing it kills the process with no catchable exception. The standard guardrail is a size threshold with an ArrayPool<T> fallback:
using System.Buffers;
const int StackLimit = 256;
char[]? rented = null;
Span<char> buffer = length <= StackLimit
? stackalloc char[StackLimit]
: (rented = ArrayPool<char>.Shared.Rent(length));
try
{
buffer = buffer[..length];
// ... work with buffer ...
}
finally
{
if (rented is not null)
ArrayPool<char>.Shared.Return(rented);
}
This hybrid pattern appears throughout the .NET runtime itself and is the single most valuable idiom in high-performance C#.
Memory<T> vs Span<T>: Choosing the Right Type
Span<T> is a ref struct, which the compiler enforces can only live on the stack. That restriction is what makes it safe — a span can never outlive the memory it points to. But it also means a span cannot:
- Be a field in a class or a regular struct
- Be captured by a lambda or local function closure
- Be used across an
awaitoryield return - Be boxed or stored in a collection
This is where Memory<T> comes in. It is a normal struct that describes the same memory but can live on the heap. You convert it to a span only when you need to touch the data:
public sealed class ChunkReader
{
private readonly Memory<byte> _buffer; // legal: Memory can be a field
public ChunkReader(Memory<byte> buffer) => _buffer = buffer;
public async Task<int> ReadAsync(Stream stream, CancellationToken ct)
{
// Memory<T> works across await; Span<T> would not compile here
int read = await stream.ReadAsync(_buffer, ct);
// Get a span only for synchronous work
Span<byte> window = _buffer.Span[..read];
Normalise(window);
return read;
}
private static void Normalise(Span<byte> data)
{
for (int i = 0; i < data.Length; i++)
if (data[i] == (byte)'\r') data[i] = (byte)'\n';
}
}
The practical rule for C# performance optimization: use Span<T> for synchronous method parameters and locals — it is the faster type and should be your default. Reach for Memory<T> only when you must store the reference in a field or cross an async boundary. Accessing .Span has a small cost, so hoist it out of loops rather than calling it repeatedly.
ReadOnlySpan<T> and the String Advantage
Always prefer ReadOnlySpan<T> for parameters you do not mutate. It documents intent, prevents accidental writes, and accepts more source types — including string, which converts implicitly. There is also a hidden compiler optimisation: a ReadOnlySpan<byte> initialised from a constant array is embedded directly in the assembly's data section with no runtime allocation at all:
// The JIT emits this as a direct pointer into the PE file - zero allocation
private static ReadOnlySpan<byte> JsonPrefix => "{\"data\":"u8;
public static bool StartsWithPrefix(ReadOnlySpan<byte> payload) =>
payload.StartsWith(JsonPrefix);
The u8 suffix (C# 11+) produces a UTF-8 ReadOnlySpan<byte> literal — ideal for network protocol and JSON parsing where you want to avoid transcoding to UTF-16 entirely.
Measuring the Difference with BenchmarkDotNet
Never optimise without measuring. Here is a benchmark you can run today:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
[MemoryDiagnoser]
public class ParsingBenchmarks
{
private const string Line = "SKU-88,Widget,19.99,3";
[Benchmark(Baseline = true)]
public decimal Split() => ParseTotalSlow(Line);
[Benchmark]
public decimal Span() => ParseTotalFast(Line);
}
BenchmarkRunner.Run<ParsingBenchmarks>();
On typical .NET 8/9 hardware the span version runs roughly 2–3x faster and, more importantly, reports 0 B allocated versus around 200 B for the Split version. The [MemoryDiagnoser] attribute is what makes allocation regressions visible — add it to every performance benchmark you write.
Best Practices and Common Pitfalls
- Do not return a span over stackalloc memory. The compiler catches most cases, but returning a span that points at a caller's expired stack frame is undefined behaviour. Return the data, not the window.
- Watch for aliasing. Two spans can overlap the same array.
CopyTohandles overlap correctly, but hand-written loops may not — useMemoryExtensions.Overlapsif you are unsure. - Spans do not keep arrays alive in the pool sense. If you rent from
ArrayPool<T>, return it exactly once and stop using every span derived from it immediately. - Do not micro-optimise cold paths. Span code is harder to read. Apply it to measured hot paths: parsers, serialisers, request pipelines, image and buffer processing. A controller action that runs 10 times a day gains nothing.
- Use the built-in helpers.
MemoryExtensionsgives youIndexOf,Split,Trim,SequenceEqual,BinarySearch, andSorton spans — most are SIMD-accelerated and will beat any loop you write by hand. - Prefer
SequenceEqualover element loops for comparison; it vectorises automatically. - Beware string interning assumptions. A
ReadOnlySpan<char>over a string is not a string — dictionary lookups need.GetAlternateLookup<ReadOnlySpan<char>>()(.NET 9+) to stay allocation-free.
Advanced: Pipelines and SIMD
Once spans are natural to you, the next tier of C# performance optimization is System.IO.Pipelines for streaming I/O and System.Numerics.Vector<T> / System.Runtime.Intrinsics for explicit SIMD. Both build directly on Span<T> and Memory<T>, so the knowledge compounds. Vector.LoadUnsafe and Vector.StoreUnsafe operate on spans, letting you process 16 or 32 bytes per instruction.
Key Takeaways
C# Span<T> is the single highest-leverage tool for eliminating allocations in .NET, and it costs you nothing in safety. To summarise:
Span<T>is a stack-only window over contiguous memory — slicing is free, bounds are checked.Memory<T>is the heap-capable equivalent for fields and async methods; call.Spanfor the actual work.- Default to
ReadOnlySpan<T>parameters; useu8literals for UTF-8 constants. - Combine
stackallocwith anArrayPool<T>fallback above a ~256-element threshold. - Always verify gains with BenchmarkDotNet and
[MemoryDiagnoser]— guessing is how optimisation efforts fail. - Target hot paths only; readability is a real cost and cold code does not repay it.
Rewrite one hot parsing method in your codebase using these patterns, benchmark before and after, and you will have a concrete number to justify the next one.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment