Learning outcome
By the end of this lesson, you should be able to explain why ReadOnlySpan<char> is a strong fit for parsers, tokenizers, and protocol readers that want to avoid extra string allocations. You should also be able to describe what slicing does, when a span is just a view into existing memory, and why the compiler blocks some seemingly convenient patterns that would let a span outlive its source.
Intuition
Taking a non-empty proper substring normally creates a new string; whole-string and empty-string cases can reuse existing instances. That is perfectly fine in many apps, but in hot parsing paths it can create avoidable GC pressure. A ReadOnlySpan<char> is different: it is a lightweight view over a contiguous region of memory. The view contains a reference and a length; it does not own the data.
That is why span-based parsing often looks like this: start with the original text, find delimiters, then slice the span into smaller spans for each field. Those slices still point at the same backing memory, so you can inspect pieces of the input without copying them.
The important mental model is:
stringis an owned immutable object.ReadOnlySpan<char>is a temporary window onto data.- slicing a span narrows the window; it does not duplicate the text.
Deep dive
ReadOnlySpan<char> is a ref struct, so the compiler restricts where the span value may escape. Its backing data can still live on the managed heap. That restriction is not an arbitrary annoyance; it is what keeps the type safe when it can point to stack memory, native memory, or an array. If a span could be stored in a class field, boxed, captured by a lambda, or returned beyond a safe backing lifetime, the program could end up holding a view to memory that is already gone.
For parsing, this gives you a useful pattern:
- Accept
ReadOnlySpan<char>as input. - Locate separators with index-based scanning.
- Use
Slice(start, length)orSlice(start)to isolate tokens. - Convert only the pieces you truly need as owned values, such as
int.Parse(...)orToString()on a final token.
A span slice is cheap because it only adjusts the window boundaries. It is not a substring copy. That makes it ideal when you need to validate or route data before materializing anything.
Failure modes
The most common mistake is assuming a span can behave like a regular object. It cannot. Because it is a ref struct, these patterns are blocked:
- storing it in a class field,
- capturing it in a lambda,
- using it across an
awaitboundary in an async method in the wrong way, - returning a span that references stack-allocated data.
Another trap is converting too early. If you call ToString() on every token, you often lose the allocation benefit. A good parser delays conversion until the point where an owned string or typed value is actually required.
Also remember that slicing does not validate semantics; it only validates bounds. Slice(0, 4) on a five-character input is legal even if the first four characters are meaningless to your protocol.
Interview drill
Try answering these out loud:
- Why does
ReadOnlySpan<char>reduce allocations compared withSubstring? - What does
Slice(...)return, and what does it not do? - Why is
ReadOnlySpan<char>aref structinstead of a normal struct? - Give one example of a span lifetime error the compiler prevents.
- When would you deliberately convert a token span to
stringanyway?
A strong answer should mention ownership, lifetime, and the difference between a view and a copy.
Revision checklist
ReadOnlySpan<char>is a non-owning view over contiguous memory.- Slicing adjusts start/length metadata rather than copying characters.
- Span-based parsing is useful when you want to delay allocation.
ref structrestrictions exist to prevent unsafe escaping of stack-bound or temporary memory.- Convert to owned strings only when you need persistence or API compatibility.
Production code
The example below tokenizes a simple comma-separated header and skips empty fields without creating intermediate substrings. It is not a complete CSV parser and does not reject missing fields or handle quoted commas. It also demonstrates a safe lifetime boundary: the parser consumes spans immediately and returns owned results only at the end.
Code walkthrough
The parser accepts ReadOnlySpan<char> instead of string. That means callers can pass an existing string with AsSpan(), but the parser itself is free to work with the text as a view.
Each loop does three things:
- finds the next delimiter with
IndexOf(','), - slices out the current token,
- trims and converts only the final token form that the result list needs.
Notice that Trim returns another span. It does not allocate. The parser still allocates the result list, its backing storage and owned token strings. This sample makes no exact allocation-count claim. In a real parser, you could keep the tokens as spans for even longer if the downstream API also accepted spans.
Why does lifetime safety matter here? Because a token span is only valid as long as the original input is valid. The compiler’s ref struct rules help ensure you do not accidentally store a token in a place where it could be used after the backing memory is gone. That makes span-based parsing both fast and safe when used within the correct scope.
Executable code examples
Span-based token parsing without substring copies
Program.cs
using System;
using System.Collections.Generic;
var line = "id, name, status";
var fields = ParseFields(line.AsSpan());
Console.WriteLine(fields.Count == 3 ? "Field count ok" : "Field count wrong");
Console.WriteLine(fields[0] == "id" ? "First field ok" : "First field wrong");
Console.WriteLine(fields[1] == "name" ? "Second field ok" : "Second field wrong");
Console.WriteLine(fields[2] == "status" ? "Third field ok" : "Third field wrong");
static List<string> ParseFields(ReadOnlySpan<char> input)
{
var result = new List<string>();
while (!input.IsEmpty)
{
int comma = input.IndexOf(',');
ReadOnlySpan<char> token = comma < 0 ? input : input.Slice(0, comma);
token = Trim(token);
if (!token.IsEmpty)
{
result.Add(token.ToString());
}
if (comma < 0)
break;
input = input.Slice(comma + 1);
}
return result;
}
static ReadOnlySpan<char> Trim(ReadOnlySpan<char> value)
{
int start = 0;
int end = value.Length - 1;
while (start <= end && char.IsWhiteSpace(value[start])) start++;
while (end >= start && char.IsWhiteSpace(value[end])) end--;
return start <= end ? value.Slice(start, end - start + 1) : ReadOnlySpan<char>.Empty;
}