π 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>andstackalloc - Explain the
ref structrestrictions 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
(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 field | class instances live on the heap; the span must stay on the stack |
Use a span across an await | async 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 argument | the 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:
- Create a new project called
Spans. - Given
"10,20,30,40,50", writeint SumCsv(ReadOnlySpan<char> line)that sums the comma-separated integers without callingSplitorSubstring. - Loop: find the next comma with
line.IndexOf(','), parse the field before it withint.Parse(line.Slice(0, comma)), then re-sliceline = line.Slice(comma + 1). Handle the final field (no comma). - Call it as
SumCsv("10,20,30,40,50".AsSpan())and print the total (150). - Bonus: Use
stackallocto build a smallSpan<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]; useReadOnlySpan<T>for read-only APIs andstring.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 aref struct: stack-only β no class fields, noawait, no iterators, no boxing.Memory<T>is the storable, async-safe cousin β pass it around, then call.Spanto 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.