Skip to main content

C# Span Tutorial: Zero-Allocation Performance Guide

Learn C# Span and Memory with runnable examples, benchmarks, and best practices. Start writing zero-allocation, high-performance .NET code today.

If you have ever profiled a hot code path in .NET and watched the garbage collector eat 30% of your CPU time, you already understand why C# Span<T> exists. Introduced in .NET Core 2.1 and now central to the entire BCL, Span<T> and its heap-friendly sibling Memory<T> let you slice, parse, and transform contiguous memory without allocating a single byte. This tutorial covers how both types work, when to use each, runnable benchmarks that prove the gains, and the pitfalls that trip up even senior developers.

Why C# Span<T> Matters for Performance Optimization

Most everyday C# code allocates far more than developers realise. Calling Substring, Split, ToArray, or Skip().Take() copies data onto the managed heap. Each copy is cheap in isolation, but in a web API handling 5,000 requests per second, those copies become gen0 collections, which become GC pauses, which become p99 latency spikes.

The traditional fix was unsafe pointers — fast, but dangerous and unverifiable. Span<T> gives you the same performance profile with full type and bounds safety. It is a ref struct: a tiny value type holding a reference to the start of a memory block plus a length. Creating a span copies nothing; slicing a span copies nothing. It is a window over memory you already have.

Crucially, a Span<T> can point at three different kinds of memory with one unified API:

  • Managed heap — arrays, strings, List<T> backing stores
  • Stack memory — via stackalloc, with zero GC involvement
  • Unmanaged memory — native buffers from interop or NativeMemory.Alloc

That unification is the real win. You can write one parsing method that works on all three sources without overloads, without copying, and without unsafe.

C# Span<T> Tutorial: Your First Zero-Allocation Code

Start with the classic allocation trap — parsing a CSV line with Split:

// SLOW: allocates a string[] plus one string per field, every call
public static decimal ParseTotalSlow(string line)
{
    string[] parts = line.Split(',');
    return decimal.Parse(parts[2]) * int.Parse(parts[3]);
}

For a 4-field line, that is five allocations per call. Now the span version:

using System;
using System.Globalization;

public static decimal ParseTotalFast(ReadOnlySpan<char> line)
{
    // Slice off field 0 and field 1 without allocating
    for (int i = 0; i < 2; i++)
    {
        int comma = line.IndexOf(',');
        line = line[(comma + 1)..];
    }

    int next = line.IndexOf(',');
    ReadOnlySpan<char> priceSpan = line[..next];
    ReadOnlySpan<char> qtySpan   = line[(next + 1)..];

    decimal price = decimal.Parse(priceSpan, CultureInfo.InvariantCulture);
    int qty       = int.Parse(qtySpan, CultureInfo.InvariantCulture);

    return price * qty;
}

// Usage - implicit conversion from string to ReadOnlySpan<char>
decimal total = ParseTotalFast("SKU-88,Widget,19.99,3");

Total allocations: zero. The Parse overloads that accept ReadOnlySpan<char> have existed since .NET Core 2.1 precisely for this pattern. Note the range syntax line[..next] — C# ranges work natively on spans and are compiled to a Slice call with no copy.

Using stackalloc for Small Buffers

When you need a scratch buffer, stackalloc paired with a span keeps it entirely off the heap:

public static string ToHex(ReadOnlySpan<byte> bytes)
{
    // Safe, verifiable stackalloc - no 'unsafe' keyword needed
    Span<char> buffer = stackalloc char[bytes.Length * 2];

    for (int i = 0; i < bytes.Length; i++)
    {
        int b = bytes[i];
        buffer[i * 2]     = GetHexChar(b >> 4);
        buffer[i * 2 + 1] = GetHexChar(b & 0xF);
    }

    return new string(buffer); // one allocation, only for the result
}

private static char GetHexChar(int value) =>
    (char)(value < 10 ? '0' + value : 'a' + value - 10);

Critical rule: never stackalloc inside a loop, and never stackalloc a size derived from untrusted input. The stack is typically 1 MB per thread; overflowing it kills the process with no catchable exception. The standard guardrail is a size threshold with an ArrayPool<T> fallback:

using System.Buffers;

const int StackLimit = 256;

char[]? rented = null;
Span<char> buffer = length <= StackLimit
    ? stackalloc char[StackLimit]
    : (rented = ArrayPool<char>.Shared.Rent(length));

try
{
    buffer = buffer[..length];
    // ... work with buffer ...
}
finally
{
    if (rented is not null)
        ArrayPool<char>.Shared.Return(rented);
}

This hybrid pattern appears throughout the .NET runtime itself and is the single most valuable idiom in high-performance C#.

Memory<T> vs Span<T>: Choosing the Right Type

Span<T> is a ref struct, which the compiler enforces can only live on the stack. That restriction is what makes it safe — a span can never outlive the memory it points to. But it also means a span cannot:

  • Be a field in a class or a regular struct
  • Be captured by a lambda or local function closure
  • Be used across an await or yield return
  • Be boxed or stored in a collection

This is where Memory<T> comes in. It is a normal struct that describes the same memory but can live on the heap. You convert it to a span only when you need to touch the data:

public sealed class ChunkReader
{
    private readonly Memory<byte> _buffer; // legal: Memory can be a field

    public ChunkReader(Memory<byte> buffer) => _buffer = buffer;

    public async Task<int> ReadAsync(Stream stream, CancellationToken ct)
    {
        // Memory<T> works across await; Span<T> would not compile here
        int read = await stream.ReadAsync(_buffer, ct);

        // Get a span only for synchronous work
        Span<byte> window = _buffer.Span[..read];
        Normalise(window);

        return read;
    }

    private static void Normalise(Span<byte> data)
    {
        for (int i = 0; i < data.Length; i++)
            if (data[i] == (byte)'\r') data[i] = (byte)'\n';
    }
}

The practical rule for C# performance optimization: use Span<T> for synchronous method parameters and locals — it is the faster type and should be your default. Reach for Memory<T> only when you must store the reference in a field or cross an async boundary. Accessing .Span has a small cost, so hoist it out of loops rather than calling it repeatedly.

ReadOnlySpan<T> and the String Advantage

Always prefer ReadOnlySpan<T> for parameters you do not mutate. It documents intent, prevents accidental writes, and accepts more source types — including string, which converts implicitly. There is also a hidden compiler optimisation: a ReadOnlySpan<byte> initialised from a constant array is embedded directly in the assembly's data section with no runtime allocation at all:

// The JIT emits this as a direct pointer into the PE file - zero allocation
private static ReadOnlySpan<byte> JsonPrefix => "{\"data\":"u8;

public static bool StartsWithPrefix(ReadOnlySpan<byte> payload) =>
    payload.StartsWith(JsonPrefix);

The u8 suffix (C# 11+) produces a UTF-8 ReadOnlySpan<byte> literal — ideal for network protocol and JSON parsing where you want to avoid transcoding to UTF-16 entirely.

Measuring the Difference with BenchmarkDotNet

Never optimise without measuring. Here is a benchmark you can run today:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

[MemoryDiagnoser]
public class ParsingBenchmarks
{
    private const string Line = "SKU-88,Widget,19.99,3";

    [Benchmark(Baseline = true)]
    public decimal Split() => ParseTotalSlow(Line);

    [Benchmark]
    public decimal Span() => ParseTotalFast(Line);
}

BenchmarkRunner.Run<ParsingBenchmarks>();

On typical .NET 8/9 hardware the span version runs roughly 2–3x faster and, more importantly, reports 0 B allocated versus around 200 B for the Split version. The [MemoryDiagnoser] attribute is what makes allocation regressions visible — add it to every performance benchmark you write.

Best Practices and Common Pitfalls

  • Do not return a span over stackalloc memory. The compiler catches most cases, but returning a span that points at a caller's expired stack frame is undefined behaviour. Return the data, not the window.
  • Watch for aliasing. Two spans can overlap the same array. CopyTo handles overlap correctly, but hand-written loops may not — use MemoryExtensions.Overlaps if you are unsure.
  • Spans do not keep arrays alive in the pool sense. If you rent from ArrayPool<T>, return it exactly once and stop using every span derived from it immediately.
  • Do not micro-optimise cold paths. Span code is harder to read. Apply it to measured hot paths: parsers, serialisers, request pipelines, image and buffer processing. A controller action that runs 10 times a day gains nothing.
  • Use the built-in helpers. MemoryExtensions gives you IndexOf, Split, Trim, SequenceEqual, BinarySearch, and Sort on spans — most are SIMD-accelerated and will beat any loop you write by hand.
  • Prefer SequenceEqual over element loops for comparison; it vectorises automatically.
  • Beware string interning assumptions. A ReadOnlySpan<char> over a string is not a string — dictionary lookups need .GetAlternateLookup<ReadOnlySpan<char>>() (.NET 9+) to stay allocation-free.

Advanced: Pipelines and SIMD

Once spans are natural to you, the next tier of C# performance optimization is System.IO.Pipelines for streaming I/O and System.Numerics.Vector<T> / System.Runtime.Intrinsics for explicit SIMD. Both build directly on Span<T> and Memory<T>, so the knowledge compounds. Vector.LoadUnsafe and Vector.StoreUnsafe operate on spans, letting you process 16 or 32 bytes per instruction.

Key Takeaways

C# Span<T> is the single highest-leverage tool for eliminating allocations in .NET, and it costs you nothing in safety. To summarise:

  • Span<T> is a stack-only window over contiguous memory — slicing is free, bounds are checked.
  • Memory<T> is the heap-capable equivalent for fields and async methods; call .Span for the actual work.
  • Default to ReadOnlySpan<T> parameters; use u8 literals for UTF-8 constants.
  • Combine stackalloc with an ArrayPool<T> fallback above a ~256-element threshold.
  • Always verify gains with BenchmarkDotNet and [MemoryDiagnoser] — guessing is how optimisation efforts fail.
  • Target hot paths only; readability is a real cost and cold code does not repay it.

Rewrite one hot parsing method in your codebase using these patterns, benchmark before and after, and you will have a concrete number to justify the next one.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...