Introduction
Most of us learned early that strings and arrays in .NET come with a cost: every time you slice, substring, or extract a portion, you allocate new memory on the heap. Over thousands of operations—parsing CSV lines, tokenizing JSON, scanning log files—those allocations add up to pressure on the garbage collector. Span<T> in everyday code: a hands-on guide for engineers shows you how to slice data without allocating new arrays or strings, keeping your high-throughput systems fast and predictable.
A view over contiguous memory: arrays, strings, and stack
Before we reach for Span<T>, let's ground ourselves in what we're actually looking at. Both arrays and strings sit in contiguous memory regions on the heap. When you call string.Substring(), the runtime allocates a new string object and copies the relevant characters into it. That's wasteful if you only need to inspect or validate a portion of the data.
Span<T> is a stack-allocated, zero-cost abstraction that represents a view over that contiguous memory—whether it's a string, an array, or even a region on the stack itself. The key insight: you're not copying data; you're pointing to it and defining a window.
Consider a common scenario in our trading or e-commerce systems: parsing a CSV line.
// The old way: allocations at every step
string line = "2026-10-22,ORDER,99";
string date = line.Substring(0, 10); // allocation
string type = line.Substring(11, 5); // allocation
string amount = line.Substring(17); // allocation
decimal value = decimal.Parse(amount);
Now with Span<T>:
// The Span<T> way: zero allocations
ReadOnlySpan<char> line = "2026-10-22,ORDER,99";
ReadOnlySpan<char> date = line[..10];
ReadOnlySpan<char> type = line[11..16];
ReadOnlySpan<char> amount = line[(line.LastIndexOf(',') + 1)..];
decimal value = decimal.Parse(amount); // many Parse overloads accept Span<char>
No heap allocations. No garbage collection pressure. The slicing syntax ([..]) creates a new span that points into the original string's memory, nothing more.
Parsing without Substring allocations
Parsing is where Span<T> shines. In production systems handling millions of requests, we often read data from network streams, files, or request bodies as strings or byte arrays. Traditional approaches fragment this data into dozens of temporary strings before extracting the values we need.
Let's look at a realistic example: parsing a log entry from a ride-hailing platform.
void ProcessLogLine(string logLine)
{
// Format: [2026-01-15T14:23:45] DRIVER_ID=D123 TRIP_ID=T456 DISTANCE=12.5 FARE=34.75
ReadOnlySpan<char> line = logLine;
// Extract timestamp
int timestampEnd = line.IndexOf(']');
ReadOnlySpan<char> timestamp = line[1..timestampEnd]; // skip the leading '['
// Extract key-value pairs
int pos = timestampEnd + 2; // skip "] "
while (pos < line.Length)
{
int eqIndex = line[pos..].IndexOf('=');
if (eqIndex == -1) break;
ReadOnlySpan<char> key = line[pos..(pos + eqIndex)];
pos += eqIndex + 1;
int spaceIndex = line[pos..].IndexOf(' ');
int valueEnd = spaceIndex == -1 ? line.Length : pos + spaceIndex;
ReadOnlySpan<char> value = line[pos..valueEnd];
// Use key and value—no copies made
ProcessKeyValue(key, value);
pos = valueEnd + 1;
}
}
void ProcessKeyValue(ReadOnlySpan<char> key, ReadOnlySpan<char> value)
{
// Span<T> methods like SequenceEqual avoid allocations
if (key.SequenceEqual("DISTANCE"))
{
double distance = double.Parse(value, CultureInfo.InvariantCulture);
// process distance
}
else if (key.SequenceEqual("FARE"))
{
decimal fare = decimal.Parse(value);
// process fare
}
}
In this pattern, we iterate through the log line once, creating spans to mark regions of interest. Methods like IndexOf(), SequenceEqual(), and TrimStart() all work on Span<T> and run without allocations. Parsing APIs for numbers, dates, and UUIDs increasingly accept ReadOnlySpan<char> or ReadOnlySpan<byte> overloads.
Stack-only: cannot be stored in fields or used across await
Here's the trade-off. Span<T> is a stack-only value type. You cannot store it in a field, return it from an async method, or pass it through a task. The compiler enforces this at compile time, which is both a safety feature and a practical limitation.
Why? Because Span<T> might point to stack memory. If you stored a Span<T> in a field and returned from the method, the stack frame is gone—and your span now references freed memory. The CLR prevents this by making Span<T> a ref struct, which cannot escape the current scope.
// ❌ Compiler error: cannot store Span<T> in a field
public class DataProcessor
{
private ReadOnlySpan<char> _buffer; // CS8345
}
// ❌ Compiler error: cannot return Span<T> from async method
public async Task<ReadOnlySpan<char>> ParseAsync(string data) // CS4010
{
return data.AsSpan()[..5];
}
// ✅ This is fine: Span<T> used synchronously in method scope
public void ParseSync(string data)
{
ReadOnlySpan<char> slice = data.AsSpan()[..5];
Console.WriteLine(slice);
}
// ✅ For async work, extract the value before crossing await boundary
public async Task ProcessAsync(string data)
{
ReadOnlySpan<char> slice = data.AsSpan()[..5];
string extracted = slice.ToString(); // allocates, but intentionally
await Task.Delay(100);
Console.WriteLine(extracted);
}
In synchronous, hot-path code—HTTP request handlers, message parsers, real-time computations—Span<T> is perfect. Across async boundaries or when you need to hold data longer, you'll extract values (allocating if necessary) or move to Memory<T>.
Memory<T> when you need it on the heap
Memory<T> is the heap-friendly cousin of Span<T>. It's a value type that wraps a region of managed memory and can be stored in fields, passed through async methods, and kept alive as long as needed. The trade-off: you lose the zero-allocation guarantee, but gain flexibility.
Think of Memory<T> as a handle to memory that may outlive the current method. You can slice it (producing another Memory<T>), but to actually work with the data, you convert it to a Span<T>> at the point of use.
// A scenario from our SaaS platform: buffering incoming events
public class EventBuffer
{
private Memory<byte> _buffer;
private int _count;
public EventBuffer(int capacity)
{
_buffer = new byte[capacity];
_count = 0;
}
public void Append(ReadOnlySpan<byte> data)
{
data.CopyTo(_buffer.Span[_count..]);
_count += data.Length;
}
public async Task FlushAsync()
{
// Extract only the used portion
Memory<byte> payload = _buffer[.._count];
// Send via network, convert to Span<T> at point of use
await SendToServerAsync(payload);
_count = 0;
}
private async Task SendToServerAsync(Memory<byte> data)
{
// Convert to Span<T> for synchronous processing
ReadOnlySpan<byte> span = data.Span;
using var stream = new MemoryStream(span.ToArray());
// or work with span directly for validation
await _httpClient.PostAsync("...", new StreamContent(stream));
}
}
Memory<T> is especially useful in libraries and frameworks where methods need to accept or return data without knowing whether it's on the stack or heap. The caller decides whether to use stackalloc for temporary buffers or allocate from the heap.
Common mistakes
- Storing Span<T> in fields or properties. The compiler stops you, but it's worth understanding why: you'd be storing a pointer to stack memory that may be freed. Always use Memory<T> if you need to hold onto data.
- Forgetting that Span<T> has a lifetime. Once the underlying array, string, or stack buffer is reallocated or goes out of scope, your span is invalid. Keep spans local and short-lived.
- Using Span<T> across await boundaries. Tempting in async code, but the compiler won't allow it. Extract the data you need before the await, or switch to Memory<T>.
- Allocating unnecessarily when Span<T> suffices. If you don't need the data after the current method returns, use Span<T>. Calling
.ToString()or.ToArray()on a span defeats the purpose. - Ignoring Span<T> overloads in standard APIs. Many parsing, comparison, and string methods now have Span<T> versions. Use them. Check the docs for
int.TryParse(ReadOnlySpan<char>),string.Create<TState>(int, TState, SpanAction<char, TState>), and similar.
Key takeaways
- Span<T> represents a view over contiguous memory without allocating new data. Use it for slicing strings, arrays, and stack buffers in hot paths.
- Parsing, tokenizing, and validation routines become dramatically cheaper when you work with Span<T> instead of creating temporary strings.
- Span<T> is stack-only; it cannot be stored in fields or passed across async boundaries. The compiler enforces this for safety.
- Memory<T> is the heap-capable alternative when you need to hold data longer or pass it through async code. Convert to Span<T> at the point of use.
- Adopt Span<T> incrementally: start in parsing and comparison code, then expand to other performance-critical sections. Measure the difference—in high-throughput systems, the GC pressure reduction is often significant.






Comments
Loading comments…