📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is `Span<T>` and how do you use it to write allocation-free code? Explain what a span points to (arrays, stack memory, native memory), slicing, why it is a ref struct and what that forbids, MemoryExtensions helpers, CollectionsMarshal.AsSpan, and the performance gains and risks.

Span<T> in depth: what it can point to, slicing, ref struct limits, helper APIs and performance.

Mid-Level Coding Advanced allocation-free memory performance ref-struct span

🎯 Key points

  • Span<T> is a ref struct holding a reference and length over contiguous memory
  • It can view arrays, stack memory from stackalloc, native memory and list backing arrays
  • Slicing creates new views without copying; writes go to the original memory
  • Ref struct rules forbid heap storage, boxing, lambda capture and crossing await or yield
  • MemoryExtensions and BinaryPrimitives provide fast, often vectorised operations
  • CollectionsMarshal.AsSpan becomes stale if the list grows
  • Use span overloads like int.TryParse(span) to avoid string allocations
  • Profile with BenchmarkDotNet MemoryDiagnoser before and after

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. What a span points to

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

2. Slicing

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.

3. Why it's a ref struct and what that forbids

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

  • No boxing (can't convert to object or interfaces).
  • Can't be a field of a class or a regular struct (only of another ref struct).
  • Can't be captured by lambdas or used in local functions converted to delegates.
  • Can't be used across await or yield return (C# 13 allows spans in async/iterator methods only if they don't cross a suspension point).
  • Can't be a generic type argument unless the generic declares allows ref struct (C# 13; many BCL APIs updated in .NET 9).
  • Ref safety rules prevent returning spans over stack memory.

For storage on the heap or across await, use Memory<T>.

4. MemoryExtensions helpers

The 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).

5. CollectionsMarshal.AsSpan

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

6. Gains and risks

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:

  • Lifetime/aliasing: spans over pooled arrays used after return; spans over lists after resizing.
  • Stack overflow with large or unbounded stackalloc.
  • Complexity: ref struct restrictions complicate async code and APIs.
  • Mutation through views: writing through a span mutates shared data unexpectedly.
  • MemoryMarshal/unsafe helpers can break type and memory safety if misused.

7. A production example

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.

🔍 What the interviewer is checking

The interviewer checks whether you understand spans as views and can use them to remove allocations safely.

Green flags

  • Describes a span as reference plus length over arrays, stack or native memory.
  • Explains slicing without copying and the ref struct restrictions.
  • Writes allocation-free parsing with span overloads.
  • Knows the CollectionsMarshal.AsSpan invalidation trap.

Red flags

  • Thinks a span copies data or lives on the heap.
  • Tries to store spans in fields or across await.
  • Uses spans without measuring.

📚 Learn in depth

1. Layout and safety

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.

2. Ref safety (escape analysis by the compiler)

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.

3. Span vs array performance

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.

4. Spans and strings

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.

5. SearchValues

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.

6. When not to use spans

  • Business logic that's not allocation-sensitive.
  • Async workflows (use Memory<T>/streams/pipelines).
  • Public APIs where simplicity matters more than allocations (consider offering both overloads).

Try it yourself

  1. Parse a CSV line with Split/Substring and with spans; compare allocations.
  2. Modify an array through a sliced span.
  3. Try storing a span in a class field and capturing it in a lambda; read the errors.
  4. Use CollectionsMarshal.AsSpan and add items to the list while holding the span to see stale writes.

💻 Code

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

⚠ Common mistakes

1. Calling ToString() on every slice

What happens: spans converted back to strings immediately. Why it is wrong: allocations you meant to avoid. Do instead: parse directly from spans.

2. Holding spans over pooled buffers after return

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.

3. Resizing lists while using CollectionsMarshal.AsSpan

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

4. Unbounded stackalloc

What happens: stackalloc byte[userLength]. Why it is wrong: stack overflow crashes the process. Do instead: cap sizes and fall back to ArrayPool.

5. Trying to use spans across await

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

6. Assuming spans copy data

What happens: modifying a span expecting the source to stay unchanged. Why it is wrong: spans are views. Do instead: copy explicitly when needed.

7. Using MemoryMarshal tricks casually

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

8. Spans in business logic everywhere

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate explains what a span points to. Why: Checks the memory fundamentals of pointers, references and spans.
    How to answer

    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.

  2. When: Candidate takes span parameters for inputs. Why: Read-only spans are the usual input type.
    How to answer

    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.

  3. When: Candidate says spans cannot be used across await. Why: Memory<T> is needed when data crosses await.
    How to answer

    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.

🛠 Real-world problems: how you handle them

Telemetry parser allocating a kilobyte per line
Situation
An ingestion API received [50,000] text telemetry lines per second from [IoT gateways] and parsed them with string.Split and Substring.
Signals
High Gen 0 GC rate, CPU at [85%] per pod, and p99 latency spikes during GC pauses.
Investigation
dotnet-counters showed allocation rate over [400 MB/s]; a dotnet-trace allocation profile pointed at Split and Substring in the parser.
Resolution
Rewrote the parser over ReadOnlySpan<char> with IndexOf, slicing and span-based TryParse, allocating only the final record object.
Prevention
BenchmarkDotNet tests with MemoryDiagnoser in CI and an allocation budget per line documented in the parser.

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.

↩ Usually asked after

More in Memory-Oriented C#

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me