Span<T> and Memory<T> separate two things that used to be bundled together in .NET: owning a buffer and viewing part of one. An engineer with a decade or more of experience is expected to know not just the syntax but the reasons the CLR enforces the rules it does, because those rules are where interviewers separate people who memorized a blog post from people who have actually chased a dangling reference through a debugger. This page works through the questions asked in senior and lead .NET loops: why Span<T> is a ref struct, why it cannot live on the heap, how it differs from Memory<T>, how to parse and buffer data without allocating, and the pitfalls that show up in real code review. Expect interviewers to push past "spans are fast" and ask you to justify every restriction from first principles.

Q1 Why is Span<T> implemented as a ref struct, and what does that trade-off cost you?#

Short answer: Span<T> is a readonly ref struct holding a by-ref reference to the first element and a length. Making it a ref struct is what lets the runtime guarantee that reference never outlives the stack frame that created it; the cost is a long list of places a span is not allowed to appear.

Structurally, a Span<T> is close to { ref T _reference; int _length; }. That ref T is a managed interior pointer: it can point past the start of an array, into the middle of a string's character buffer, or at a stack slot created by stackalloc. Ordinary object references only ever point at the start of a heap object, so the garbage collector's normal bookkeeping (object headers, generation tracking, the write barrier) does not apply to it. The JIT instead reports interior pointers through stack-frame GC information, which only exists for the lifetime of that frame. ref struct is the mechanism that keeps the compiler, not just convention, enforcing that a span never escapes into a place the JIT cannot track.

That guarantee is bought with real restrictions: a span cannot be a field of an ordinary class or struct, cannot be boxed, cannot be captured by a lambda, and cannot be stored in generic collections like List<Span<T>>. You cannot build a linked structure of spans, and until C# 13 you could not even keep one alive across await or yield return. Every one of these restrictions maps directly to a place where the interior pointer could otherwise end up outliving its target.

C#
public ref struct CsvCursor
{
    private ReadOnlySpan<char> _remaining;
    public CsvCursor(ReadOnlySpan<char> row) => _remaining = row;

    public bool TryNextField(out ReadOnlySpan<char> field)
    {
        if (_remaining.IsEmpty)
        {
            field = default;
            return false;
        }

        int comma = _remaining.IndexOf(',');
        field = comma < 0 ? _remaining : _remaining[..comma];
        _remaining = comma < 0 ? default : _remaining[(comma + 1)..];
        return true;
    }
}

// class Cache { CsvCursor Cursor; }                 // error: ref struct field in a non-ref-struct
// object boxed = new CsvCursor("a,b");               // error: a ref struct cannot be boxed
// Func<bool> f = () => cursor.TryNextField(out _);   // error: lambdas cannot capture a ref struct

What interviewers look for: whether you can explain the interior pointer and stack-frame GC reporting from memory, rather than reciting "it's a ref struct so it's fast." Strong candidates connect the restriction list back to the same root cause instead of treating each one as an arbitrary rule.

Common mistakes: describing ref struct as being about performance rather than safety; confusing it with unsafe code, when in fact Span<T> over managed memory needs no unsafe context at all.

Follow-up questions:

  • How would you design your own ref struct type with the same safety guarantees as Span<T>?
  • What did the scoped modifier, added in C# 11, change about ref safety analysis?

Q2 Why can't a Span<T> be stored on the managed heap, for example as a field of a class?#

Short answer: Because its ref T field is an interior pointer the GC can only track while it lives on the stack, and because Span<T> is a multi-field struct, a heap copy could be read by another thread mid-write and produce a "torn" span whose pointer and length no longer describe the same buffer.

Two separate failure modes justify the restriction. First, tracking: heap objects are fixed up during a compacting collection using type metadata that describes ordinary references at known offsets. An interior pointer into the middle of some other object needs the same treatment, but only for as long as the object holding it is reachable and only in ways the GC can prove are safe; the CLR handles this for byrefs on the stack through JIT-emitted GC information, not through the general heap object model. Second, tearing: a Span<T> is two machine words. Two threads racing to read and write a heap-resident span without synchronization could observe a pointer from one write interleaved with a length from another, producing a span that reads out of bounds of its real buffer. Value types that live in registers or on the stack of a single thread's frame do not have that problem, because nothing else can see them mid-assignment. A class field, an array element or a boxed object, by contrast, is visible to any thread holding a reference to the container.

Memory<T> exists precisely to give you the heap-friendly half of this story: it stores an array (or a MemoryManager<T>), a start offset and a length as an ordinary struct that copies safely and can live in fields, and you call .Span to get a Span<T> only for the synchronous block of code that touches the data.

What interviewers look for: the distinction between "the compiler forbids it" and "here is the specific memory-safety failure that would occur if it were allowed." Candidates who can describe the torn-read scenario unprompted are demonstrating real systems understanding, not memorized trivia.

Follow-up questions:

  • Why is it safe for Memory<T> to be a heap-storable struct when Span<T> is not?
  • What would happen if you used Unsafe.As or reflection to force a span into a field?

Q3 What is the practical difference between Span<T> and Memory<T>, and when does mixing them up hurt a public API?#

Short answer: Span<T> is the faster, stack-only view you use for synchronous processing; Memory<T> is the heap-friendly handle you store, pass into async methods, or keep in a field, and you convert it to a Span<T> via .Span only inside the synchronous code that does the work.

The guideline that follows from the earlier answers is simple to state and easy to violate under deadline pressure: accept Span<T> or ReadOnlySpan<T> in synchronous APIs, and Memory<T> or ReadOnlyMemory<T> in asynchronous ones. Getting it backwards produces two different failures. Typing an async method's parameter as Span<T> will not even compile once the span needs to survive an await, because the compiler correctly refuses to let a stack-only reference live inside a heap-allocated state machine. The subtler mistake is over-defensive API design: accepting Memory<T> in a purely synchronous, hot-path method "to be safe" forces every caller who only has a Span<T> slice, or a stackalloc buffer, to either copy into an array first or restructure their code, and it invites the callee to stash the Memory<T> somewhere and touch it after the call returns, which the contract does not license.

C#
// Synchronous, hot-path parsing: take the cheapest possible view.
public static bool TryParseHeaderValue(ReadOnlySpan<char> value, out int result) =>
    int.TryParse(value, out result);

// Asynchronous I/O: Span<T> cannot cross the await, so the parameter is Memory<T>.
public static async Task<int> ReadLengthPrefixAsync(Stream stream, CancellationToken ct)
{
    byte[] header = new byte[4];
    await stream.ReadExactlyAsync(header.AsMemory(), ct);   // Memory<T> survives the await
    return BitConverter.ToInt32(header);                     // Span-based work happens after
}

A related failure mode is lifetime: a method that receives a Memory<T> must stop using it once it returns, or once the task it started completes, unless the caller explicitly transfers ownership through something like IMemoryOwner<T>. Violating that contract, by capturing a Memory<T> into a background operation that outlives the buffer's real owner, produces the same class of bug that spans exist to prevent in the first place, just moved from compile time to runtime.

What interviewers look for: fluency with the "sync gets Span, async gets Memory" rule and, more importantly, the reasoning about ownership and lifetime that makes it more than a style preference.

Common mistakes: accepting Memory<T> everywhere for uniformity; forgetting that ReadOnlyMemory<T> is the right type for read-only asynchronous data, not ReadOnlyM­emory<T>'s mutable sibling.

Q4 Walk through designing allocation-free parsing with ReadOnlySpan<char>.#

Short answer: Slice the input with IndexOf, ranges or Split into a Span<Range>, then hand each slice directly to a span-aware TryParse overload; the only allocation left is the one the caller actually asked for, such as the final result object.

Almost every parsing primitive in the BCL now accepts a span: int.TryParse, decimal.TryParse, DateTime.TryParse and friends all have ReadOnlySpan<char> overloads, and MemoryExtensions.Split can write field boundaries into a caller-provided Span<Range> instead of allocating a string[]. The discipline is to never call Substring, ToString() or ToArray() until you are certain you need an owned copy, because each of those calls is exactly the allocation the whole exercise is trying to avoid.

C#
public static bool TryParseRequestLine(
    ReadOnlySpan<char> line, out ReadOnlySpan<char> method,
    out ReadOnlySpan<char> path, out ReadOnlySpan<char> version)
{
    method = path = version = default;

    int firstSpace = line.IndexOf(' ');
    if (firstSpace < 0)
    {
        return false;
    }
    method = line[..firstSpace];

    ReadOnlySpan<char> rest = line[(firstSpace + 1)..];
    int secondSpace = rest.IndexOf(' ');
    if (secondSpace < 0)
    {
        return false;
    }
    path = rest[..secondSpace];
    version = rest[(secondSpace + 1)..];
    return true;
}

Nothing in that method allocates: line[..firstSpace] is a slice, not a copy. If the caller needs a Dictionary<string, T> keyed by one of these fields, .NET 9's GetAlternateLookup<ReadOnlySpan<char>> lets you probe the dictionary with the span directly, so a string key is only allocated the first time a given value is actually inserted, not on every lookup. The pattern generalizes to log parsing, protocol framing and CSV ingestion; the design question in an interview is really "where does the allocation you cannot avoid finally happen," because a correct answer names one, rather than claiming the whole pipeline is free.

What interviewers look for: a candidate who reaches for span-based TryParse and slicing by default, and who can point to the one unavoidable allocation (the final owned object) rather than claiming zero allocations everywhere.

Follow-up questions:

  • How would you extend this to handle a request line with an unknown number of header fields?
  • What happens if the caller passes a span backed by a stackalloc buffer into a method that stores it? (It cannot; the method's parameter would have to be a ref struct itself or the caller is restricted to synchronous, non-escaping use.)

Q5 Is stackalloc safe to use, and what rules do you enforce for it in code review?#

Short answer: Yes, when the result is assigned to a Span<T> no unsafe context is required and the compiler prevents the span from escaping the method, but the size must be small and bounded, because the stack is a limited, per-thread resource and running out of it terminates the process with no way to catch the failure.

The specific danger is a size that depends on external input:

C#
// Bad: an attacker or a malformed request controls how much stack this call uses.
Span<byte> buffer = stackalloc byte[request.PayloadLength];

// Good: a bounded constant, with a pooled fallback for anything larger.
const int StackLimit = 512;
byte[]? rented = null;
Span<byte> buffer = request.PayloadLength <= StackLimit
    ? stackalloc byte[StackLimit]
    : (rented = ArrayPool<byte>.Shared.Rent(request.PayloadLength));

A StackOverflowException cannot be caught by any try/catch in managed code; it tears down the process immediately, which makes an unbounded stackalloc a denial-of-service vector as much as a correctness bug. Three more rules matter in review: never call stackalloc inside a loop, because each iteration would grow the same frame instead of reusing one buffer; treat the memory as uninitialized garbage until you write to it, since unlike new T[n] the runtime does not zero it for you in every code path a reviewer should assume; and never return a span over stack memory from a method, which the compiler blocks for you through the same ref-safety rules that make Span<T> a ref struct in the first place.

What interviewers look for: whether you volunteer the "cannot be caught" detail unprompted, and whether you have a concrete threshold policy (a constant, checked against a pooled fallback) rather than a vague "keep it small."

Common mistakes: treating stackalloc as automatically zero-initialized in all cases; sizing it from a request header, a loop counter or any other value that is not a compile-time or tightly clamped constant.

Q6 How do you combine stackalloc with ArrayPool<T> to handle both small and large buffers safely?#

Short answer: Use stackalloc for the common, small case and rent from ArrayPool<T> only when the required size exceeds a fixed threshold, always returning the rented array in a finally block and never touching it afterward.

C#
using System.Buffers;

public static class QueryStringBuilder
{
    private const int StackThreshold = 256;

    // Builds "key1=value1&key2=value2..." without allocating until the final string.
    public static string Build(ReadOnlySpan<(string Key, string Value)> parameters)
    {
        int maxLength = EstimateMaxLength(parameters);
        char[]? rented = null;
        Span<char> buffer = maxLength <= StackThreshold
            ? stackalloc char[StackThreshold]
            : (rented = ArrayPool<char>.Shared.Rent(maxLength));
        try
        {
            int written = 0;
            foreach (var (key, value) in parameters)
            {
                if (written > 0)
                {
                    buffer[written++] = '&';
                }
                key.AsSpan().CopyTo(buffer[written..]);
                written += key.Length;
                buffer[written++] = '=';
                value.AsSpan().CopyTo(buffer[written..]);
                written += value.Length;
            }
            return new string(buffer[..written]);
        }
        finally
        {
            if (rented is not null)
            {
                ArrayPool<char>.Shared.Return(rented);
            }
        }
    }

    private static int EstimateMaxLength(ReadOnlySpan<(string Key, string Value)> parameters)
    {
        int total = 0;
        foreach (var (key, value) in parameters)
        {
            total += key.Length + value.Length + 2;   // '=' and '&'
        }
        return total;
    }
}

Three pitfalls show up repeatedly in real pull requests. Renting and then returning the array on every code path, including exceptions, is what the finally block is for; a return statement inside the try still runs it. Rent may hand back an array larger than requested, so the code must track and slice to the length it actually wrote, never the array's Length. Finally, ownership ends at Return: touching the array afterward, or returning it a second time from a different code path, corrupts whatever unrelated caller the pool hands it to next, and that failure shows up far away from its cause, in a completely different part of the system.

What interviewers look for: the threshold pattern itself, plus explicit handling of the exception path and the "never touch after return" ownership rule, without being prompted for each one separately.

Common mistakes: forgetting the finally, assuming the rented array's exact length equals the request, or clearing sensitive data with Array.Clear instead of Return(array, clearArray: true).

Q7 What changed for ref struct types in C# 13, and why did the language wait so long to allow it?#

Short answer: C# 13 lets a ref struct local survive as long as it is not live across an await or a yield return, lets ref struct types implement interfaces (without ever being boxed to them), and adds an allows ref struct anti-constraint so generic code can accept them; before that, ref struct locals were banned from async methods and iterators entirely.

The reason it took years is that the compiler needed exact answers to "is this value still needed after the point where the method's state gets moved to the heap," not just "is this value used somewhere in an async method." An async method with an await compiles down to a state machine object; anything alive across the suspension point becomes a field of that heap object, which is exactly where a span-like interior pointer cannot go. C# 11's ref fields and scoped modifier were the first pieces of general-purpose lifetime tracking for ref-like values; C# 13 extended that same escape analysis specifically to await and yield boundaries, so the compiler can now prove a ref struct local's last use happens strictly before suspension and allow it, while still rejecting the case where it does not. The interface support follows the same "prove no escape" logic: an interface method call on a ref struct can be dispatched without boxing as long as the compiler can see the concrete type, but converting the value to the interface type itself would require boxing, so that conversion stays illegal. where T : allows ref struct is what makes span-aware generic APIs, such as the alternate dictionary lookups keyed by ReadOnlySpan<char>, possible at all: without it, no generic method could accept a Span<T> as a type argument.

What interviewers look for: understanding that this was an escape-analysis problem, not a syntax restriction the language team simply forgot to lift. Bonus points for connecting it to a concrete API, such as span-keyed dictionary lookups, that the change enabled.

Follow-up questions:

  • Give an example where a ref struct local would still be rejected in an async method under the C# 13 rules.
  • What is the difference between scoped Span<T> and an ordinary Span<T> parameter?

Q8 A colleague's pull request calls CollectionsMarshal.AsSpan on a List and mutates the list inside the same loop. What's wrong with it?#

Short answer: CollectionsMarshal.AsSpan exposes the list's current backing array; if the loop body adds items and that triggers a resize, the list swaps in a new backing array while the span still points at the old, abandoned one, so further reads and writes through the span silently affect memory nobody uses anymore.

C#
// Buggy: growing 'orders' during the loop can silently invalidate 'span'.
foreach (Order order in CollectionsMarshal.AsSpan(orders))
{
    if (order.NeedsSplitShipment)
    {
        orders.Add(order.CreateBackorder());   // may reallocate orders' backing array
    }
}

// Fixed: collect new items separately, mutate the list only after the loop ends.
var backorders = new List<Order>();
foreach (Order order in CollectionsMarshal.AsSpan(orders))
{
    if (order.NeedsSplitShipment)
    {
        backorders.Add(order.CreateBackorder());
    }
}
orders.AddRange(backorders);

This is a sharper version of the classic "modified collection during enumeration" bug. With a normal foreach over List<T>, the built-in enumerator detects the structural change and throws InvalidOperationException, which is a loud, immediate failure. CollectionsMarshal.AsSpan trades that safety net for raw speed: it hands back a Span<T> with no version check at all, so a resize during iteration does not throw, it just silently disconnects the span from the list's real data. The general rule this illustrates is that a span (or any enumerator) is only valid for as long as the structure it views does not change shape; anything that can trigger a resize, a removal that shifts elements, or a re-sort must happen outside the loop that holds the span.

What interviewers look for: recognizing that the failure mode here is silent, not an exception, which is what makes it dangerous in production. Candidates who jump straight to "collect changes, apply them after the loop" are showing the right instinct.

Common mistakes: assuming AsSpan has the same safety checks as List<T>'s own enumerator; using it on a list that is later resized on another thread without any synchronization at all.

Q9 Beyond compiler errors, what do you look for in code review on a PR that introduces Span<T> or Memory<T>?#

Short answer: Whether allocations were actually removed where it matters, whether pooled buffers are returned on every path including exceptions, and whether the change is proven with a benchmark rather than asserted from intuition.

A short list of red flags catches most real problems: calling ToString() or ToArray() inside the same loop the span was introduced to avoid allocating in, which quietly reintroduces the cost one line later; ignoring the actual length written to a rented buffer and using the pool array's full Length instead; and storing a Memory<T> or an IMemoryOwner<T> somewhere that outlives the operation that rented it, without a clear, single owner responsible for disposal.

C#
using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(4096);
Memory<byte> buffer = owner.Memory;
// 'owner' must be disposed exactly once, by whichever component the contract
// says owns it — never by both the renter and a downstream consumer.

The last, and most frequently skipped, check is whether the change is justified at all: spans pay off in hot paths that run per request or per element of a large loop, and a rewrite that only shaves nanoseconds off code that runs once per process start is a readability cost with no measurable benefit. Ask for the [MemoryDiagnoser] output before and after, not just a claim that it is faster.

What interviewers look for: a reviewer's mindset rather than a list of syntax rules; the strongest answers volunteer "show me the benchmark" as a review gate, not just a nice-to-have.

Common mistakes: treating any use of Span<T> as automatically an improvement; missing that a Memory<T> misuse is a runtime bug with no compiler error to catch it, unlike most Span<T> mistakes.

Q10 How would you prove a span-based rewrite of a hot path actually helped, and what would make you reject it?#

Short answer: Benchmark the exact code path with BenchmarkDotNet and its memory diagnoser, confirm the allocation and time deltas are outside the run's own error margin, and then check whether that hot path is a large enough share of end-to-end latency for the win to matter at all.

C#
[MemoryDiagnoser]
public class RequestLineBenchmarks
{
    private const string Line = "GET /api/orders/42 HTTP/1.1";

    [Benchmark(Baseline = true)]
    public string[] WithSplit() => Line.Split(' ');

    [Benchmark]
    public bool WithSpans() => RequestLineParser.TryParseRequestLine(Line, out _, out _, out _);
}

Run this in Release mode, on hardware close to production, and read the Allocated column alongside the mean: the split-based version should show a non-zero allocation per call, and the span version should show zero, or close to it if the caller still needs an owned string at the end. A result is worth acting on only when the reported error interval does not overlap between the two rows; a "10% faster" headline number that sits inside the benchmark's own noise is not a result. The rejection criteria matter as much as the acceptance ones: reject the change if the method it touches is a small fraction of overall request time and the gain would be invisible in production metrics, if it meaningfully hurts readability for a marginal number, or if it introduces a pooling or lifetime bug (an unreturned rented array, a span held past its safe scope) that the test suite does not cover. Optimizing code nobody profiled is how codebases end up full of clever, unreadable spans that made no measurable difference.

What interviewers look for: a measure-first discipline, and the maturity to say "no" to an optimization that cannot show its work, which is a strong lead-level signal.

Follow-up questions:

  • How would you decide whether a 5% improvement in a microbenchmark is worth taking, given the maintenance cost of the change?
  • What would you check before trusting a benchmark run on a shared CI runner?

Quick-Fire Round#

QuestionAnswer
Can Span<T> be a field of an ordinary class?No — only of another ref struct.
Can a Span<T> local survive an await?Not directly; convert to Memory<T> and call .Span after the await.
Does stackalloc need an unsafe block?No, when the result is assigned to a Span<T>.
What does ArrayPool<T>.Rent guarantee about size?At least the requested length; it may return a larger array.
Is a rented array's content pre-cleared?No, unless you pass clearArray: true to Return.
Can a ref struct implement an interface?Since C# 13, yes, but it can never be boxed to that interface type.
What type does slicing a string produce?A ReadOnlySpan<char>, never a mutable Span<char>.
Which generic constraint allows a ref struct type argument?where T : allows ref struct.

How to Prepare#

  • Write a small span-based parser (a request line, a CSV row, a key-value config line) from scratch and benchmark it against the Split-based equivalent.
  • Be ready to explain the Span<T> field layout and the interior-pointer safety argument on a whiteboard, not just recite the restriction list.
  • Know exactly what changed in C# 13 for ref struct types, and why the async and iterator restriction existed in the first place.
  • Practice the stackalloc plus ArrayPool<T> threshold pattern, including the exception-safe return path.
  • Review a real PR (your own or an open-source one) that touches Span<T> or Memory<T> and list every red flag you would raise.
  • Read the Span and Memory guide end to end so the API surface (SearchValues, GetAlternateLookup, CollectionsMarshal) is fresh.