Allocation-Aware Parsing with ReadOnlySpan<char> in C#
Parse text with ReadOnlySpan<char> and slices.
The parser returns three strings. Which allocations did span slicing avoid, and which results still need storage? Read the conversion point in ParseFields, then predict the separate ab → zb view/copy fixture.
A view or an owned result?
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 immutable object; multiple callers can share it.
ReadOnlySpan<char>is a temporary window onto data.
- slicing a span narrows the window; it does not duplicate the text.
Delay materialization until the result boundary
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,
- preserving it across an
awaitboundary,
- returning a span over this method’s own
stackalloclocal.
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.
Worked parsing example
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.
The storage and lease behind a token span must remain valid. The compiler rejects escaping stack-backed spans, but cannot detect a pool return or unmanaged-memory release. A read-only view does not freeze mutable backing storage. Since C# 13, permitted synchronous regions of async methods can use spans; a span cannot be preserved across await. Memory<T> can cross that boundary while its buffer remains valid.
Executable code examples
Run this example
Use the .NET 10 SDK. In an empty folder, save this project as Example.csproj and the existing listing as Program.cs. Run dotnet run --project Example.csproj.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>14.0</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>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;
}Expected output
Field count ok First field ok Second field ok Third field ok
The first token is id. The next two iterations trim the leading spaces from name and status. Three nonempty strings are added in that order, so the count check and all three indexed comparisons print their success messages.
Predict the five fixtures
Write the five predictions before reading the checks. Questions about a separate fixture are labeled; they are not edits to the main program.
The view/copy result and the two expected compilation failures below were recorded with the .NET 10 SDK and C# 14. Keep the failure fixtures separate from Program.cs.
1) In the parser listing, where are token boundaries found and where are result strings requested?
2) Predict the final line in this recorded view/copy fixture.
char[] source = { 'a', 'b' };
ReadOnlySpan<char> view = source;
string copy = view.ToString();
source[0] = 'z';
Console.WriteLine($"view={view.ToString()}; copy={copy}");3) Which statement fails compilation in this separate lifetime fixture, and what was the recorded diagnostic?
using System;
public static class BadLifetime
{
public static Span<int> Escape()
{
Span<int> values = stackalloc int[1];
return values;
}
}4) Where does this separate async fixture fail, despite its array initializer?
using System;
using System.Threading.Tasks;
public static class BadAwait
{
public static async Task<int> ReadLater()
{
Span<int> values = new int[] { 1 };
await Task.Yield();
return values[0];
}
}5) What leaves ParseFields, and what does the caller use after it returns?
Pause: trace reads, writes and lifetime boundaries before checking.
Check the five predictions
1
The loop finds each comma with input.IndexOf(','), chooses a token using Slice or the remaining input, and passes it through Trim. The conversion appears only in result.Add(token.ToString()). There is no Substring call in this listing. This identifies the conversion point; it does not count every allocation.
2
view=zb; copy=ab. The assignment changes source[0] after copy has been created. The recorded print shows z through view, while copy still prints its earlier a. This is a separate fixture; it is not output from the parsing program above.
3
return values; is rejected with CS8352. The returned expression is the local values declared from this method’s stackalloc. This fixture never produces a successful return value; the failure is the result being tested.
4
The recorded CS4007 points to return values[0];. The listing creates values, reaches await Task.Yield(), then tries to use that same local afterward. The test fails compilation rather than returning 1.
5
The method returns result, its List<string>. Nonempty tokens enter that list through result.Add(token.ToString()). The caller then reads fields.Count and fields[0] through fields[2]. No token span is returned or added to the result list.
TechInterviewNotes Editorial Team · Updated 28 Oct 2026 ·