Span<T> in depth: what it can point to, slicing, ref struct limits, helper APIs and performance.
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.
Span<T> is a stack-only view over a contiguous region of memory — an array, part of an array, stack memory from stackalloc, or native memory — represented as a reference plus a length. It lets you read, write and slice data without copying or allocating, with bounds checking and full memory safety. I'd explain what it can point to, slicing, why it's a ref struct and what that forbids, the MemoryExtensions helpers, CollectionsMarshal.AsSpan, and the gains and risks.
int[] array = [1, 2, 3, 4, 5];
Span<int> all = array; // whole array
Span<int> middle = array.AsSpan(1, 3); // elements 1..3, no copy
Span<byte> buffer = stackalloc byte[256]; // stack memory, no heap allocation
unsafe { Span<byte> native = new Span<byte>((void*)ptr, length); } // native memory
ReadOnlySpan<char> text = "Order 42"; // string data (read-only)
Writing through a span writes to the underlying memory: middle[0] = 99; changes array[1].
ReadOnlySpan<char> line = "2026-10-31,ORD-42,129.95";
var date = line[..10]; // view, no allocation
var rest = line[11..];
int comma = rest.IndexOf(',');
var id = rest[..comma];
var amount = decimal.Parse(rest[(comma + 1)..], CultureInfo.InvariantCulture);
Slice/ranges are O(1): they compute a new reference and length, with a bounds check. Compare Substring or array[1..], which copy.
ref struct and what that forbidsA span can point to stack memory; if it escaped to the heap, it could outlive the stack frame and become a dangling pointer. So Span<T> is a ref struct, and the compiler enforces:
object or interfaces).ref struct).await or yield return (C# 13 allows spans in async/iterator methods only if they don't cross a suspension point).allows ref struct (C# 13; many BCL APIs updated in .NET 9).For storage on the heap or across await, use Memory<T>.
MemoryExtensions helpersThe BCL provides span versions of many operations, mostly vectorised:
span.IndexOf(value); span.IndexOfAny(",;\t"); span.LastIndexOf(x);
span.Contains(x); span.SequenceEqual(other); span.StartsWith(prefix, StringComparison.Ordinal);
span.Trim(); span.TrimStart(); span.Split(ranges, ','); // .NET 8 split into Range[]
span.Fill(0); span.Clear(); span.Reverse(); span.Sort(); span.BinarySearch(x);
span.CopyTo(dest); span.TryCopyTo(dest); span.Count(x); span.Replace(a, b);
span.IndexOfAny(SearchValues.Create("abc")) // .NET 8 optimised sets
Plus parsing/formatting: int.Parse(span), value.TryFormat(destSpan, out written), Encoding.UTF8.GetBytes(chars, bytes), BinaryPrimitives.ReadInt32LittleEndian(span).
CollectionsMarshal.AsSpanList<decimal> prices = LoadPrices();
Span<decimal> span = CollectionsMarshal.AsSpan(prices);
for (int i = 0; i < span.Length; i++) span[i] *= 1.1m; // in-place, no copy, no version checks
Gives a span over a List<T>'s backing array (the Count used elements). Fast, but unsafe if the list is resized while you hold the span — the span keeps pointing at the old array and writes are lost. Don't add/remove items while using it.
Gains: zero-allocation parsing and slicing; one API working over arrays, stack and native memory; vectorised helpers; less GC pressure; used throughout ASP.NET Core, System.Text.Json, Kestrel and the BCL.
Risks:
stackalloc.MemoryMarshal/unsafe helpers can break type and memory safety if misused.A log-ingestion service parsed ~50,000 lines/second with Split and Substring, allocating heavily and spending ~30% CPU in GC. Rewriting the parser over ReadOnlySpan<char> (span splitting into Range buffers, span-based TryParse, SearchValues for delimiters) cut allocations per line to near zero and doubled throughput on the same container size. The one bug during rollout: a span over a pooled char[] used after the buffer was returned — fixed by restructuring ownership.
The interviewer checks whether you understand spans as views and can use them to remove allocations safely.
Green flags
CollectionsMarshal.AsSpan invalidation trap.Red flags
Span<T> is ref T _reference + int _length (C# 11 ref fields), 16 bytes on 64-bit. The managed reference can be an interior pointer into a heap object; the GC understands it and updates it when compacting. Indexers are JIT intrinsics with bounds checks the JIT can eliminate in canonical loops.
The compiler tracks a safe-context for each span: whether it may refer to stack memory of the current method. Rules prevent assigning such spans to locations that outlive the method (returning them, storing in outer spans/ref fields). scoped parameters (C# 11) promise not to escape, letting callers pass stack spans.
For arrays, spans add little overhead and often improve performance by enabling slicing without copies and unified vectorised helpers. In loops, for (int i = 0; i < span.Length; i++) gets bounds-check elimination like arrays.
string converts implicitly to ReadOnlySpan<char>. Many APIs have span overloads (int.Parse, Path.Combine, string.Concat(ReadOnlySpan<char>...)). new string(span)/span.ToString() allocate — do it only for values you keep.
SearchValues.Create("aeiou") precomputes an optimised search structure (bitmaps/vectorised probes) for IndexOfAny/ContainsAny, much faster than naive loops for repeated searches. .NET 9 added SearchValues<string> for multi-string search.
Memory<T>/streams/pipelines).Split/Substring and with spans; compare allocations.CollectionsMarshal.AsSpan and add items to the list while holding the span to see stale writes.using System.Buffers.Binary;
using System.Runtime.InteropServices;
public static class Program
{
public static void Main()
{
int[] data = [5, 3, 8, 1, 9, 2];
Span<int> middle = data.AsSpan(1, 4); // view over 3, 8, 1, 9: no copy
middle.Sort(); // sorts in place inside the array
Console.WriteLine(string.Join(",", data)); // 5,1,3,8,9,2
Span<byte> header = stackalloc byte[8]; // stack memory, no GC allocation
BinaryPrimitives.WriteInt32BigEndian(header, 0x0A0B0C0D);
BinaryPrimitives.WriteInt32LittleEndian(header[4..], 42);
Console.WriteLine(Convert.ToHexString(header)); // 0A0B0C0D2A000000
var list = new List<int> { 1, 2, 3 };
Span<int> listSpan = CollectionsMarshal.AsSpan(list);
listSpan[0] = 100; // writes into the list's backing array
Console.WriteLine(list[0]); // 100
// list.Add(4) here could reallocate: listSpan would then point at the OLD array
Console.WriteLine(SumPositive(data));
Console.WriteLine(TrySumCsv("10, 20,x,30", out int total) ? total : -1); // -1 (bad field)
Console.WriteLine(TrySumCsv("10, 20, 30", out total) ? total : -1); // 60
}
static int SumPositive(ReadOnlySpan<int> values)
{
int sum = 0;
foreach (int v in values) if (v > 0) sum += v;
return sum;
}
// Allocation-free CSV of integers: no Split, no Substring
static bool TrySumCsv(ReadOnlySpan<char> line, out int sum)
{
sum = 0;
while (!line.IsEmpty)
{
int comma = line.IndexOf(',');
ReadOnlySpan<char> field = comma < 0 ? line : line[..comma];
if (!int.TryParse(field.Trim(), out int value)) return false;
sum += value;
line = comma < 0 ? ReadOnlySpan<char>.Empty : line[(comma + 1)..];
}
return true;
}
}
ToString() on every sliceWhat happens: spans converted back to strings immediately. Why it is wrong: allocations you meant to avoid. Do instead: parse directly from spans.
What happens: using a span after ArrayPool.Return.
Why it is wrong: other code may reuse the buffer — corruption.
Do instead: finish using spans before returning buffers.
CollectionsMarshal.AsSpanWhat happens: adding items during span use. Why it is wrong: the span refers to the old array. Do instead: don't change list size while holding the span.
stackallocWhat happens: stackalloc byte[userLength].
Why it is wrong: stack overflow crashes the process.
Do instead: cap sizes and fall back to ArrayPool.
awaitWhat happens: async methods holding spans between awaits.
Why it is wrong: not allowed (and unsafe).
Do instead: Memory<T> across awaits; spans in synchronous helpers.
What happens: modifying a span expecting the source to stay unchanged. Why it is wrong: spans are views. Do instead: copy explicitly when needed.
MemoryMarshal tricks casuallyWhat happens: MemoryMarshal.Cast, CreateSpan without understanding layout.
Why it is wrong: can violate type safety and alignment.
Do instead: use safe APIs unless profiling demands otherwise.
What happens: span-heavy code where allocations don't matter. Why it is wrong: complexity without benefit. Do instead: reserve spans for hot paths and parsers.
Spans changed .NET performance work: the BCL itself (parsing, formatting, encoding, System.Text.Json, Kestrel) is built on them, which is why modern .NET is fast without unsafe code. A senior engineer uses them at the hot edges of a system and keeps ordinary APIs simple.
| Layer | Recommendation |
|---|---|
| Parsers, serialisers, protocol code | spans everywhere |
| Public library APIs | accept ReadOnlySpan<T> overloads alongside string/array ones |
| Business logic | usually plain types; spans only where profiling shows allocations |
| Async boundaries | Memory<T> |
The senior risk awareness is about aliasing and lifetime: a span is a view, so two spans can alias the same memory, and spans over pooled or list-backed arrays are only valid while the owner keeps that array.
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.
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.
Memory<T> and ReadOnlyMemory<T> are ordinary structs (not ref structs) that describe a slice of an array, a string (read-only), or memory from a MemoryManager<T>. Because they never point at the stack, they can be stored in fields, captured in lambdas and used across await, which spans cannot. You call .Span in synchronous code to do the actual work. Slicing is cheap and does not copy. MemoryMarshal.TryGetArray gets the underlying ArraySegment<T> for older APIs. Memory does not own anything: whoever rented or allocated the buffer owns it, so a Memory<T> over a pooled array is invalid once that array is returned. Async I/O APIs (Stream.ReadAsync(Memory<byte>), Socket, PipeReader, JsonSerializer with UTF-8) use them.
How to tell it (STAR)
**Situation:** our [telemetry ingestion API] was running hot at [85%] CPU with GC pauses causing latency spikes. **Task:** I was asked to reduce cost before Black Friday traffic. **Action:** I profiled allocations, found the line parser created several strings per field, and rewrote it with ReadOnlySpan<char> slicing and span-based parsing, guarded by benchmarks. **Result:** allocation per line fell from about [1 KB] to [48 bytes], CPU halved and we removed [three] pods from the deployment.
Last updated 30 Oct 2026.