Span and Memory in C# — Zero-Allocation Slicing Explained
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
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
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
Internally, Span
The key insight is that Span
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
Memory
The key production pattern is: accept Memory
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
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
Another production reality: Span
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 | Memory |
|---|---|---|
| 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 |
| Get a Span | Is a Span | 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
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
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
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
.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
- ✕Mistake 1: Calling String.Substring inside a loop instead of slicing a Span — Symptom: allocations don't drop despite using Span elsewhere; dotMemory shows thousands of short-lived strings — Fix: obtain a ReadOnlySpan
once with AsSpan(), then use Slice() and IndexOf() exclusively inside the loop; only call new string(span) when handing the result back to non-Span-aware code - ✕Mistake 2: Using Span
in an async method or as a class field — Symptom: compiler errors CS4007 ('Spans may not be used in async methods') or CS8345 ('cannot be used as a field') which confuse developers who then try to cast or box their way around them — Fix: replace Span with Memory in the method signature or class field; call .Span inside each synchronous sub-method when you need to read or write the actual data - ✕Mistake 3: stackallocating a buffer sized from untrusted or unbounded input — Symptom: the process crashes with a fatal StackOverflowException that bypasses all try/catch blocks and terminates the app with no logging — Fix: always guard stackalloc with an explicit size check (e.g. if (size > 4096) throw) and fall back to MemoryPool
.Shared.Rent() for larger payloads
Interview Questions on This Topic
- QWhy is Span
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
and Memory , and what allocation profile would you expect after the change? - QWhat's the difference between Memory
.Span and directly using a Span , and why does calling .Span inside an async method work when storing a Span field does not?
Frequently Asked Questions
Can I use Span with async/await in C#?
No — Span
Is Span faster than arrays in C#?
Span
What is the difference between ReadOnlySpan and string in C#?
A string is an immutable heap-allocated object. ReadOnlySpan
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.