.net made easy[]

ReadOnlySpan<T> and Strings: Allocation-Free Parsing in C#

AdvancedLesson 201 of 315·Production .NET·22 min read·Updated

A string can't hand you a writable window onto itself — because a string never changes. This is the window it hands you instead.

You already know one of the oldest rules in .NET: a string is immutable. Once created, its characters never change — every "modification" you've ever performed on a string, from ToUpper() to Replace() to string concatenation, actually produced a brand-new string, leaving the original untouched.

Now recall the previous lesson: a plain Span<T> lets you both read and write through the memory it views. If string.AsSpan() returned a plain, writable Span<char>, you could write straight through it and mutate a string's characters in place — which would break one of the most fundamental guarantees in the entire .NET type system. Something has to give.

In this lesson, you'll meet ReadOnlySpan<T> — the read-only counterpart to Span<T> that makes zero-copy slicing safe even for immutable data like strings, and you'll see the single most common, genuinely important performance pattern it unlocks: parsing text without allocating a new string for every piece you extract.

What Is It?

The Simple Explanation

ReadOnlySpan<T> is exactly what its name says: a Span<T> that only lets you read through the window, never write through it. You get all the same zero-copy slicing and indexing — just with the compiler refusing, at compile time, to let you assign into an element.

The Technical Definition

ReadOnlySpan<T> (in System) is, like Span<T>, a ref struct representing a contiguous region of memory as a reference plus a length. The difference is entirely at the API surface: its indexer returns ref readonly T rather than ref T, so the compiler statically rejects any attempt to write through it. There's no runtime flag, no hidden check — the safety is enforced entirely by the type system, at compile time, with zero runtime cost.

This matters most for exactly the case that motivated this lesson: string.AsSpan() returns a ReadOnlySpan<char>, not a Span<char>. Since string is immutable, the runtime can safely hand out a direct view onto its internal character buffer — as long as that view can never be used to write back into it. ReadOnlySpan<char> is precisely that: read access with zero copying, and a compile-time guarantee that immutability can't be violated through it.

Why Does It Exist?

The Problem — every string operation you've used allocates

string.Substring(start, length) is one of the most commonly called methods in all of .NET — and every single call allocates a brand-new string object and copies characters into it, even when all you needed was to glance at a few characters and immediately discard them. Write a hand-rolled parser — a CSV reader, a log-line parser, a simple protocol parser — using Substring at every step, and you're allocating a fresh string for every field, on every line, of every file. For a large log file or a high-throughput ingestion pipeline, that's an enormous, entirely avoidable amount of garbage for the GC to trace and collect.

The Solution

ReadOnlySpan<char> lets you slice a string's characters the same way you sliced an array in the previous lesson — without allocating anything. You can walk through a line of text, find delimiters, and produce narrower and narrower slices, all pointing back into the exact same original string's memory, and only pay for an actual string allocation at the very end, if and when you truly need one (say, to store a final parsed value in a model object).

Big Picture — before vs. after, parsing a simple CSV line

string line = "42,Widget,19.99";

WITHOUT ReadOnlySpan<char> — string.Substring

  • Find first comma at index 2
  • line.Substring(0, 2) → allocates a new string "42"
  • Find second comma at index 9
  • line.Substring(3, 6) → allocates a new string "Widget"
  • line.Substring(10) → allocates a new string "19.99"
  • 3 allocations for 1 line — thousands of lines means thousands of throwaway strings

WITH ReadOnlySpan<char> — slicing

  • ReadOnlySpan<char> span = line.AsSpan(); — zero allocation
  • Find first comma at index 2
  • span[0..2] → a view onto "42" — zero allocation
  • Find second comma at index 9 (relative to the remaining slice)
  • span[3..9] → a view onto "Widget" — zero allocation
  • span[10..] → a view onto "19.99" — zero allocation
  • 0 allocations for slicing — parse into numbers directly, allocate a real string only if a field truly needs to be stored as one

How It Works

FROM STRING TO ReadOnlySpan<char> TO SLICES
1. GET A READ-ONLY VIEW OVER THE STRING
string line = "42,Widget,19.99";
ReadOnlySpan<char> span = line.AsSpan(); // zero allocation, zero copying
▼
2. FIND A DELIMITER, THEN SLICE
int firstComma = span.IndexOf(',');
ReadOnlySpan<char> idField = span[..firstComma]; // "42" — still a view, no allocation
  • IndexOf works on ReadOnlySpan<char> just like on string
  • The slice is another ReadOnlySpan<char> pointing into the same original string memory
▼
3. PARSE DIRECTLY FROM THE SLICE — NO STRING NEEDED AT ALL
int id = int.Parse(idField); // int.Parse has a ReadOnlySpan<char> overload — no string ever created
  • Modern .NET parsing methods (int.Parse, double.Parse, DateTime.Parse, and their TryParse counterparts) all have overloads that accept ReadOnlySpan<char> directly
  • The number gets parsed straight out of the original string's memory — no intermediate "42" string object is ever created
▼
4. ONLY ALLOCATE A REAL STRING WHEN YOU GENUINELY NEED ONE
string name = nameField.ToString(); // deliberate, one-time allocation — you need a real string to store
  • Calling .ToString() on a ReadOnlySpan<char> is the one moment you explicitly opt back into allocation — appropriate when you truly need a standalone string, like a field on a model object

Simple Example — a naive parser vs. a span-based parser, side by side

Before — string.Substring at every step

public static (int Id, string Name, decimal Price) ParseLineNaive(string line)
{
    int firstComma = line.IndexOf(',');
    int secondComma = line.IndexOf(',', firstComma + 1);

    string idText = line.Substring(0, firstComma);                              // allocation #1
    string name = line.Substring(firstComma + 1, secondComma - firstComma - 1); // allocation #2
    string priceText = line.Substring(secondComma + 1);                          // allocation #3

    return (int.Parse(idText), name, decimal.Parse(priceText));
}
// 3 throwaway string allocations per line, purely to hand values to int.Parse/decimal.Parse

After — ReadOnlySpan<char> slicing

public static (int Id, string Name, decimal Price) ParseLineFast(string line)
{
    ReadOnlySpan<char> span = line.AsSpan();

    int firstComma = span.IndexOf(',');
    ReadOnlySpan<char> idField = span[..firstComma];

    ReadOnlySpan<char> rest = span[(firstComma + 1)..];
    int secondComma = rest.IndexOf(',');
    ReadOnlySpan<char> nameField = rest[..secondComma];
    ReadOnlySpan<char> priceField = rest[(secondComma + 1)..];

    int id = int.Parse(idField);           // no allocation — Parse reads straight from the span
    decimal price = decimal.Parse(priceField); // same

    // 'name' is the one field we actually need as a real, standalone string
    string name = nameField.ToString();    // exactly 1 allocation, deliberate

    return (id, name, price);
}
// 1 allocation per line, only where a real string is genuinely needed — down from 3

Why this matters: both functions return the exact same result. The only difference is that the naive version manufactures three throwaway strings per call purely as a vehicle to get characters to int.Parse/decimal.Parse, while the span-based version reads directly out of the original line string's own memory and allocates only for the one field that genuinely needs to be a standalone string afterward. Run this across a million-line file, and the difference between roughly 3 million and 1 million short-lived allocations is a real, measurable amount of GC work saved.

Real-World Example

This exact pattern — repeated slicing of a ReadOnlySpan<char> instead of repeated Substring calls — shows up constantly in real, high-volume text processing:

The core lesson generalizes well beyond CSV: any time you find yourself calling Substring purely to hand a piece of a string to another method that only needs to read those characters, check whether that method (or an overload of it) accepts a ReadOnlySpan<char> instead — increasingly, in modern .NET, it does.

Analogy

A library book you may read, not write in

A plain Span<T> is like being handed a notebook you actually own — you can read it and scribble in it. A ReadOnlySpan<T> is like being handed a library book: you can flip through it, read any page, even bookmark a specific chapter (slice it) to hand to someone else to read — but the librarian (the compiler) simply won't let you write in it. That's exactly right for a string, since a string is meant to be read by everyone who holds a reference to it, with an absolute guarantee that none of them can quietly change its contents out from under the others.

Under the Hood

Span<T> ↔ ReadOnlySpan<T> CONVERSION
1. WIDENING: Span<T> → ReadOnlySpan<T> IS ALWAYS SAFE, AND IMPLICIT
Span<int> writable = someArray.AsSpan();
ReadOnlySpan<int> readOnly = writable; // implicit conversion — no cast needed
  • Any Span<T> can be used wherever a ReadOnlySpan<T> is expected — a read/write view is always safe to treat as a read-only view, since you're only ever taking capabilities away.
  • This is why methods that only need to read data (a search, a parse, a comparison) should almost always accept ReadOnlySpan<T> as their parameter type — doing so lets callers pass either a Span<T> or a ReadOnlySpan<T> (including one from a string) without friction, while documenting, in the signature itself, that the method won't mutate the caller's data.
▼
2. THE REVERSE — NOT A NORMAL IMPLICIT CONVERSION, AND FOR GOOD REASON
  • Going the other way — treating a ReadOnlySpan<T> as a writable Span<T> — is not something the language offers as an ordinary implicit conversion, and there's no built-in general-purpose way to do it from a ReadOnlySpan<T> alone.
  • The reason is exactly what motivated this lesson: a ReadOnlySpan<char> obtained from string.AsSpan() views memory that the runtime guarantees is immutable. If widening back to write access were an ordinary, easy operation, that guarantee would be trivially breakable — you could quietly corrupt every string in your program that happened to share the same underlying storage.
  • Advanced, narrowly-scoped escape hatches for this direction do exist deeper in the framework (for interop and highly specialized scenarios), but they are a deliberate, explicit opt-out of the safety guarantee — not something ordinary application code reaches for, and out of scope for this lesson.

Common Confusion

1. "Slicing a ReadOnlySpan<char> still allocates, just like Substring"

No — this is precisely the misunderstanding this lesson corrects. span[0..2] and span.Slice(0, 2) never allocate; they compute a new starting reference and length within the exact same string memory. Only .ToString() — called explicitly, when you actually need a standalone string — allocates.

2. "ReadOnlySpan<T> only exists for strings"

Strings are its single most common use case, but ReadOnlySpan<T> works over any kind of memory, exactly like Span<T> — including read-only views over arrays you don't want a method to accidentally mutate, even when the underlying array itself happens to be writable.

3. "I should just cast a ReadOnlySpan<char> back to Span<char> when I need to write"

There's no plain cast that does this, and that's deliberate — as covered in Under the Hood, it would undermine the immutability guarantee a ReadOnlySpan<char> from a string relies on. If you need writable character data, start from a genuinely writable source (a char[], or a Span<char> you allocated yourself), not from a string's read-only view.

Common Mistakes

Mistake 1 — Calling .ToString() too early, defeating the whole point

Slicing with spans correctly, then immediately calling .ToString() on every slice "just to be safe" before doing anything else with it — silently reintroducing every allocation the span-based approach was meant to eliminate.

int id = int.Parse(idField.ToString()); //  allocates a string just to immediately discard it

Pass the span directly to any method that accepts ReadOnlySpan<char>: int.Parse(idField). Reserve .ToString() for the one moment you truly need a standalone string to keep.

Mistake 2 — Assuming every string method has a span overload

Assuming any arbitrary method that accepts a string parameter can be handed a ReadOnlySpan<char> directly, without checking.

Many modern parsing and comparison APIs do accept ReadOnlySpan<char>, but plenty of older or higher-level APIs still only accept string — in those cases, a .ToString() call is genuinely necessary, and that's fine; the goal is avoiding unnecessary allocations, not eliminating every allocation at any cost.

Mistake 3 — Trying to store a ReadOnlySpan<char> for later use

Trying to keep a parsed field's ReadOnlySpan<char> around in a field or a collection for use after the current method returns.

Like Span<T>, ReadOnlySpan<T> is also a ref struct, with exactly the same restrictions covered in the previous lesson. If a slice needs to outlive the current synchronous stretch of code, either materialize it with .ToString() now, or reach for ReadOnlyMemory<T> — covered in the next lesson.

When Should I Use It?

Reach for ReadOnlySpan<T> when

  • You're writing or optimizing a text parser (CSV, logs, simple protocols) that currently leans on Substring
  • You're writing a method that only needs to read data — accepting ReadOnlySpan<T> instead of Span<T> both widens who can call it and documents your intent
  • You want to expose read access to internal data without letting callers accidentally mutate it

Skip it when

  • The code isn't parsing-heavy or hot-path — plain string methods are simpler and perfectly fine
  • You need to store the sliced text for later use across an await or in a field — reach for ReadOnlyMemory<T> instead
  • You genuinely need a real, independent string at that point anyway (interpolation, storage, hashing into a dictionary keyed by string) — just call .ToString() once
Rule of thumb: If you're writing a loop that calls Substring repeatedly to break a string into pieces, that's the signal to reach for ReadOnlySpan<char> slicing instead. For everyday, infrequent string handling, plain string methods remain simpler and are the right default.

Mental Model

ReadOnlySpan<T> = a Span<T> the compiler won't let you write through.
string.AsSpan() = a ReadOnlySpan<char>, always — because a writable view over immutable data would be a contradiction.
Slicing = still free, still zero-allocation — read-only doesn't mean "copy."

Remember: Span<T> → ReadOnlySpan<T> is always safe and implicit (you're only removing capability). The reverse isn't — widening back to write access needs an explicit, advanced escape hatch, not an everyday cast.

Key Takeaway


Check Your Understanding

You've seen why strings need a read-only view and how it kills allocation-heavy parsing. Let's confirm it stuck.

1. Why does string.AsSpan() return a ReadOnlySpan<char> instead of a plain Span<char>?

Show answer

Correct: B

Why B is correct: A string's immutability is one of the most fundamental guarantees in .NET. A writable Span<char> over a string's internal buffer would let anyone holding that span mutate the string's contents, directly violating that guarantee for every other holder of a reference to that same string.

Why A is incorrect: Plain char[] arrays are perfectly writable in .NET — the restriction here is specific to strings' immutability, not to char as a type.

Why C is incorrect: Span<char> supports the exact same character data as ReadOnlySpan<char> — Unicode support is unrelated to the read/write distinction.

Why D is incorrect: This is a deliberate, load-bearing safety decision tied directly to preserving string immutability — not an arbitrary choice.

Reinforcement: The read-only restriction is the compiler's way of upholding a promise .NET has made about strings since the beginning.

2. In the span-based parser example, why does calling int.Parse(idField) on a ReadOnlySpan<char> slice avoid an allocation that int.Parse(idField.ToString()) would not?

Show answer

Correct: B

Why B is correct: Passing the ReadOnlySpan<char> directly lets Parse read the digits straight from the original string's backing memory — no new object. Calling .ToString() first allocates an entirely new string just to immediately hand it to Parse and discard it, reintroducing the exact allocation the span approach was meant to avoid.

Why A is incorrect: int.Parse has multiple overloads — one accepting string, one accepting ReadOnlySpan<char> — both work, which is exactly why this comparison is meaningful.

Why C is incorrect: .ToString() on a span genuinely allocates a new string every time it's called; there's no caching involved.

Why D is incorrect: Parse reads exactly the characters it's given, whether from a span or a string — it doesn't ignore its input.

Reinforcement: The allocation-avoidance benefit only materializes if you pass the span itself to a span-aware API — calling .ToString() "just in case" quietly cancels out the savings.

3. You have Span<char> buffer = someArray.AsSpan(); and want to pass it to a method with the signature bool Contains(ReadOnlySpan<char> text, char target). What happens?

Show answer

Correct: B

Why B is correct: Because a ReadOnlySpan<T> only removes capability compared to a Span<T>, the conversion is always safe, and C# provides it implicitly — no cast needed, no copying involved.

Why A is incorrect: They are closely related types by design specifically to make this kind of interoperability seamless.

Why C is incorrect: The conversion is purely a type-level widening of the same underlying view — no data is copied anywhere.

Why D is incorrect: An explicit cast isn't required precisely because this direction (write access narrowing to read-only) is always safe — that's why it's implicit.

Reinforcement: Widening from Span<T> to ReadOnlySpan<T> is free and automatic; only the reverse direction needs special handling, for real safety reasons.

4. A method has the signature void Normalize(Span<char> text) and mutates the characters passed to it in place. Someone tries to call it as Normalize(myString.AsSpan()). What happens?

Show answer

Correct: B

Why B is correct: Widening from write access to read-only is implicit and safe; the reverse — treating a read-only view as writable — is not an ordinary implicit conversion, exactly because it would let this call silently break string immutability. The compiler catches this as a type mismatch at compile time.

Why A is incorrect: This is exactly the outcome the type system exists to prevent — it's a compile-time error, not a runtime surprise.

Why C is incorrect: The problem is caught before the program ever runs — there's no code generated that could reach a runtime failure here.

Why D is incorrect: No such automatic copying happens for this mismatch — the code simply doesn't compile, full stop.

Reinforcement: The fact that ReadOnlySpan<char> → Span<char> isn't an ordinary implicit conversion is precisely what keeps string immutability enforceable at compile time.

You can now write allocation-free text parsing with ReadOnlySpan<char> — and you understand exactly why the widening conversion only goes one direction. Next: what to do when span-like data needs to survive past one synchronous method call.