ReadOnlySpan<T>: read-only views, constant data, u8 literals, SearchValues and C# 14 span conversions.
ReadOnlySpan<T> is the read-only version of Span<T>: same layout and ref struct rules, but the indexer returns ref readonly, so callers cannot modify the memory. Use it for inputs to parsing, searching and hashing, and Span<T> only for outputs you write to. It is the only span type that can view a string (text.AsSpan()) and constant data: static ReadOnlySpan<byte> Magic => [0x50, 0x4B, 0x03, 0x04]; points straight at data in the assembly with no allocation, and "GET "u8 gives UTF-8 bytes encoded at compile time. .NET 8's SearchValues<T> precomputes fast lookups for IndexOfAny. C# 14 makes span conversions first-class, so arrays and strings convert implicitly in more places, including extension method receivers and generic inference.
Use ReadOnlySpan<T> whenever code only reads the data — which is most of the time. It accepts more sources (strings, constant data, read-only memory, ImmutableArray<T>), documents intent, prevents accidental writes, and unlocks compiler optimisations. Use Span<T> only when the method must write into the buffer. I'd cover read-only views over strings and constant data, u8 literals, static ReadOnlySpan<byte> properties, SearchValues, overload preference, and C# 14's first-class span conversions.
static bool IsValidPostcode(ReadOnlySpan<char> s) => s.Length == 4 && s.IndexOfAnyExceptInRange('0', '9') < 0;
IsValidPostcode("2000"); // string -> ReadOnlySpan<char>, no allocation
IsValidPostcode(input.AsSpan(5, 4)); // slice of a larger string
IsValidPostcode(charArray); // char[] works too
Strings are immutable, so they can only be viewed as ReadOnlySpan<char> — a method taking Span<char> couldn't accept a string at all. Accepting ReadOnlySpan<char> makes an API work with strings, arrays, stack buffers and slices.
u8 literals and static propertiesReadOnlySpan<byte> contentType = "application/json"u8; // UTF-8 bytes baked into the assembly
private static ReadOnlySpan<byte> PngSignature => [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
private static ReadOnlySpan<char> Hex => "0123456789ABCDEF";
static ReadOnlySpan<byte> property returning a constant array/collection expression of primitives compiles to a span pointing directly into the assembly's data section — no allocation, no static array, no initialisation. (It must be a property returning ReadOnlySpan<T>, not a field, because spans can't be stored in static fields.) Since .NET 7/C# 11 this works for all primitive element types (with correct endianness handling), not just bytes."..."u8 literals (C# 11) produce ReadOnlySpan<byte> UTF-8 data at compile time — ideal for HTTP headers, JSON property names with Utf8JsonWriter, protocol constants.SearchValuesprivate static readonly SearchValues<char> CsvSpecial = SearchValues.Create(",\"\r\n");
private static readonly SearchValues<string> BannedWords = SearchValues.Create(["drop", "delete"], StringComparison.OrdinalIgnoreCase); // .NET 9
bool needsQuoting = value.AsSpan().ContainsAny(CsvSpecial);
int index = text.AsSpan().IndexOfAny(BannedWords);
SearchValues (.NET 8) precomputes an optimised, often vectorised strategy for repeated searches over ReadOnlySpan<T> — much faster than naive loops or IndexOfAny(char[]) for repeated use. Store them in static readonly fields.
When APIs have overloads for string, T[], ReadOnlySpan<T> and Span<T>:
Span<T> and ReadOnlySpan<T> overloads picks the read-only one when the method only reads (API authors usually provide just ReadOnlySpan<T> for input).params ReadOnlySpan<T> (C# 13) prefer span overloads, avoiding array allocations.[OverloadResolutionPriority] (C# 13) lets library authors steer callers to new span overloads without breaking existing ones.API guideline: input buffers → ReadOnlySpan<T>, output buffers → Span<T> (with a TryXxx(..., out int written) pattern).
Before C# 14, spans weren't first-class in the type system: conversions like T[] → ReadOnlySpan<T> were user-defined implicit operators, so they didn't work for extension method receivers, type inference or some generic scenarios:
int[] numbers = [1, 2, 3];
numbers.ContainsAny(...) // MemoryExtensions on ReadOnlySpan<T> - previously needed .AsSpan()
C# 14 adds implicit span conversions recognised by the language: T[] → Span<T>/ReadOnlySpan<T>, Span<T> → ReadOnlySpan<T>, string → ReadOnlySpan<char>, including covariant conversions for reference-type elements (string[] → ReadOnlySpan<object>). These participate in extension method lookup and generic type inference, so span-based extension methods work directly on arrays and strings, and overload resolution prefers span overloads more consistently. (Some existing code saw overload changes — e.g. Enumerable vs MemoryExtensions methods on arrays — which the .NET team mitigated with priorities.)
An HTTP middleware compared request header values with strings and wrote constant headers via Encoding.UTF8.GetBytes("...") per request. Converting constants to "..."u8 and static ReadOnlySpan<byte> properties, header checks to ReadOnlySpan<char> with SearchValues, and the API signatures to accept ReadOnlySpan<char> removed per-request allocations from the middleware and made the code accept slices without copying.
The interviewer checks whether you know when read-only spans are the right type and the modern features around them.
Green flags
ReadOnlySpan<T> for inputs and Span<T> for outputs.ReadOnlySpan<byte> properties point at assembly data.SearchValues.Red flags
SearchValues while claiming performance expertise.ReadOnlySpan properties avoid allocationprivate static ReadOnlySpan<byte> Magic => [0x50, 0x4B, 0x03, 0x04];
The compiler emits the bytes into a static data blob in the PE file (<PrivateImplementationDetails>) and the getter returns new ReadOnlySpan<byte>(pointer, 4) (via RuntimeHelpers.CreateSpan<T> for non-byte types, handling alignment and endianness). No heap array is ever created. If you write static readonly byte[] Magic = { ... };, you get a real array allocated at type initialisation — mutable and on the heap.
Pitfall: returning a span over a new array (=> new byte[] { ... }) allocates every call; the compiler optimisation applies to constant initialisers directly in the expression.
Span<T> → ReadOnlySpan<T>: implicit (safe).ReadOnlySpan<T> → Span<T>: not allowed (would allow writes). MemoryMarshal.CreateSpan(ref MemoryMarshal.GetReference(ro), ro.Length) can do it unsafely — writing to string or constant data this way is undefined behaviour."abc"u8 is a ReadOnlySpan<byte> (not a constant), with a null terminator stored after the data (not included in length) for interop convenience. It can't be interpolated. Utf8JsonWriter.WriteString("name"u8, value) avoids transcoding property names.
SearchValues.Create analyses the set: small sets use vectorised IndexOfAny with packed comparisons; ASCII sets use bitmaps with SIMD shuffles; ranges use range checks; SearchValues<string> (.NET 9) uses algorithms like Aho-Corasick/Teddy for multi-substring search. The result is orders of magnitude faster than naive loops on large inputs.
With C# 14 span conversions, calls like array.Contains(x) may bind to MemoryExtensions.Contains(ReadOnlySpan<T>, T) instead of Enumerable.Contains, which is usually faster but could differ in edge cases (e.g. custom equality comparers). The BCL added [OverloadResolutionPriority] and specific overloads to keep behaviour consistent.
static ReadOnlySpan<byte> P => [1,2,3]; vs static readonly byte[] P = {1,2,3};.IndexOfAny(char[]) vs SearchValues<char> on long strings.ReadOnlySpan<char> and call it with a string, slice and stackalloc buffer.MemoryExtensions method directly on an array without AsSpan().using System.Buffers;
using System.Text;
public static class Html
{
// Built once; IndexOfAny uses an optimised (often vectorised) search
private static readonly SearchValues<char> Special = SearchValues.Create("<>&\"'");
public static string Escape(string input)
{
ReadOnlySpan<char> text = input; // string -> ReadOnlySpan<char>
int first = text.IndexOfAny(Special);
if (first < 0) return input; // common case: no allocation
var sb = new StringBuilder(input.Length + 16);
while (!text.IsEmpty)
{
int i = text.IndexOfAny(Special);
if (i < 0) { sb.Append(text); break; }
sb.Append(text[..i]);
sb.Append(text[i] switch { '<' => "<", '>' => ">", '&' => "&", '"' => """, _ => "'" });
text = text[(i + 1)..];
}
return sb.ToString();
}
}
public static class FileSniffer
{
// Points at data embedded in the assembly: no allocation, cannot be modified
private static ReadOnlySpan<byte> PngSignature => [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
private static ReadOnlySpan<byte> PdfSignature => "%PDF-"u8;
public static string Detect(ReadOnlySpan<byte> header) => header switch
{
_ when header.StartsWith(PngSignature) => "image/png",
_ when header.StartsWith(PdfSignature) => "application/pdf",
_ => "application/octet-stream",
};
}
public static class SpanExtensions
{
public static int CountOf<T>(this ReadOnlySpan<T> span, T value) where T : IEquatable<T>
{
int count = 0;
foreach (T item in span) if (item.Equals(value)) count++;
return count;
}
}
public static class Program
{
public static void Main()
{
Console.WriteLine(Html.Escape("Tom & Jerry <3")); // Tom & Jerry <3
byte[] pdf = [.. "%PDF-1.7"u8];
Console.WriteLine(FileSniffer.Detect(pdf)); // application/pdf
int[] ratings = [5, 3, 5, 4, 5];
Console.WriteLine(ratings.CountOf(5)); // 3: C# 14 first-class span conversion
}
}
Span<T> for read-only inputWhat happens: parsing methods declared with Span<char> parameters.
Why it is wrong: can't accept strings or read-only data; implies mutation.
Do instead: ReadOnlySpan<T> for inputs.
static readonly byte[] for constant tablesWhat happens: constant lookup tables as arrays.
Why it is wrong: allocated at start-up and mutable.
Do instead: static ReadOnlySpan<T> properties with constant initialisers.
What happens: static ReadOnlySpan<byte> P => new byte[] { ... } with non-constant content.
Why it is wrong: allocates each access.
Do instead: constant initialisers directly, or cache a static array.
What happens: Encoding.UTF8.GetBytes("Content-Type") in hot paths.
Why it is wrong: allocation and work each time.
Do instead: "Content-Type"u8.
SearchValues per callWhat happens: SearchValues.Create(...) inside methods.
Why it is wrong: expensive set-up repeated.
Do instead: cache in static readonly fields.
What happens: MemoryMarshal used to write into string or constant data.
Why it is wrong: undefined behaviour; can corrupt interned strings or crash.
Do instead: copy into a writable buffer.
What happens: upgrading language version changes which overload is called. Why it is wrong: subtle behavioural differences in edge cases. Do instead: run tests after upgrades; be explicit where semantics matter.
What happens: span-based rewrites of rarely executed code. Why it is wrong: complexity without measurable gain. Do instead: focus on measured hot paths.
ReadOnlySpan<T> is the universal input type of modern .NET: one overload accepts arrays, slices, strings, stack buffers and embedded constants. Designing APIs around it gives callers flexibility and avoids allocations without exposing mutation.
| API design question | Answer |
|---|---|
| Input data, synchronous | ReadOnlySpan<T> |
| Output buffer | Span<T> with a TryX(..., out int written) pattern |
| Needs to be stored or awaited | ReadOnlyMemory<T> |
| Repeated character or byte search | static SearchValues<T> |
| Constant tables | static ReadOnlySpan<T> properties |
C# 14's first-class spans continue the direction: span-based APIs become the natural path, and upgrades can change overload choices, which a senior engineer tests rather than assumes.
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.
Span<T> is a ref struct holding a reference and a length: a typed, bounds-checked view over contiguous memory, whether that memory is an array, a stackalloc buffer, native memory or part of a string (through ReadOnlySpan<char>). Slicing (span[2..6], Slice) creates a new view without copying, so parsers and formatters can work on pieces of a buffer with no allocations. Because it can point at the stack, it is stack-only: it cannot be a field of a class, be boxed, be captured by a lambda, or live across await/yield (C# 13 allows span locals in async and iterator methods as long as they do not cross those points). Helpers in MemoryExtensions (IndexOf, SequenceEqual, Sort, Trim) are vectorised. CollectionsMarshal.AsSpan(list) exposes a list's backing array, but adding to the list invalidates it.
In memory a .NET string is UTF-16; an Encoding converts it to and from bytes at the boundaries (files, HTTP, databases). I default to UTF-8 without a BOM (new UTF8Encoding(false)), and I use throwOnInvalidBytes: true when bad input should fail rather than silently become �. Legacy formats like Windows-1252 need CodePagesEncodingProvider registered. For speed there are "OK"u8 literals and the Utf8 class for span transcoding. For text correctness, Rune handles code points beyond the BMP, StringInfo counts grapheme clusters (what users see), and I normalise to NFC before comparing or storing user input.
How to tell it (STAR)
**Situation:** our [CMS] spent nearly a fifth of render CPU escaping HTML. **Task:** I needed a safe speed-up without changing output. **Action:** I benchmarked options, then rewrote the helper to take ReadOnlySpan<char>, use a static SearchValues for the special characters, and skip allocation when no escaping was needed, with tests comparing output to the old version. **Result:** the helper became [4x] faster, render CPU dropped [14%], and the output stayed byte-for-byte identical.
Last updated 30 Oct 2026.