ReadOnlySpan<char> for zero-allocation text processing in .NET 10: slicing, span-based parsing and searching, splitting without substrings, stackalloc and ArrayPool buffers, alternate dictionary lookups, ref struct restrictions and ReadOnlyMemory.
ReadOnlySpan<char> is a view over contiguous characters, which can be a string, an array or a stack buffer, made of a reference plus a length. Slicing it is free (no new string), and almost every parsing API now takes spans, so I can split a CSV line, trim fields and parse numbers and dates with zero allocations until I really need a string. It's a ref struct, so it can't be stored in a class field, boxed, captured by a lambda or held across an await. For those cases I use ReadOnlyMemory<char> and call .Span when processing. I reach for it in measured hot paths such as parsers, protocol code and high-volume ingestion.
ReadOnlySpan<char> is a view over a contiguous run of characters — part of a string, a char[], stack memory or native memory — represented as a reference plus a length. Slicing a span creates another view, not a copy, so you can parse text by moving windows over the original string instead of allocating substrings. The catch is that it is a ref struct: it can only live on the stack, which defines its limitations and why ReadOnlyMemory<char> exists.
string line = "2026-10-30,ORD-1042,129.95,AUD";
ReadOnlySpan<char> span = line; // implicit conversion, no allocation
ReadOnlySpan<char> date = span[..10]; // "2026-10-30" - a view, no allocation
With strings, line.Substring(0, 10) allocates a new 10-character string. With spans, span.Slice(0, 10) (or span[..10]) produces a 16-byte struct pointing into the same memory. Most parsing APIs accept spans: int.Parse(ReadOnlySpan<char>), decimal.TryParse, DateOnly.TryParseExact, Guid.TryParse, Enum.TryParse, MemoryExtensions.Equals/StartsWith/IndexOf/Trim.
public readonly record struct OrderLine(DateOnly Date, int OrderNumber, decimal Amount, Currency Currency);
public enum Currency { AUD, NZD, USD }
public static class OrderLineParser
{
public static bool TryParse(ReadOnlySpan<char> line, out OrderLine result)
{
result = default;
Span<Range> parts = stackalloc Range[5]; // one extra to detect too many fields
if (line.Split(parts, ',') != 4) return false; // .NET 8 MemoryExtensions.Split
if (!DateOnly.TryParseExact(line[parts[0]], "yyyy-MM-dd", CultureInfo.InvariantCulture,
DateTimeStyles.None, out var date)) return false;
var id = line[parts[1]];
if (!id.StartsWith("ORD-", StringComparison.Ordinal) ||
!int.TryParse(id[4..], NumberStyles.None, CultureInfo.InvariantCulture, out var number)) return false;
if (!decimal.TryParse(line[parts[2]], NumberStyles.Number, CultureInfo.InvariantCulture, out var amount)) return false;
if (!Enum.TryParse(line[parts[3]].Trim(), ignoreCase: false, out Currency currency)) return false;
result = new OrderLine(date, number, amount, currency);
return true;
}
}
Per line this allocates nothing: Split writes Ranges into a stack buffer, slices are views, parsing reads spans directly, and the result is a struct. Over a million-line file that removes millions of substring allocations and the GC work that comes with them.
ref struct)await or yield return (C# 13 allows ref struct locals in async/iterator methods as long as they don't cross a suspension point).allows ref struct (C# 13).stackalloc memory.ReadOnlyMemory<char> insteadReadOnlyMemory<char> is the heap-storable cousin: a normal struct (object reference + start + length) that you convert to a span when you need to read (memory.Span).
Use it when the slice must:
await (async parsing, Stream/PipeReader processing),ReadOnlyMemory<char> (TextWriter.WriteAsync, StringBuilder.GetChunks()).Rule: accept ReadOnlySpan<char> in synchronous methods; use ReadOnlyMemory<char> only where you must store or await. string.AsMemory() and char[].AsMemory() create one.
Spans add complexity. For code that runs a few times per request, Split and Substring are perfectly fine. Use spans where profiling shows string allocations dominate: parsers, protocol handlers, log processing, high-volume imports, middleware.
A bank-statement import processed ~2 million lines nightly using line.Split(',') and decimal.Parse(parts[2]). A dotnet-counters run showed allocation rates of hundreds of MB/s and frequent Gen0/Gen1 GCs; the job took ~9 minutes. Rewriting the hot loop with ReadOnlySpan<char>, MemoryExtensions.Split into a stackalloc'd Range buffer, and span-based TryParse brought the allocation rate close to zero and runtime to under 2 minutes. The rest of the pipeline (database writes with SqlBulkCopy) became the bottleneck — a reminder to profile again after each optimisation.
The interviewer checks modern performance skills and understanding of ref struct safety rules.
Green flags
TryParse.ReadOnlyMemory<char>.stackalloc limits, ArrayPool and measuring.Red flags
await.public readonly ref struct ReadOnlySpan<T>
{
internal readonly ref T _reference; // a managed pointer (C# 11 ref field)
private readonly int _length;
}
On 64-bit, a span is 16 bytes: a managed pointer to the first element plus an int length. The managed pointer can target:
stackalloc);new Span<byte>(void* ptr, length)).Because it may point into the stack, the type must not escape the stack, hence ref struct.
span.Slice(start, length) is just pointer arithmetic plus a bounds check:
new ReadOnlySpan<T>(ref Unsafe.Add(ref _reference, start), length)
No allocation, no copy. The JIT treats span indexers as intrinsics, eliminating bounds checks in common loop shapes (for (int i = 0; i < span.Length; i++)), so span loops are as fast as array loops.
string has an implicit conversion to ReadOnlySpan<char>, implemented by taking a reference to the string's first character (ref string.GetPinnableReference()). Since strings are immutable, a read-only view is safe. You can never get a writable Span<char> over a string without unsafe tricks — and you shouldn't.
Going back to a string (span.ToString() or new string(span)) allocates; do it once at the end, only for values you keep.
The compiler tracks a safe-to-escape scope for every span:
stackalloc memory: only safe within the current method.scoped (C# 11), which restricts them to the method and allows callers to pass stack spans.ReadOnlySpan<char> Bad()
{
Span<char> buffer = stackalloc char[16];
return buffer; // CS8352: may expose stack memory outside its scope
}
These rules are what make spans memory-safe without a GC-tracked lifetime.
Memory<T> internalspublic readonly struct ReadOnlyMemory<T>
{
private readonly object? _object; // string, T[], or MemoryManager<T>
private readonly int _index;
private readonly int _length;
}
Memory<T> stores an object reference, not a managed pointer, so it can live on the heap. Calling .Span inspects _object's type and builds a span — a small cost, so in loops get the span once outside the loop. MemoryManager<T> lets you wrap native or pooled memory (e.g. MemoryPool<T>.Shared.Rent), with an IMemoryOwner<T> that must be disposed to return the buffer.
| API | Purpose |
|---|---|
MemoryExtensions.Split(span, Span<Range>, char) |
Split into ranges without allocation (.NET 8) |
span.Split(',') returning SpanSplitEnumerator |
Enumerate parts lazily (.NET 9) |
IndexOfAny(SearchValues<char>) |
Vectorised search for a set of characters (.NET 8 SearchValues) |
span.Trim(), TrimStart, TrimEnd |
Views without allocation |
Equals/StartsWith/EndsWith(..., StringComparison) |
Explicit comparison on spans |
int/decimal/DateOnly.TryParse(ReadOnlySpan<char>, ...) |
Parse without substrings |
Dictionary<string,T>.GetAlternateLookup<ReadOnlySpan<char>>() |
Look up string keys by span (.NET 9) |
string.Create, TryFormat, Span<char>.TryWrite($"...") |
Write text into spans |
Because spans can't cross await, async parsers read into a buffer, then process synchronously:
while (true)
{
ReadResult result = await reader.ReadAsync(ct);
ReadOnlySequence<byte> buffer = result.Buffer;
while (TryReadLine(ref buffer, out var line)) // synchronous helper using spans
ProcessLine(line);
reader.AdvanceTo(buffer.Start, buffer.End);
if (result.IsCompleted) break;
}
The synchronous helper uses spans freely; only ReadOnlySequence<byte>/Memory<T> cross the await.
line.Split(',') + decimal.Parse against the span parser above with [MemoryDiagnoser].ReadOnlySpan<char> in a class field and read the compiler error.async method that holds a span across await and read CS4007/CS4012.SearchValues.Create(",;\t") with IndexOfAny to find delimiters and measure the speed-up.using System.Globalization;
namespace Strings;
public readonly record struct SaleRow(DateOnly Date, string ProductCode, decimal Amount);
public static class SpanCsvParser
{
private static readonly Dictionary<string, string> ProductCodes =
new(StringComparer.Ordinal) { ["ORD-42"] = "ORD-42", ["ORD-77"] = "ORD-77" };
// Parses "2026-10-23,ORD-42,199.95" with no intermediate strings
public static bool TryParse(ReadOnlySpan<char> line, out SaleRow row)
{
row = default;
Span<Range> parts = stackalloc Range[4];
int count = line.Split(parts, ',', StringSplitOptions.TrimEntries);
if (count != 3) return false;
if (!DateOnly.TryParseExact(line[parts[0]], "yyyy-MM-dd", CultureInfo.InvariantCulture,
DateTimeStyles.None, out var date))
return false;
if (!decimal.TryParse(line[parts[2]], NumberStyles.Number, CultureInfo.InvariantCulture, out var amount))
return false;
// .NET 9+: look up a string key by span, reuse the existing string instance
var lookup = ProductCodes.GetAlternateLookup<ReadOnlySpan<char>>();
if (!lookup.TryGetValue(line[parts[1]], out var code))
return false;
row = new SaleRow(date, code, amount);
return true;
}
// ReadOnlyMemory<char> when slices must survive an await
public static async Task<int> CountValidAsync(IAsyncEnumerable<ReadOnlyMemory<char>> lines, CancellationToken ct)
{
int valid = 0;
await foreach (var line in lines.WithCancellation(ct))
{
if (TryParse(line.Span, out _)) valid++; // .Span used synchronously, never across await
}
return valid;
}
public static void Main()
{
foreach (var text in new[] { "2026-10-23,ORD-42,199.95", "2026-10-23, ORD-77 , 5", "bad,line" })
{
Console.WriteLine(TryParse(text, out var row) ? row.ToString() : $"Rejected: {text}");
}
}
}
ToString() on every sliceWhat happens: spans are introduced, but each field is converted back with span.ToString() "to be safe".
Why it is wrong: you pay the allocations you were trying to avoid, plus extra complexity.
Do instead: parse directly from spans; only create strings for values you must keep.
What happens: compile errors, then developers switch to unsafe workarounds.
Why it is wrong: spans may point to stack memory; escaping them would be unsafe.
Do instead: store ReadOnlyMemory<char>, indexes or Range values, and create spans when needed.
awaitWhat happens: an async parser tries to keep a span over a buffer between reads.
Why it is wrong: not allowed, because the method's state moves to the heap at await.
Do instead: use Memory<T>/ReadOnlySequence<T> across awaits and spans in synchronous helpers.
What happens: ArrayPool<char>.Shared.Return(buffer) is called while a span over it is still used.
Why it is wrong: another caller can rent and overwrite the buffer — data corruption that is very hard to debug.
Do instead: finish all use of the span before returning the array; structure code with try/finally.
stackalloc buffersWhat happens: stackalloc char[input.Length] for arbitrary input sizes.
Why it is wrong: large or attacker-controlled sizes cause StackOverflowException, which kills the process.
Do instead: cap stack allocations (e.g. 256–1024 chars) and fall back to ArrayPool for larger sizes.
What happens: decimal.TryParse(span, out var d) without a provider.
Why it is wrong: span overloads still use the current culture by default.
Do instead: pass CultureInfo.InvariantCulture and explicit NumberStyles for machine data.
What happens: business logic and APIs are rewritten with spans for no measured reason. Why it is wrong: spans make code harder to read and restrict design (no async, no fields). Do instead: use them in measured hot paths and low-level parsers; keep public business APIs simple.
Memory<T>.Span inside tight loopsWhat happens: for (...) total += memory.Span[i];.
Why it is wrong: .Span does type checks each time.
Do instead: get the span once before the loop.
What happens: slicing user input at fixed positions without checking length.
Why it is wrong: out-of-range slices throw ArgumentOutOfRangeException; malformed input crashes the parser.
Do instead: check lengths and use Try patterns; return failure for malformed lines.
MemoryMarshal to a string's memoryWhat happens: MemoryMarshal.AsMemory or Unsafe used to get a writable span over a string.
Why it is wrong: breaks string immutability, corrupting interned strings and hash tables.
Do instead: use string.Create or a char[]/Span<char> buffer.
Spans are the foundation of modern high-performance .NET; seniors use them surgically and keep APIs ergonomic.
| Layer | Recommendation |
|---|---|
| Public API | accept ReadOnlySpan<char> and string overloads; return strings or records |
| Hot parsing loop | spans, ranges, span TryParse, alternate lookups |
| Async pipeline | ReadOnlyMemory<char> / ReadOnlySequence<byte> with System.IO.Pipelines |
| Buffers | stackalloc under a limit, ArrayPool above it |
| Proof | BenchmarkDotNet allocations and dotnet-counters GC rate |
char spans is staying in UTF-8 bytes (ReadOnlySpan<byte>, Utf8Parser, Utf8JsonReader) when input arrives as bytes.allows ref struct (C# 13) and first-class span conversions (C# 14) make generic and extension APIs over spans much more practical.I use StringBuilder when I'm appending an unknown or large number of pieces, usually in a loop, because += creates a new string and copies everything on every iteration. For a fixed number of pieces, a + b + c or interpolation compiles to a single string.Concat or handler call, which is just as fast. Internally StringBuilder keeps a chunked mutable buffer, so it grows without recopying old text, and ToString() allocates the final string once. Alternatives are string.Join for collections, string.Create when I know the exact length, and streaming to a writer for very large output.
A pointer is a raw memory address. In .NET, an object reference is a pointer the GC tracks: when the GC compacts the heap and moves an object, it updates every reference, so you never see addresses. ref, in and out pass a managed pointer to a variable instead of copying its value, and ref locals, ref returns and ref fields let you hold one; managed pointers can point inside objects, arrays or stack frames, and the GC tracks those too. Span<T> is a ref struct holding a managed pointer plus a length, giving safe views over arrays, stackalloc memory or native memory. Unsafe pointers (int*) are untracked addresses: they need an unsafe context and fixed or pinning, because the GC might otherwise move the object underneath them.
How to tell it (STAR)
**S:** Our nightly sales ingestion [at company] was about to overrun its batch window. **T:** I needed to speed it up without redesigning the pipeline. **A:** I profiled it, showed most CPU went to allocations from Split, Substring and string-based parsing, and rewrote just the line parser with spans, range splitting and alternate dictionary lookups, with benchmarks to prove it. **R:** Run time dropped from 40 to [14] minutes, allocations fell by over 90%, and the benchmark guards it in CI.
Last updated 30 Oct 2026.