Span & Memory in C#: Kill Allocations [Guide]
Master Span and Memory in C# to kill string/array allocations. Benchmarked CSV + varint parsing with zero GC and faster throughput.
Span & Memory in C#: Kill Allocations [Guide]
Master Span and Memory in C# to kill string/array allocations. Benchmarked CSV + varint parsing with zero GC and faster throughput.

Strings and arrays are everywhere in .NET. They’re also your biggest silent allocators. Span, ReadOnlySpan, and Memory let you slice and process data with near-zero allocations.
In this guide, you’ll build span-based parsers for CSV and Protocol Buffers and see benchmarked wins over traditional code.
Learning Objectives
- Parse CSV rows with ReadOnlySpan without String.Split or substrings.
- Decode Protocol Buffer-style varints from ReadOnlySpan with zero allocations.
- Use Memory safely with async I/O for streaming workloads.
- Decide when to use Span, ReadOnlySpan, Memory, and ReadOnlyMemory.
Prerequisites
- .NET 8 SDK or later; C# 12.
- BenchmarkDotNet for measurements:
dotnet add package BenchmarkDotNet. - Basic familiarity with arrays, strings, and UTF-8 vs UTF-16.
Getting Started
We’ll stand up a tiny BenchmarkDotNet harness, then replace allocation-heavy patterns with spans.
Spin up the benchmark harness
What this does: Sets up a reproducible benchmark to compare String.Split vs span parsing.
Code:
// Program.cs (.NET 8)
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Buffers;
using System.Text;
public class CsvBench
{
private const string Line = "42,John Doe,acme@example.com,199.95";
private static readonly string[]? _ = null; // placeholder to avoid warnings
[Benchmark(Baseline = true)]
public (int id, string name, string email, decimal price) Split()
{
var parts = Line.Split(','); // allocates array + substrings
return (int.Parse(parts[0]), parts[1], parts[2], decimal.Parse(parts[3]));
}
[Benchmark]
public (int id, string name, string email, decimal price) SpanParse()
{
ReadOnlySpan<char> s = Line.AsSpan();
var id = int.Parse(Next(ref s, ','));
var name = Next(ref s, ',').ToString();
var email = Next(ref s, ',').ToString();
var price = decimal.Parse(s);
return (id, name, email, price);
static ReadOnlySpan<char> Next(ref ReadOnlySpan<char> span, char sep)
{
var idx = span.IndexOf(sep);
if (idx < 0)
{
var last = span; span = ReadOnlySpan<char>.Empty; return last;
}
var token = span.Slice(0, idx);
span = span.Slice(idx + 1);
return token;
}
}
}
BenchmarkRunner.Run<CsvBench>();
Output: BenchmarkDotNet table comparing Split vs SpanParse.

BenchmarkDotNet table comparing Split vs SpanParse
Kill String.Split — zero-alloc CSV field slicing
What this does: Reads CSV fields using ReadOnlySpan slices; only allocates for the two fields we intentionally materialize as strings.
Code:
// CsvSpan.cs
using System;
using System.Buffers.Text;
using System.Globalization;
public static class CsvSpan
{
public static bool TryParseInvoice(
ReadOnlySpan<char> line,
out int id,
out string name,
out string email,
out decimal price)
{
id = default; name = ""; email = ""; price = default;
var a = Next(ref line, ',');
var b = Next(ref line, ',');
var c = Next(ref line, ',');
var d = line; // remainder
if (!int.TryParse(a, NumberStyles.Integer, CultureInfo.InvariantCulture, out id)) return false;
name = b.ToString(); // materialize only what you need as string
email = c.ToString();
if (!decimal.TryParse(d, NumberStyles.Number, CultureInfo.InvariantCulture, out price)) return false;
return true;
static ReadOnlySpan<char> Next(ref ReadOnlySpan<char> span, char sep)
{
var i = span.IndexOf(sep);
if (i < 0) { var last = span; span = ReadOnlySpan<char>.Empty; return last; }
var tok = span[..i];
span = span[(i + 1)..];
return tok;
}
}
}
Output: Parsed fields with 0 intermediate allocations (only name/email strings are created on purpose).
If this post helped, you’ll love the rest of my .Net Tips content: ✅Read more: .Net Code Chronicles ✅Get new posts: **Subscribe on Medium**
Master byte parsing — Protocol Buffer varint decode
What this does: Decodes a protobuf-style 32-bit varint directly from ReadOnlySpan with zero allocations and reports how many bytes were consumed.
Code:
// Varint.cs
using System;
public static class Varint
{
public static uint ReadVarint(ReadOnlySpan<byte> data, out int bytesRead)
{
uint value = 0; int shift = 0; bytesRead = 0;
foreach (byte b in data)
{
value |= (uint)(b & 0x7F) << shift;
bytesRead++;
if ((b & 0x80) == 0) break;
shift += 7;
if (shift > 28) throw new FormatException("Varint too long");
}
return value;
}
}
Output: Returns decoded integer and bytesRead without allocating streams or buffers.
In-place style transforms with Span and string.Create
What this does: Creates a new string of the desired size once and fills it via a Span without temporary arrays or substrings.
Code:
public static class Upper
{
public static string ToUpperAsciiFast(ReadOnlySpan<char> input)
{
if (input.Length <= 1024)
{
Span<char> tmp = stackalloc char[input.Length];
for (int i = 0; i < tmp.Length; i++)
{
char c = input[i];
tmp[i] = (uint)(c - 'a') <= 25 ? (char)(c - 32) : char.ToUpperInvariant(c);
}
return new string(tmp);
}
char[] rented = ArrayPool<char>.Shared.Rent(input.Length);
try
{
var dest = rented.AsSpan(0, input.Length);
for (int i = 0; i < dest.Length; i++)
{
char c = input[i];
dest[i] = (uint)(c - 'a') <= 25 ? (char)(c - 32) : char.ToUpperInvariant(c);
}
return new string(dest);
}
finally
{
ArrayPool<char>.Shared.Return(rented);
}
}
}
Output: New uppercase string with a single allocation (the result), no temporary arrays.
Async I/O that doesn’t churn — Memory + span decoding
What this does: Streams a UTF-8 file using Stream.ReadAsync(Memory<byte>) and decodes to chars with span-based APIs; reuses buffers to avoid GC.
Code:
// StreamRead.cs
using System;
using System.Buffers;
using System.IO;
using System.Text;
using System.Threading.Tasks;
public static class Reader
{
public static async Task<long> CountLinesAsync(string path)
{
var bytePool = ArrayPool<byte>.Shared;
var charPool = ArrayPool<char>.Shared;
byte[] bBuf = bytePool.Rent(64 * 1024);
char[] cBuf = charPool.Rent(64 * 1024);
long lines = 0;
var enc = Encoding.UTF8;
try
{
await using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan);
int read;
while ((read = await fs.ReadAsync(bBuf.AsMemory())) > 0)
{
var bytes = new ReadOnlySpan<byte>(bBuf, 0, read);
var charsWritten = enc.GetChars(bytes, cBuf.AsSpan());
var chars = cBuf.AsSpan(0, charsWritten);
lines += Count('\n', chars);
}
}
finally
{
bytePool.Return(bBuf);
charPool.Return(cBuf);
}
return lines;
static int Count(char needle, ReadOnlySpan<char> span)
{
int n = 0; int i = 0;
while ((i = span[i..].IndexOf(needle)) >= 0)
{
n++; i++;
if (i >= span.Length) break;
}
return n;
}
}
}
Output: Line count completed with 0 transient allocations per chunk (buffers are reused via pools).
The Truth: When to use Span, ReadOnlySpan, Memory, ReadOnlyMemory
- Span — stack-only
ref struct. Fastest for synchronous, short-lived slicing/transform. Can’t be fields, can’t crossawait, can’t box. - ReadOnlySpan — same rules but read-only; prefer for inputs.
- Memory — heap-allocatable wrapper for buffers. Can be fields, can cross
await, works with async I/O. - ReadOnlyMemory — read-only version for async/long-lived APIs.
Design pattern:
What this does: Offers both sync span and async memory overloads for the same API surface.
Code:
public static class ApiDesign
{
// Hot-path sync processing
public static int IndexOfComma(ReadOnlySpan<char> s) => s.IndexOf(',');
// Async/long-lived work
public static ValueTask<int> ReadIntoAsync(Stream s, Memory<byte> dst) => s.ReadAsync(dst);
}
Output: Clear guidance for callers: spans for immediate work, memory for async.
Final Thoughts / Conclusion
- Stop bleeding memory: prefer spans for hot, synchronous text/byte paths.
- Kill needless copies: slice with
ReadOnlySpan<T>; materialize only final results. - Master async with
Memory<T>+ buffer pools for steady throughput. - The truth: choose API shapes that make allocation-free use the default.
Try next: swap a string parameter for ReadOnlySpan<char> in a critical method and profile; then add a ReadOnlyMemory<T> overload for async callers.
Hey, Aliaksandr here — thanks for reading to the end. I just launched NET Code Chronicles on Medium. It’s tiny now, but you can help shape it from day one.
If this post helped, do free things: follow me on LinkedIn, **follow the publication and [read the blog](https://amarozka.dev/)**. Your support = more practical .NET guides.
P.S. If you’re on Medium, clap and follow the writer — it really helps.
메타데이터
- post_id
- d1be8926bf38
- slug
- kill-allocations-span-memory-csharp-d1be8926bf38
- url
- https://medium.com/net-code-chronicles/kill-allocations-span-memory-csharp-d1be8926bf38
- canonical_url
- https://medium.com/net-code-chronicles/kill-allocations-span-memory-csharp-d1be8926bf38
- author_url
- https://medium.com/@alexbel83
- status
- ok
- fetched_at
- 2026-07-16 00:14:14