← Open in the full interactive course (progress tracking, search & more)

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

WITH ReadOnlySpan<char> — slicing

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

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
2. THE REVERSE — NOT A NORMAL IMPLICIT CONVERSION, AND FOR GOOD REASON

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

Skip it when

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.


dotnetmadeeasy.com — Learn C# and .NET, the right way.