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.
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.
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.
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.
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).
string line = "42,Widget,19.99";
line.Substring(0, 2) → allocates a new string "42"line.Substring(3, 6) → allocates a new string "Widget"line.Substring(10) → allocates a new string "19.99"ReadOnlySpan<char> span = line.AsSpan(); — zero allocationspan[0..2] → a view onto "42" — zero allocationspan[3..9] → a view onto "Widget" — zero allocationspan[10..] → a view onto "19.99" — zero allocationstring line = "42,Widget,19.99";
ReadOnlySpan<char> span = line.AsSpan(); // zero allocation, zero copying
int firstComma = span.IndexOf(',');
ReadOnlySpan<char> idField = span[..firstComma]; // "42" — still a view, no allocation
IndexOf works on ReadOnlySpan<char> just like on stringReadOnlySpan<char> pointing into the same original string memoryint id = int.Parse(idField); // int.Parse has a ReadOnlySpan<char> overload — no string ever created
int.Parse, double.Parse, DateTime.Parse, and their TryParse counterparts) all have overloads that accept ReadOnlySpan<char> directlystring name = nameField.ToString(); // deliberate, one-time allocation — you need a real string to store
.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 objectpublic 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
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.
This exact pattern — repeated slicing of a ReadOnlySpan<char> instead of repeated Substring calls — shows up constantly in real, high-volume text processing:
int.Parse, double.Parse, Guid.Parse, and many string methods themselves gained ReadOnlySpan<char> overloads specifically so that framework and application code alike could stop allocating intermediate strings just to call them.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.
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.
Span<int> writable = someArray.AsSpan();
ReadOnlySpan<int> readOnly = writable; // implicit conversion — no cast needed
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.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.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.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.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.
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.
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.
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.
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.
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.
SubstringReadOnlySpan<T> instead of Span<T> both widens who can call it and documents your intentstring methods are simpler and perfectly fineawait or in a field — reach for ReadOnlyMemory<T> insteadstring at that point anyway (interpolation, storage, hashing into a dictionary keyed by string) — just call .ToString() onceSubstring 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.
Span<T> the compiler won't let you write through.ReadOnlySpan<char>, always — because a writable view over immutable data would be a contradiction.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.
ReadOnlySpan<T> is the read-only counterpart to Span<T> — same zero-copy view, with writes prevented at compile time.string.AsSpan() returns ReadOnlySpan<char>, not Span<char>, because string is immutable — a writable view would break that guarantee.Substring calls in a parser each allocate a new string; repeated ReadOnlySpan<char> slices never do — parse directly from the slice using the span overloads of int.Parse, decimal.Parse, and similar methods, and call .ToString() only where you truly need a standalone string.Span<T> converts implicitly to ReadOnlySpan<T> — safe, since it only removes capability. The reverse isn't an ordinary implicit conversion, precisely because it would be unsafe in general.Span<T>, it's a ref struct — short-lived and synchronous only. When data needs to outlive that, ReadOnlyMemory<T>, next up, is the tool.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>?
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?
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?
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?
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.