← Back to list

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.

Aliaksandr Marozka in .Net Code Chronicles · 2025-10-30 15:36 · 20 claps · 5.1 min read
#dotnet #spans #csharp #programming #coding
Open on Medium ↗
Wiki topics: EVAL · Evaluation & Benchmarks 💻 · Programming

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

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 cross await, 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.

Links: GitHub · LinkedIn

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