Every allocation has a cost: it takes memory, and sooner or later the garbage collector has to clean it up. For years, the way to avoid it in hot paths was unsafe code or hand-made pools. Span<T> and Memory<T> changed that: they let you work on memory as efficiently as native code, while staying type-safe.
A view, not a copy
A Span<T> is a view over a contiguous block of memory: a reference to the first element plus a length. No copy, no extra allocation. The same code works whether the memory is an array, the stack or native memory:
public static void ProcessData(Span<byte> data) { // Same code, regardless of where the memory lives for (int i = 0; i < data.Length; i++) { data[i] = (byte)(data[i] * 2); } } var array = new byte[1000]; ProcessData(array); // heap Span<byte> stackData = stackalloc byte[1000]; ProcessData(stackData); // stack Slice creates another view in O(1), with zero allocations. Finding a word in a sentence without creating a single string looks like this:
public static ReadOnlySpan<char> FindWord(ReadOnlySpan<char> text, int wordIndex) { int currentWord = 0; int start = 0; for (int i = 0; i <= text.Length; i++) { if (i == text.Length || char.IsWhiteSpace(text[i])) { if (currentWord == wordIndex) { // Zero allocations: just a view of the original data return text.Slice(start, i - start); } currentWord++; start = i + 1; } } return ReadOnlySpan<char>.Empty; } Why it's a ref struct
Span<T> is a ref struct: it can only live on the stack. That's what makes it safe and fast, and it comes with rules:
- it can't be a field of a class;
- it can't be boxed or captured by a lambda;
- since C# 13 it can live inside an
asyncmethod, but it can't be used after anawait.
When data has to cross an await or live on the heap, you use Memory<T>: a regular struct that you pass around, and turn into a Span<T> when it's time to touch the data.
public static async Task<int> CountLinesAsync(Stream stream, CancellationToken cancellationToken) { byte[] buffer = ArrayPool<byte>.Shared.Rent(8192); try { int lines = 0; int read; // Memory<T> crosses the await... while ((read = await stream.ReadAsync(buffer.AsMemory(), cancellationToken)) > 0) { // ...Span<T> does the work in between lines += buffer.AsSpan(0, read).Count((byte)'\n'); } return lines; } finally { ArrayPool<byte>.Shared.Return(buffer); } } The buffer comes from ArrayPool<T>, so even the buffer itself isn't a new allocation on every call.
Parsing without substrings
Parsing is where spans shine. An ISO 8601 timestamp, without a single Substring:
public static bool TryParseIsoDateTime(ReadOnlySpan<char> input, out DateTime result) { result = default; if (input.Length != 19 || input[4] != '-' || input[7] != '-' || input[10] != 'T' || input[13] != ':' || input[16] != ':') return false; // Each component is parsed straight from the original characters if (!int.TryParse(input[..4], out int year) || !int.TryParse(input.Slice(5, 2), out int month) || !int.TryParse(input.Slice(8, 2), out int day) || !int.TryParse(input.Slice(11, 2), out int hour) || !int.TryParse(input.Slice(14, 2), out int minute) || !int.TryParse(input.Slice(17, 2), out int second)) return false; if (month is < 1 or > 12 || day < 1 || day > DateTime.DaysInMonth(year, month) || hour > 23 || minute > 59 || second > 59) return false; result = new DateTime(year, month, day, hour, minute, second); return true; } How much does it help?
Here are the examples of the full article, measured on .NET 10 with a simple Stopwatch loop (so treat the numbers as an indication):
| Example | Traditional | With Span / contiguous |
|---|---|---|
CSV with 10,000 rows, Split vs slicing | 3.8 MB allocated per pass | 0 bytes, about 2.3x faster |
100,000 Split + Join | 3 Gen0 collections | none |
| Summing a jagged array vs a contiguous one | baseline | about 1.5x faster |
Speed varies with hardware; the allocations don't. Measure your own hot paths with BenchmarkDotNet.
A few rules
- Accept
ReadOnlySpan<T>in APIs that only read, andSpan<T>when they write. - Use
Memory<T>acrossawait, and get theSpan<T>only where the work happens. - Rent big buffers from
ArrayPool<T>, and always return them in afinally. - Allocate at the edges: call
ToArray()orToString()only when you really need to keep the data.
Want the full story?
This is the short version. The complete C# Insights article goes deeper: the internal layout of Span<T>, native memory, zero-copy operations, buffer management in network-heavy applications, divide-and-conquer algorithms on spans, the full benchmarks, and the cost of the garbage collector and CPU caches.
Read it here: Span and Memory: The memory management revolution in .NET.
Thanks for reading.
