📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is ReadOnlySpan<char>, and how does it let you parse and slice strings without allocating? Show a parser that uses it, and explain its limitations and when to use ReadOnlyMemory<char> instead.

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.

Senior Coding Advanced csharp parsing performance readonlyspan span strings

🎯 Key points

  • ReadOnlySpan<char> is a ref struct view (reference + length) over contiguous chars: a string, char[], stackalloc buffer or native memory
  • Slicing a span (span[3..8], Slice) allocates nothing, unlike Substring
  • Most parsing APIs accept spans: int.Parse(ReadOnlySpan<char>), decimal.TryParse, DateOnly.TryParseExact, Guid.TryParse
  • MemoryExtensions gives span versions of IndexOf, Trim, StartsWith, Equals with StringComparison, and Split into ranges or an enumerator
  • Dictionary<string, T>.GetAlternateLookup<ReadOnlySpan<char>>() (.NET 9+) looks up string keys by span without allocating
  • ref struct rules: cannot live on the heap, be boxed, be captured by lambdas, be a field of a class, or be used across await
  • Use ReadOnlyMemory<char> when you need to store the slice or use it in async code, then call .Span when processing
  • Measure first: spans help hot paths (parsers, protocols, logging), not typical CRUD code

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. The idea

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.

2. A zero-allocation CSV line parser

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.

3. Limitations (because it is a ref struct)

  • Cannot be a field of a class or a normal struct, cannot be boxed, cannot be stored in collections.
  • Cannot be captured by lambdas or local functions converted to delegates.
  • Cannot be used across await or yield return (C# 13 allows ref struct locals in async/iterator methods as long as they don't cross a suspension point).
  • Cannot be a generic type argument unless the generic declares allows ref struct (C# 13).
  • Its lifetime is bounded by what it points to; the compiler's ref-safety rules stop you returning a span over stackalloc memory.
  • You must not keep a span over a pooled array after returning the array.

4. When to use ReadOnlyMemory<char> instead

ReadOnlyMemory<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:

  • survive an await (async parsing, Stream/PipeReader processing),
  • be stored in a field or collection (a tokenizer that returns token positions for later),
  • be passed to APIs taking 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.

5. When not to bother

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.

6. A production example

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.

🔍 What the interviewer is checking

The interviewer checks modern performance skills and understanding of ref struct safety rules.

Green flags

  • Explains a span as a reference plus length and why slicing is free.
  • Writes a span-based parser using ranges and span TryParse.
  • States the ref struct restrictions and when to use ReadOnlyMemory<char>.
  • Mentions stackalloc limits, ArrayPool and measuring.

Red flags

  • Thinks a span copies data.
  • Tries to keep spans in fields or across await.
  • Uses spans everywhere without evidence of a bottleneck.

📚 Learn in depth

1. What a span is in memory

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:

  • the inside of a heap object (a string's first character, an array element) — an interior pointer the GC understands and updates if the object moves;
  • stack memory (stackalloc);
  • native memory (new Span<byte>(void* ptr, length)).

Because it may point into the stack, the type must not escape the stack, hence ref struct.

2. Why slicing is free

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.

3. String → span conversion

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.

4. Ref safety rules (simplified)

The compiler tracks a safe-to-escape scope for every span:

  • A span over a heap array or string: safe to return from the method.
  • A span over stackalloc memory: only safe within the current method.
  • Method parameters: assumed to be returnable unless marked 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.

5. Memory<T> internals

public 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.

6. Useful span APIs for text (.NET 8–10)

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

7. Async parsing pattern

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.

Try it yourself

  1. Benchmark line.Split(',') + decimal.Parse against the span parser above with [MemoryDiagnoser].
  2. Try to store a ReadOnlySpan<char> in a class field and read the compiler error.
  3. Write an async method that holds a span across await and read CS4007/CS4012.
  4. Use SearchValues.Create(",;\t") with IndexOfAny to find delimiters and measure the speed-up.

💻 Code

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}");
        }
    }
}

⚠ Common mistakes

1. Calling ToString() on every slice

What 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.

2. Trying to store spans in fields or capture them in lambdas

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.

3. Using spans across await

What 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.

4. Holding a span over a pooled or returned buffer

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.

5. Large stackalloc buffers

What 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.

6. Forgetting culture when parsing spans

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.

7. Using spans everywhere

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.

8. Using Memory<T>.Span inside tight loops

What 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.

9. Assuming spans validate text

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.

10. Writing through MemoryMarshal to a string's memory

What 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.

🏛 Senior insight: trade-offs

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
  • Even better than char spans is staying in UTF-8 bytes (ReadOnlySpan<byte>, Utf8Parser, Utf8JsonReader) when input arrives as bytes.
  • Keep span code in small, well-tested helpers; the rest of the codebase stays simple.
  • allows ref struct (C# 13) and first-class span conversions (C# 14) make generic and extension APIs over spans much more practical.

🔗 Follow-ups the interviewer may ask

  1. When: The candidate discusses materialising strings from spans. Why: Compares span techniques with StringBuilder and string.Create for producing text.
    How to answer

    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.

  2. When: The candidate says a span holds a reference and a length. Why: Explores the memory model behind spans, refs and pointers.
    How to answer

    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.

🛠 Real-world problems: how you handle them

Nightly CSV ingestion cut from 40 to 14 minutes with span parsing
Situation
A retail analytics worker ingested about 20 million sales lines nightly from Azure Blob Storage into SQL Server and was close to overrunning its batch window.
Signals
dotnet-counters showed a very high allocation rate and frequent gen-0/gen-1 GCs; CPU was saturated while SQL bulk insert was idle part of the time.
Investigation
Profiled with dotnet-trace and the Visual Studio allocation tool; string.Split, Substring and decimal.Parse(string) accounted for most allocations, and product-code dictionary lookups created a string per line.
Resolution
Rewrote the line parser with ReadOnlySpan<char>, Split into stack-allocated ranges, span-based TryParse with invariant culture, and GetAlternateLookup for product codes; kept the rest of the pipeline unchanged.
Prevention
BenchmarkDotNet tests with allocation thresholds in CI, plus a run-time metric for lines per second with an alert if it drops.

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.

↩ Usually asked after

More in Strings

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me