Span and Memory in C# — Zero-Allocation Slicing Explained
- Span<T> is a 16-byte ref struct that holds a pointer and a length — it creates a zero-allocation view over any contiguous memory (heap array, stackalloc, or unmanaged) and is enforced as stack-only by the compiler
- Memory<T> is the async-safe sibling — use it in method signatures that contain await or need class fields, then call .Span inside synchronous helpers to do the actual work
- The pattern stackalloc + Span<T> eliminates heap allocation entirely for fixed-size intermediate buffers, but you must validate the size against a hard cap before allocating or risk an unrecoverable StackOverflowException
Imagine you have a massive sandwich and you want to give someone 'just the middle section' — you don't make a brand new sandwich, you just point to where the middle starts and ends. Span
Every millisecond your web API spends allocating strings and arrays is a millisecond the garbage collector has to eventually clean up. At scale — thousands of requests per second — that tax compounds into measurable latency spikes and GC pauses that ruin your P99 numbers. The .NET team didn't add Span<T> and Memory<T> as academic exercises; they built them because the runtime itself needed a way to parse HTTP headers, JSON, and binary protocols without spraying the heap with short-lived allocations.
Before these types existed, the only way to work with a 'slice' of an array or a substring was to call Array.Copy or String.Substring — both of which allocate brand-new objects. If you were parsing a 10 KB HTTP request and needed 40 substrings, you'd create 40 heap objects just to read data you already had. Span<T> and Memory<T> solve this by letting you create a lightweight view over existing memory — whether that memory lives on the stack, the heap, or even unmanaged native memory — with no allocation at all.
By the end of this article you'll understand the internal representation of both types, know exactly when each one is appropriate, be able to write genuinely zero-allocation parsers, and understand the safety rules that make the compiler and runtime enforce correctness. You'll also walk away knowing the three most common production mistakes teams make when they first adopt these types.
How Span Works Under the Hood — Stack-Only Views Into Any Memory
Span<T> is a ref struct — that single fact drives every constraint you'll encounter. A ref struct can only live on the stack. It can never be boxed, stored in a field of a regular class, used as a generic type argument, or captured by a lambda. The compiler enforces all of this at compile time, so you get a hard error rather than a runtime surprise.
Internally, Span<T> is just two fields: a managed pointer (ref T) to the first element, and an integer length. On 64-bit systems that's 16 bytes total. There's no array object, no string object, no allocation. When you call someSpan[3], the JIT generates a single bounds-checked pointer dereference — essentially the same code you'd get from a raw unsafe pointer, but with the safety guarantee that the index is validated.
The key insight is that Span<T> can point at three completely different memory regions without changing its API: a managed array on the heap, a stackalloc block on the stack, or a block of unmanaged memory obtained from Marshal.AllocHGlobal. Your parsing code doesn't care which one — it just reads and writes through the Span interface identically. This is what makes Span<T> the universal buffer abstraction in modern .NET.
using System; using System.Runtime.InteropServices; public static class SpanInternalsDemo { public static void Run() { // --- Scenario 1: Span over a heap-allocated array (most common) --- int[] heapArray = { 10, 20, 30, 40, 50, 60 }; // No allocation here — Span is just a pointer + length on the stack Span<int> fullView = heapArray; Span<int> middleSlice = fullView.Slice(1, 4); // points at index 1, length 4 Console.WriteLine("Middle slice from heap array:"); foreach (int value in middleSlice) Console.Write($"{value} "); // 20 30 40 50 Console.WriteLine(); // Mutating through the Span mutates the original array — they share memory middleSlice[0] = 999; Console.WriteLine($"heapArray[1] after mutation via Span: {heapArray[1]}"); // 999 // --- Scenario 2: Span over a stackalloc buffer (truly zero heap allocation) --- // stackalloc is only safe inside unsafe or with Span — Span wraps it safely Span<byte> stackBuffer = stackalloc byte[128]; stackBuffer.Fill(0xFF); // fill all 128 bytes with 255 Console.WriteLine($"\nFirst byte of stack buffer: {stackBuffer[0]}"); // 255 Console.WriteLine($"Stack buffer length: {stackBuffer.Length}"); // 128 // --- Scenario 3: Span over unmanaged memory --- nint unmanagedPtr = Marshal.AllocHGlobal(64); try { // Wrapping unmanaged memory in a Span — no GC involvement at all unsafe { Span<byte> unmanagedView = new Span<byte>((void*)unmanagedPtr, 64); unmanagedView.Clear(); // zero it out safely unmanagedView[0] = 42; Console.WriteLine($"\nUnmanaged byte[0]: {unmanagedView[0]}"); // 42 } } finally { Marshal.FreeHGlobal(unmanagedPtr); // you must still manage lifetime manually } // --- Slicing a ReadOnlySpan<char> from a string (the parser pattern) --- string csvLine = "Alice,30,Engineer"; ReadOnlySpan<char> lineSpan = csvLine.AsSpan(); int firstComma = lineSpan.IndexOf(','); ReadOnlySpan<char> nameField = lineSpan.Slice(0, firstComma); // no String.Substring! // SequenceEqual lets you compare without allocating a new string bool isAlice = nameField.SequenceEqual("Alice"); Console.WriteLine($"\nName field is 'Alice': {isAlice}"); // True } }
20 30 40 50
heapArray[1] after mutation via Span: 999
First byte of stack buffer: 255
Stack buffer length: 128
Unmanaged byte[0]: 42
Name field is 'Alice': True
Memory and ReadOnlyMemory — When You Need to Cross Async Boundaries
Span<T> is perfect for synchronous, stack-contained operations. But the moment you hit an await, the current stack frame evaporates and resumes on a potentially different thread. A ref struct can't survive that transition, so Span<T> simply can't cross an await boundary. This is where Memory<T> comes in.
Memory<T> is a regular struct — not a ref struct — so it can be stored in class fields, used in async methods, returned from lambdas, and put in collections. It wraps the same concept (a segment of contiguous memory with an offset and length) but does so through a level of indirection: it holds a reference to the owner object (array, MemoryManager<T>, or string), plus an index and length. When you actually want to process the data, you call .Span on it to get a Span<T> at that point — that's where the 'read through a stack-safe view' pattern kicks in.
The key production pattern is: accept Memory<T> in your async method signatures, pass Span<T> into your synchronous helper methods that do the actual parsing or computation. This keeps allocations zero while remaining async-friendly. IMemoryOwner<T> and MemoryPool<T> extend this further by giving you a rented, lifetime-managed buffer — critical for socket and pipe-based I/O where you need pooled buffers that survive multiple await points.
using System; using System.Buffers; using System.Text; using System.Threading.Tasks; public static class MemoryAsyncPipelineDemo { // Simulates reading a chunk of data asynchronously (e.g. from a socket) private static async Task<int> SimulateReadAsync(Memory<byte> destinationBuffer) { await Task.Delay(1); // represents real async I/O // Write some fake "received" data into the buffer byte[] fakePayload = Encoding.UTF8.GetBytes("HELLO:World"); fakePayload.CopyTo(destinationBuffer); return fakePayload.Length; // return how many bytes were "read" } // Synchronous parser — takes Span<byte> because it never awaits private static (string command, string argument) ParseProtocolFrame(ReadOnlySpan<byte> frame) { int separatorIndex = frame.IndexOf((byte)':'); if (separatorIndex < 0) return (Encoding.UTF8.GetString(frame), string.Empty); // Slice without any allocation — we're just adjusting a pointer + length ReadOnlySpan<byte> commandBytes = frame.Slice(0, separatorIndex); ReadOnlySpan<byte> argumentBytes = frame.Slice(separatorIndex + 1); // The only allocation is the final string we return — unavoidable for output return (Encoding.UTF8.GetString(commandBytes), Encoding.UTF8.GetString(argumentBytes)); } public static async Task RunAsync() { // MemoryPool<byte>.Shared gives us a pooled buffer — no new byte[] on the heap using IMemoryOwner<byte> rentedBuffer = MemoryPool<byte>.Shared.Rent(minimumLength: 256); Console.WriteLine($"Rented buffer size: {rentedBuffer.Memory.Length} bytes"); // Memory<byte> crosses the await boundary safely (it's a normal struct) int bytesRead = await SimulateReadAsync(rentedBuffer.Memory); Console.WriteLine($"Bytes received: {bytesRead}"); // Now we're back from await — call .Span to get a synchronous view // Slice to only the bytes we actually received (don't parse garbage) ReadOnlySpan<byte> receivedData = rentedBuffer.Memory.Span.Slice(0, bytesRead); // Pass the Span to our zero-allocation parser var (command, argument) = ParseProtocolFrame(receivedData); Console.WriteLine($"Command : {command}"); Console.WriteLine($"Argument: {argument}"); // IMemoryOwner is IDisposable — the 'using' returns the buffer to the pool // No GC pressure from the buffer itself } // Demonstrating storing Memory<T> in a class field — impossible with Span<T> public class MessageBuffer { private readonly IMemoryOwner<byte> _owner; // Memory<byte> as a field: perfectly legal public Memory<byte> Data => _owner.Memory; public MessageBuffer(int capacity) { _owner = MemoryPool<byte>.Shared.Rent(capacity); } // Get a Span when you need to read/write synchronously public void WriteAscii(string text) { Span<byte> writableView = Data.Span; // this line would fail if Data were a field of type Span Encoding.ASCII.GetBytes(text, writableView); } public void Dispose() => _owner.Dispose(); } }
Bytes received: 11
Command : HELLO
Argument: World
Building a Real Zero-Allocation CSV Parser — Putting It All Together
Theory only sticks when you build something real. Let's write a CSV line parser that processes an entire row without a single heap allocation except the final result array. This is the kind of code you'd find inside a high-throughput data ingestion pipeline or a game server parsing player commands at 60 fps.
The core technique is iterating over a ReadOnlySpan<char> by repeatedly finding delimiters and slicing — no Split(), no Substring(), no List<string> intermediate collection. We use a fixed-size Span<Range> to record where each field starts and ends, then we materialize strings only at the very end when the caller actually needs them.
This pattern mirrors exactly how System.Text.Json and ASP.NET Core's routing engine work internally. Understanding it means you can read — and contribute to — performance-critical library code with confidence.
using System; using System.Runtime.CompilerServices; public static class ZeroAllocationCsvParser { private const int MaxFieldsPerRow = 32; // stack-allocate space for up to 32 fields /// <summary> /// Parses a single CSV line into its fields. /// Allocates ONLY the returned string array — all intermediate work is on the stack. /// </summary> public static string[] ParseLine(ReadOnlySpan<char> csvLine, char delimiter = ',') { if (csvLine.IsEmpty) return Array.Empty<string>(); // Stack-allocate an array of Range structs to track field boundaries // Range is a lightweight (int start, int end) struct — no heap allocation Span<Range> fieldRanges = stackalloc Range[MaxFieldsPerRow]; int fieldCount = 0; int currentStart = 0; for (int charIndex = 0; charIndex <= csvLine.Length; charIndex++) { // Treat end-of-span as a virtual delimiter to capture the last field bool isDelimiter = charIndex == csvLine.Length || csvLine[charIndex] == delimiter; if (isDelimiter) { if (fieldCount >= MaxFieldsPerRow) throw new InvalidOperationException( $"CSV line exceeds maximum of {MaxFieldsPerRow} fields."); // Record the range of this field — no string created yet fieldRanges[fieldCount++] = new Range(currentStart, charIndex); currentStart = charIndex + 1; } } // Only NOW do we allocate — creating the output strings the caller will use string[] results = new string[fieldCount]; for (int fieldIndex = 0; fieldIndex < fieldCount; fieldIndex++) { ReadOnlySpan<char> fieldSpan = csvLine[fieldRanges[fieldIndex]]; // Trim whitespace without allocating — Trim() on a span returns a span ReadOnlySpan<char> trimmedField = fieldSpan.Trim(); // new string(ReadOnlySpan<char>) — this is the only heap allocation per field results[fieldIndex] = trimmedField.IsEmpty ? string.Empty : new string(trimmedField); } return results; } /// <summary> /// Processes each field without materializing ANY strings — peak zero-allocation. /// Use this when you're doing numeric parsing, validation, or routing — not string output. /// </summary> public static void ProcessFieldsWithoutAllocation( ReadOnlySpan<char> csvLine, char delimiter, FieldProcessor processor) { int currentStart = 0; int fieldIndex = 0; for (int i = 0; i <= csvLine.Length; i++) { if (i == csvLine.Length || csvLine[i] == delimiter) { ReadOnlySpan<char> field = csvLine.Slice(currentStart, i - currentStart).Trim(); processor(fieldIndex++, field); // callback receives a span — no allocation currentStart = i + 1; } } } // Delegate type that receives a field as ReadOnlySpan<char> — avoids string allocation public delegate void FieldProcessor(int index, ReadOnlySpan<char> fieldValue); } // --- Demo entry point --- public static class CsvParserDemo { public static void Run() { string csvRow = " Alice , 30 , Senior Engineer , London , true "; ReadOnlySpan<char> rowSpan = csvRow.AsSpan(); // --- Version 1: Returns string[] — one allocation per field + the array --- Console.WriteLine("=== ParseLine (string[] output) ==="); string[] fields = ZeroAllocationCsvParser.ParseLine(rowSpan); for (int i = 0; i < fields.Length; i++) Console.WriteLine($" Field[{i}]: '{fields[i]}'"); // --- Version 2: Callback with span — zero extra allocations --- Console.WriteLine("\n=== ProcessFieldsWithoutAllocation (zero alloc) ==="); ZeroAllocationCsvParser.ProcessFieldsWithoutAllocation( rowSpan, delimiter: ',', processor: (index, fieldSpan) => { // Parse an int inline without creating a string if (index == 1 && int.TryParse(fieldSpan, out int age)) Console.WriteLine($" Age parsed inline (no string): {age}"); else Console.WriteLine($" Field[{index}] length: {fieldSpan.Length} chars"); } ); // --- Demonstrate that spans correctly handle quoted/edge CSV --- Console.WriteLine("\n=== Edge case: single field ==="); string[] singleField = ZeroAllocationCsvParser.ParseLine("OnlyOneField".AsSpan()); Console.WriteLine($" Fields found: {singleField.Length}, Value: '{singleField[0]}'"); } }
Field[0]: 'Alice'
Field[1]: '30'
Field[2]: 'Senior Engineer'
Field[3]: 'London'
Field[4]: 'true'
=== ProcessFieldsWithoutAllocation (zero alloc) ===
Field[0] length: 5 chars
Age parsed inline (no string): 30
Field[2] length: 15 chars
Field[3] length: 6 chars
Field[4] length: 4 chars
=== Edge case: single field ===
Fields found: 1, Value: 'OnlyOneField'
Performance Benchmarks, Gotchas, and Production Rules
Knowing the API is one thing. Knowing when you've actually eliminated allocation — and when you've accidentally added it back — is what separates production-ready code from aspirational code. BenchmarkDotNet is your ground truth here; gut feelings about allocations are reliably wrong.
The most dangerous false economy is calling .ToString() or creating a new string inside a hot path you thought was allocation-free. One ToString() call on a ReadOnlySpan<char> inside a loop that runs a million times creates a million strings. Profile first with dotnet-trace or PerfView; optimize second.
Another production reality: Span<T> slicing is O(1) — it's literally adding an integer to a pointer. But accessing elements through a Span in a tight loop is only as fast as unsafe pointer access when the JIT eliminates bounds checks. The JIT does eliminate bounds checks when it can prove the index is within range (e.g. iterating with a for loop up to .Length). If you're using arbitrary computed indices, you may not get that optimization. When it matters, benchmark both the Span version and the unsafe version and let the numbers decide.
using System; using System.Buffers; using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; // Run with: dotnet run -c Release // Add BenchmarkDotNet NuGet package first [MemoryDiagnoser] // shows allocation column in BenchmarkDotNet output [SimpleJob] public class CsvParsingBenchmark { private const string SampleRow = "Alice,30,Senior Engineer,London,true"; // BASELINE: traditional approach — allocates on every call [Benchmark(Baseline = true)] public string[] TraditionalSplit() { // String.Split allocates a new string[] AND a new string for each field return SampleRow.Split(','); } // OPTIMISED: Span-based parser from the previous section [Benchmark] public string[] SpanBasedParse() { return ZeroAllocationCsvParser.ParseLine(SampleRow.AsSpan()); } // PEAK: process without materialising any strings at all [Benchmark] public int SpanBasedCountCharsInAllFields() { int totalChars = 0; ZeroAllocationCsvParser.ProcessFieldsWithoutAllocation( SampleRow.AsSpan(), delimiter: ',', processor: (_, field) => totalChars += field.Length ); return totalChars; } } // ============================================================ // PRODUCTION RULES — read these before shipping Span-based code // ============================================================ public static class ProductionRules { public static void Demonstrate() { // RULE 1: Never store the result of .AsSpan() across an await // This won't compile — it's a safety guarantee, not a limitation // async Task BadAsync(string input) { // ReadOnlySpan<char> span = input.AsSpan(); // compile error CS4007 // await Task.Delay(1); // Console.WriteLine(span[0]); // can never reach here // } // RULE 2: MemoryPool<T>.Shared is cheaper than new byte[] for buffers > 85KB // Arrays > 85KB go to the Large Object Heap (LOH) — renting avoids that entirely using IMemoryOwner<byte> largeBuffer = MemoryPool<byte>.Shared.Rent(100_000); Console.WriteLine($"Rented large buffer: {largeBuffer.Memory.Length} bytes — no LOH pressure"); // RULE 3: ReadOnlySpan<char> comparisons are case-sensitive by default // Use MemoryExtensions.Equals for case-insensitive comparison ReadOnlySpan<char> headerName = "Content-Type".AsSpan(); ReadOnlySpan<char> incoming = "content-type".AsSpan(); bool caseSensitive = headerName.SequenceEqual(incoming); // false bool caseInsensitive = MemoryExtensions.Equals( headerName, incoming, StringComparison.OrdinalIgnoreCase); // true Console.WriteLine($"Case-sensitive match: {caseSensitive}"); // False Console.WriteLine($"Case-insensitive match: {caseInsensitive}"); // True // RULE 4: stackalloc size should be capped and validated at runtime // Never stackalloc based on untrusted user input — stack overflow is unrecoverable int userInputLength = 64; // imagine this came from a request if (userInputLength > 1024) throw new ArgumentException("Input too large for stack buffer"); Span<char> safeStackBuffer = stackalloc char[userInputLength]; Console.WriteLine($"Safe stack buffer ready: {safeStackBuffer.Length} chars"); } }
// | Method | Mean | Allocated |
// |-------------------------------|-----------|----------:|
// | TraditionalSplit | 185.3 ns | 240 B |
// | SpanBasedParse | 98.7 ns | 176 B |
// | SpanBasedCountCharsInAllFields| 41.2 ns | 0 B |
// ProductionRules.Demonstrate() output:
Rented large buffer: 131072 bytes — no LOH pressure
Case-sensitive match: False
Case-insensitive match: True
Safe stack buffer ready: 64 chars
| Feature / Aspect | Span<T> | Memory<T> |
|---|---|---|
| Struct kind | ref struct (stack-only) | Regular struct (heap-safe) |
| Can cross await boundary | No — compile error CS4007 | Yes — designed for async |
| Can be a class field | No — compile error CS8345 | Yes — full support |
| Can point at stackalloc | Yes — primary use case | No — stackalloc is stack-only |
| Can point at unmanaged memory | Yes — via unsafe constructor | Yes — via MemoryManager<T> |
| Get a Span<T> from it | Is a Span<T> | Call .Span property (free) |
| Use in LINQ / generics | No — ref struct restriction | Yes — normal type parameter |
| Allocation cost | Zero — 16 bytes on stack | Zero — 24 bytes on stack |
| Lifetime enforcement | Compile-time by ref struct rules | Runtime / IDisposable pattern |
| Ideal use case | Synchronous parsers, hot loops | Async I/O, pooled buffers |
🎯 Key Takeaways
- Span<T> is a 16-byte ref struct that holds a pointer and a length — it creates a zero-allocation view over any contiguous memory (heap array, stackalloc, or unmanaged) and is enforced as stack-only by the compiler
- Memory<T> is the async-safe sibling — use it in method signatures that contain await or need class fields, then call .Span inside synchronous helpers to do the actual work
- The pattern stackalloc + Span<T> eliminates heap allocation entirely for fixed-size intermediate buffers, but you must validate the size against a hard cap before allocating or risk an unrecoverable StackOverflowException
- MemoryPool<T>.
Shared.Rent()is the production tool for variable-size buffers in async I/O paths — it avoids Large Object Heap pressure for buffers over 85KB and keeps allocation off the critical path
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QWhy is Span<T> a ref struct, and what specific things does that prevent you from doing with it? Walk me through what would go wrong if it weren't a ref struct.
- QYou have a high-throughput HTTP server parsing request headers. Currently it uses string.Split and Substring and your GC Gen0 collections are spiking. How would you redesign the parsing code using Span<T> and Memory<T>, and what allocation profile would you expect after the change?
- QWhat's the difference between Memory<T>.Span and directly using a Span<T>, and why does calling .Span inside an async method work when storing a Span<T> field does not?
Frequently Asked Questions
Can I use Span with async/await in C#?
No — Span<T> is a ref struct and the compiler will reject it inside async methods with error CS4007. This is a safety rule, not a bug. Use Memory<T> in async method signatures instead, then call .Span on it inside synchronous sub-methods where you need to read or write the data.
Is Span faster than arrays in C#?
Span<T> doesn't make array access faster — the underlying data is identical. The performance gain comes from eliminating allocations: instead of creating a new array or string for every sub-slice operation, you create a Span that points into the existing data. Fewer allocations means fewer GC collections, which is where the real speedup appears in practice.
What is the difference between ReadOnlySpan and string in C#?
A string is an immutable heap-allocated object. ReadOnlySpan<char> is a zero-allocation view into a region of character data — it could be pointing at part of an existing string, a char array, or a stackalloc buffer. You get ReadOnlySpan<char> from a string via .AsSpan() at zero cost, and you can slice, search, and compare it without ever creating new string objects. Only when you need to pass the result to an API that requires a string do you call new string(span), which is the single allocation point.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.