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 async method, but it can't be used after an await.

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, and Span<T> when they write.
  • Use Memory<T> across await, and get the Span<T> only where the work happens.
  • Rent big buffers from ArrayPool<T>, and always return them in a finally.
  • Allocate at the edges: call ToArray() or ToString() 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.