Skip to main content

πŸ“ Lesson 4.2: Span and Memory

Span<T> lets you view and slice arrays, strings, and buffers without copying them. It's the cornerstone of high-performance, low-allocation C# β€” powering fast parsing, formatting, and I/O across modern .NET.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what Span<T> is and why it avoids allocations
  • Slice arrays and strings with spans and ranges
  • Use ReadOnlySpan<T> and stackalloc
  • Explain the ref struct restrictions on spans
  • Use Memory<T> where spans can't go (async, fields)

Estimated Time: 75 minutes

Project: Parse and process text with zero allocations using spans.

In This Lesson

The Cost of Copying

Slicing data traditionally means copying it. Every Substring, every array[a..b] copy, allocates a new object on the heap β€” adding GC pressure (Lesson 4.1). In a hot loop parsing millions of records, those copies dominate.

string csv = "name,age,city";

// Each Split entry and each Substring is a NEW heap-allocated string
string[] parts = csv.Split(',');       // allocates an array + 3 strings
string first = csv.Substring(0, 4);    // allocates another string

πŸ“– Definition

Span<T> is a lightweight view (or "window") over a contiguous region of memory β€” part of an array, a string, or a stack buffer β€” without owning or copying it. Slicing a span just creates another view; no allocation occurs.

Span as a Window

A Span<T> holds a reference to the start of some memory and a length. It doesn't copy the data β€” it points into it. Writing through a span modifies the underlying storage:

int[] numbers = { 10, 20, 30, 40, 50 };

Span<int> span = numbers;        // a view over the whole array (no copy)
span[0] = 99;                    // writes through to the array
Console.WriteLine(numbers[0]);   // 99 β€” same underlying memory
graph LR ARR["int[] { 10, 20, 30, 40, 50 }
(the real memory)"] S1["Span: whole array"] -->|"views"| ARR S2["Span: Slice(1, 3)
= 20, 30, 40"] -->|"views part of"| ARR style ARR fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px style S1 fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style S2 fill:#eff6ff,stroke:#3b82f6,stroke-width:2px

Multiple spans can view the same array β€” or different parts of it β€” all sharing one allocation.

Slicing Without Allocating

The whole point: Slice (or the range operator [a..b]) produces a sub-view with no allocation β€” just a new start and length pointing into the same memory:

int[] numbers = { 10, 20, 30, 40, 50 };
Span<int> all = numbers;

Span<int> middle = all.Slice(1, 3);   // { 20, 30, 40 } β€” no copy
Span<int> tail = all[2..];            // { 30, 40, 50 } β€” range syntax, no copy

middle[0] = 999;                       // writes into the original array
Console.WriteLine(numbers[1]);        // 999

Contrast a summing routine that takes a Span<int> β€” it works on any array or slice with zero copying:

static long Sum(ReadOnlySpan<int> values)   // accepts arrays AND slices, no alloc
{
    long total = 0;
    foreach (int v in values) total += v;
    return total;
}

int[] data = Enumerable.Range(1, 100).ToArray();
Console.WriteLine(Sum(data));            // whole array
Console.WriteLine(Sum(data.AsSpan(0, 10))); // first 10 β€” no new array

πŸ’‘ ReadOnlySpan<T> for read-only views

Use ReadOnlySpan<T> when a method only reads β€” it's the idiomatic parameter type for high-performance APIs and expresses intent (the callee can't mutate your data). A Span<T> converts to a ReadOnlySpan<T> implicitly.

Spans over Strings

Strings are immutable, but you can create a ReadOnlySpan<char> over one with AsSpan() β€” enabling substring-like operations without allocating new strings:

string date = "2026-08-07";

ReadOnlySpan<char> span = date.AsSpan();

ReadOnlySpan<char> yearSpan  = span.Slice(0, 4);   // "2026" β€” no new string
ReadOnlySpan<char> monthSpan = span.Slice(5, 2);   // "08"
ReadOnlySpan<char> daySpan   = span[8..];          // "07"

// Modern parsing accepts spans directly β€” parse without ever allocating a substring
int year  = int.Parse(yearSpan);
int month = int.Parse(monthSpan);
int day   = int.Parse(daySpan);

Console.WriteLine($"{year}/{month}/{day}");   // 2026/8/7

βœ… The allocation win

The traditional version β€” date.Substring(0, 4) then int.Parse β€” allocates a throwaway string for every field. The span version allocates nothing. Across millions of parses, that's the difference between constant GC churn and none.

πŸ’‘ Span-friendly APIs are everywhere now

Modern .NET overloads accept spans: int.Parse/TryParse, span.IndexOf, span.Trim(), MemoryExtensions.Split, and formatting via TryFormat. Reach for these on hot paths to stay allocation-free.

stackalloc and Buffers

For small, short-lived buffers, stackalloc allocates on the stack instead of the heap β€” no GC involvement at all. Combined with Span<T>, it gives you a scratch buffer that's freed instantly when the method returns:

Span<int> buffer = stackalloc int[16];   // 16 ints on the stack β€” zero heap allocation

for (int i = 0; i < buffer.Length; i++)
{
    buffer[i] = i * i;
}
Console.WriteLine(buffer[4]);   // 16

⚠️ Keep stackalloc small

The stack is limited (typically ~1 MB per thread). Allocating a large or variable, unbounded size with stackalloc risks a StackOverflowException β€” which cannot be caught and crashes the process. Use it only for small, fixed-size buffers (a common cap is a few hundred bytes); for anything larger, rent from ArrayPool<T> (next lesson) or use a normal array.

The ref struct Rules

Span<T> is a ref struct (Lesson 4.1) β€” a value type that the compiler guarantees lives only on the stack. This guarantee is what makes it safe to point at stack memory, but it comes with restrictions:

You cannot…Because…
Store a Span<T> in a class fieldclass instances live on the heap; the span must stay on the stack
Use a span across an awaitasync state machines may live on the heap
Use a span in an iterator (yield)same reason β€” the state machine can be heap-allocated
Box a span (store in object)boxing moves it to the heap
Use it as a generic type argumentthe generic instantiation could end up on the heap

πŸ’‘ In short: spans are for synchronous, local, on-stack work

Use Span<T> as a local variable and method parameter within a single synchronous call. If you hit "can't use Span here" β€” usually because of async or a field β€” that's the signal to reach for Memory<T> instead.

Memory<T> for Async

Memory<T> is the heap-friendly cousin of Span<T>. It represents the same idea β€” a view over contiguous memory without copying β€” but as a regular struct (not a ref struct), so it can be stored in fields and used across await.

async Task ProcessAsync(Memory<byte> buffer)   // Memory is allowed across await
{
    int bytesRead = await ReadIntoAsync(buffer);

    // Get a Span only when you need to touch the elements (synchronously)
    Span<byte> span = buffer.Span;
    Process(span.Slice(0, bytesRead));
}

βœ… The relationship

Think of Memory<T> as the storable handle and Span<T> as the working view. Pass Memory<T> through async methods and fields; call .Span to get a span at the moment you actually read/write the elements. Async I/O APIs like Stream.ReadAsync take Memory<byte> for exactly this reason.

πŸ’‘ When do you need any of this?

Most application code never needs spans β€” the runtime and libraries already use them under the hood. Reach for them when profiling shows allocation/GC is a real bottleneck: parsers, serializers, network buffers, tight numeric loops. Elsewhere, clarity beats micro-optimization (a recurring theme of this module).

Exercise & Quiz

πŸ‹οΈ Exercise: Allocation-Free CSV Field Sum

Objective: Parse and sum numbers from a string using spans, with no substring allocations.

Instructions:

  1. Create a new project called Spans.
  2. Given "10,20,30,40,50", write int SumCsv(ReadOnlySpan<char> line) that sums the comma-separated integers without calling Split or Substring.
  3. Loop: find the next comma with line.IndexOf(','), parse the field before it with int.Parse(line.Slice(0, comma)), then re-slice line = line.Slice(comma + 1). Handle the final field (no comma).
  4. Call it as SumCsv("10,20,30,40,50".AsSpan()) and print the total (150).
  5. Bonus: Use stackalloc to build a small Span<int> of squares and sum it.

Starter Code:

Console.WriteLine(SumCsv("10,20,30,40,50".AsSpan()));   // 150

static int SumCsv(ReadOnlySpan<char> line)
{
    int total = 0;
    // TODO: loop using IndexOf(',') and int.Parse on slices; handle the last field
    return total;
}
πŸ’‘ Hint

While line.IndexOf(',') returns a non-negative index, parse line.Slice(0, comma), add to total, and set line = line.Slice(comma + 1). After the loop, parse the remaining line (the last field). int.Parse has a ReadOnlySpan<char> overload β€” no substring needed.

βœ… Solution
Console.WriteLine(SumCsv("10,20,30,40,50".AsSpan()));   // 150

// Bonus
Span<int> squares = stackalloc int[5];
for (int i = 0; i < squares.Length; i++) squares[i] = (i + 1) * (i + 1);
int sqSum = 0;
foreach (int s in squares) sqSum += s;
Console.WriteLine(sqSum);   // 55

static int SumCsv(ReadOnlySpan<char> line)
{
    int total = 0;
    int comma;
    while ((comma = line.IndexOf(',')) >= 0)
    {
        total += int.Parse(line.Slice(0, comma));   // parse field, no substring
        line = line.Slice(comma + 1);               // advance the view
    }
    total += int.Parse(line);                       // last field
    return total;
}

No Split, no Substring, zero string allocations β€” the whole parse works over views into the original string.

🎯 Quick Quiz

Question 1: What does slicing a Span<T> allocate?

Question 2: Why can't you use a Span<T> across an await?

Question 3: When should you use Memory<T> instead of Span<T>?

Summary

πŸŽ‰ Key Takeaways

  • Span<T> is a view over contiguous memory (array, string, stack buffer) β€” slicing it copies nothing, avoiding allocations.
  • Slice with .Slice(start, length) or ranges [a..b]; use ReadOnlySpan<T> for read-only APIs and string.AsSpan() for allocation-free string work (e.g. int.Parse(span)).
  • stackalloc + Span<T> gives a heap-free scratch buffer β€” but keep it small (stack overflow risk).
  • Span<T> is a ref struct: stack-only β€” no class fields, no await, no iterators, no boxing.
  • Memory<T> is the storable, async-safe cousin β€” pass it around, then call .Span to work with elements. Reach for all this only when profiling shows allocations matter.

πŸ“š Additional Resources

πŸš€ What's Next?

You can process data without copying it. To close the module, we measure and reduce allocations directly. In Lesson 4.3: Allocations, GC, and Benchmarking, you'll learn how the garbage collector works and prove your optimizations with BenchmarkDotNet.

πŸŽ‰ Zero-copy achieved!

You can slice and parse without allocating. Next: measuring allocations and taming the GC.