ReadOnlySpan<T> in Practice: Slicing Strings Without the Garbage
Substring() copies data to the heap. ReadOnlySpan<T> gives you a view into the original buffer instead. Here is how that changes parsing loops, where it stops working, and how to verify the difference.
17 Sept 2025, 09:19 UTC

The allocation you do not see
You are parsing a 50 MB log file line by line. Every call to Substring() drops a new string onto the heap. The garbage collector wakes up more often. Your throughput tanks, and the profiler shows thousands of small allocations per second. The fix is not a faster regex or a bigger server. It is changing how you look at the data.
ReadOnlySpan<T> gives you a type-safe window into a contiguous block of memory that already exists. You can slice it, search it, and pass it around without copying bytes to the heap. The catch is that it lives on the stack, so you cannot keep it everywhere. Understanding that boundary is the difference between a fast parser and a compiler error.
What ReadOnlySpan<T> is (and is not)
A span is a view: a pointer and a length that describe a region of contiguous memory. That memory can be managed (a string, an array) or unmanaged (a native buffer). Because it is a view, slicing does not clone data. It just moves the pointer and adjusts the length.
The type is a ref struct. That means the runtime keeps it on the stack rather than the heap, and the compiler enforces strict lifetime rules:
- It cannot be stored as a field in an ordinary class or non-ref struct.
- It cannot be boxed or cast to
object. - It cannot be used as a generic type argument or implemented through an interface.
- It cannot be captured by an
asyncstate machine, so you cannot hold one across anawait.
These restrictions exist because stack memory is reclaimed when the method returns. A span that outlived its method would point at garbage.
Worked example: parsing without Substring()
Consider a log line with comma-separated fields. The traditional approach uses Split() and Substring(), which allocate an array and multiple strings. A span-based parser walks the same line without creating intermediate objects.
// Requires .NET Core 2.1+ or .NET 5+
// Run in a console app or unit test project
using System;
public static class LogParser
{
private const string Sample = "2026-10-18T12:00:00Z,INFO,User,42,action=login";
// Allocates: Split creates an array, each element is a new string,
// and Substring creates another string.
public static void ParseWithSubstring()
{
string[] parts = Sample.Split(',');
foreach (string part in parts)
{
if (part.StartsWith("action="))
{
string value = part.Substring(7);
Console.WriteLine(value);
}
}
}
// Zero heap allocations for the parsing logic itself.
public static void ParseWithSpan()
{
ReadOnlySpan<char> remaining = Sample.AsSpan();
while (!remaining.IsEmpty)
{
int comma = remaining.IndexOf(',');
ReadOnlySpan<char> field = comma >= 0
? remaining.Slice(0, comma)
: remaining;
if (field.StartsWith("action=".AsSpan()))
{
ReadOnlySpan<char> value = field.Slice(7);
// ToString() allocates, but only for data you actually keep.
Console.WriteLine(value.ToString());
}
remaining = comma >= 0
? remaining.Slice(comma + 1)
: ReadOnlySpan<char>.Empty;
}
}
}
The span version never calls Split. It repeatedly slices the original string view until it is exhausted. The only allocation in the loop is the final ToString() for the value you intend to print or store. If you are filtering or validating, you may never need that string at all.
The trade-off: stack-only lifetime
The performance win comes with a real constraint. Because a span cannot live on the heap, you cannot use it as a general-purpose replacement for string.
If your parsing logic runs inside an async method, you cannot declare a span and then await an I/O operation while holding it. The compiler rejects the capture because the async state machine may resume on a different thread after the original stack frame is gone.
Likewise, you cannot cache a parsed slice in a class field for later use. If the data must outlive the method that produced it, you have to copy it into a string, char[], or other heap type. That copy is the allocation you were trying to avoid, so spans are best for hot paths where you consume the data immediately and discard it.
Verifying the behavior
You can confirm both the allocation savings and the view semantics with small, local checks.
- Measure allocations. Add
BenchmarkDotNetto a test project and decorate the two methods above with[MemoryDiagnoser]. Compare the Allocated column. The span method should show zero or near-zero bytes allocated for the parsing loop, while theSubstringversion allocates for every field. - Test the compiler boundary. Add a field like
private ReadOnlySpan<char> _cache;to a normal class. The compiler rejects it because aref structcannot be a field of a non-ref struct type. That error is the language enforcing the stack-only rule. - Prove it is a view. Wrap a mutable array and slice it. Mutating the original array changes what the slice sees, which proves no copy was made:
char[] buffer = "hello".ToCharArray();
ReadOnlySpan<char> view = buffer.AsSpan();
ReadOnlySpan<char> slice = view.Slice(0, 3);
buffer[0] = 'j';
// slice now begins with 'j', confirming it reads the original memory.
Note that ReadOnlySpan<T> itself cannot write through the view. The mutation above happens on the underlying array. If you need a writable view, use Span<T> instead.
When to reach for it
Use ReadOnlySpan<T> when you are parsing, scanning, or validating data in a tight loop and you do not need to keep the pieces. Use string when the value escapes the method, crosses an API boundary, or must be stored. The most effective pattern is often a hybrid: parse with spans inside the hot loop, then materialize only the final results as strings.
If you are on .NET Core 2.1 or later, the BCL already exposes span-based overloads for common string operations, so adoption is usually a matter of changing method signatures and loop structure rather than adding new dependencies.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.