Use Span in .NET to Achieve Higher Performance with Less Memory Usage
Most .NET performance problems have the same shape: code that is correct but quietly
allocates. Every substring is a new string, every array slice is a new array, every parse makes
the garbage collector work harder. Span<T> (added in .NET Core 2.1) is
the type that fixes this class of problem: it lets you work with a window over existing
memory β a slice of an array, a string, or even stack memory β without copying anything.
This article explains what it is, how it works under the hood, and where it pays off, with
commented examples you can drop into a console project.
The problem: slicing copies
// The everyday way: every one of these lines allocates a NEW object.
string log = "2026-09-28T12:34:56Z,GET,/api/orders,200,148";
string datePart = log.Substring(0, 10); // allocates a new 10-char string
string pathPart = log.Substring(16, 11); // allocates another string
string[] parts = log.Split(','); // allocates an array + 5 strings
int len = pathPart.Length; // fine β but we already copied the data
For one line of a log file, that is harmless. For a parser chewing through a million log lines, it is a hundred million allocations β and the GC has to clean up every one. The data was already in memory; we just wanted to look at parts of it.
The fix: a window, not a copy
Span<char> (and ReadOnlySpan<char> for strings) is a small struct
β two fields: where the memory starts, and how many elements it covers. It holds no data
itself. Slicing it just adjusts those two fields:
ReadOnlySpan<char> log = "2026-09-28T12:34:56Z,GET,/api/orders,200,148".AsSpan();
ReadOnlySpan<char> datePart = log.Slice(0, 10); // no allocation: just a window
ReadOnlySpan<char> pathPart = log.Slice(16, 11); // another window over the same chars
// Spots can be compared without materializing strings:
bool isGet = log.Slice(23, 3).SequenceEqual("GET"); // char-by-char, zero copies
// And they can be parsed without strings:
int status = int.Parse(log.Slice(35, 3)); // "200" -> 200, no allocation
How it works: a span is a ref struct β a struct the runtime
forces to stay on the stack. That restriction is what makes it safe: because a span cannot be
boxed, stored in a field of a normal class, or captured by an async method, it can never
outlive the memory it points at. You get pointer-like power with GC-visible safety. When you
do need to heap-allocate the window (e.g. to store it in a class), you use
Memory<T> instead β the same idea with fewer restrictions and slightly more
overhead.
Example 1: parsing without allocating
A realistic log-parsing routine, written the span way:
// Parses "date,method,path,status,size" without allocating a single string.
// Returns the parsed fields through out params as spans into the original text.
public static bool TryParseLogLine(ReadOnlySpan<char> line,
out ReadOnlySpan<char> date,
out ReadOnlySpan<char> method,
out ReadOnlySpan<char> path,
out int status)
{
date = method = path = default;
status = 0;
// IndexOf on a span scans memory directly β no char[] copy, no substring.
int c1 = line.IndexOf(',');
if (c1 < 0) return false;
date = line.Slice(0, c1); // window 1: everything before the comma
int c2 = line.Slice(c1 + 1).IndexOf(',') + c1 + 1;
if (c2 < c1) return false;
method = line.Slice(c1 + 1, c2 - c1 - 1); // window 2: the method
int c3 = line.Slice(c2 + 1).IndexOf(',') + c2 + 1;
if (c3 < c2) return false;
path = line.Slice(c2 + 1, c3 - c2 - 1); // window 3: the path
// int.Parse accepts a ReadOnlySpan<char> directly β no string needed.
return int.TryParse(line.Slice(c3 + 1, 3), out status);
}
// Usage:
foreach (string raw in File.ReadLines("access.log"))
{
if (TryParseLogLine(raw.AsSpan(), out var date, out var method, out var path, out var status))
{
// process the windows β the source string stays the only allocation
if (status >= 500) Console.WriteLine($"{date.ToString()} {method.ToString()}{path.ToString()} -> {status}");
}
}
Calling .ToString() on a span does allocate (it materializes a string) β
but only at the boundary where you actually need one. Inside the parser, zero allocations.
Example 2: slicing arrays the same way
Spans are not just for strings. Any array slices for free:
byte[] frame = ReadNetworkFrame(); // e.g. [type:1][length:2][payload:N][crc:2]
Span<byte> span = frame.AsSpan();
byte type = span[0]; // header byte β direct memory access
ushort length = BitConverter.ToUInt16(span.Slice(1, 2)); // window over 2 bytes
Span<byte> payload = span.Slice(3, length); // the payload β again, no copy
Span<byte> crc = span.Slice(3 + length, 2); // the trailing CRC
// Write into a window just like an array (this is Span, not ReadOnlySpan):
payload[0] = 0xFF;
// Bulk copies between windows use fast block operations:
Span<byte> target = new byte[length];
payload.CopyTo(target); // memcpy under the hood
The classic use is protocol/binary parsing: header, length, payload, checksum β each a
window into one buffer that was allocated once. Compare with the pre-span approach of
Array.Copy into scratch buffers at every step.
Example 3: stackalloc β buffers without the heap
Small short-lived buffers can live on the stack entirely:
// Turn an int into its digit characters without a string in between.
public static int WriteDigits(Span<char> dest, int value)
{
value.ToString(dest, out int charsWritten); // formats directly into dest
return charsWritten;
}
// Caller: allocate on the stack β freed when the method returns, zero GC cost.
Span<char> buf = stackalloc char[20];
int n = WriteDigits(buf, 123456);
Console.WriteLine(buf.Slice(0, n).ToString()); // "123456"
stackalloc with spans is safe in a way the old raw-pointer version never was:
if the buffer is bigger than the stack budget, the runtime silently falls back to a heap
array. Combine with ArrayPool<T>.Shared for larger buffers and you can build
hot paths that allocate essentially never.
Example 4: the API surface worth memorizing
| Member | What it does |
|---|---|
span.Slice(start, length) | a window into the window β O(1), no copy |
span.IndexOf(...) / LastIndexOf | search without materializing |
span.SequenceEqual(other) | compare contents β replaces == on strings |
span.StartsWith(...) | prefix check, no copy |
int.Parse(span) / TryParse | numeric parsing straight from chars |
span.Trim() / TrimStart() / TrimEnd() | whitespace handling without new strings |
span.CopyTo(dest) / span.TryCopyTo(...) | block copy between spans |
span.Fill(value) | set every element (e.g. zero a buffer) |
string.Create(...) | build a string by writing into its memory once β no intermediate builders |
MemoryMarshal.Cast<TFrom,TTo>(span) | reinterpret bytes as structs (the safe reinterpret_cast) |
Rules, limits, and gotchas
- A span is a view, not an owner. If the underlying array/string is reallocated or disposed, the window dangles β keep the owner alive as long as the span lives.
- Ref-struct limits are the safety contract. No fields in classes, no
async/await, no capturing in lambdas. When you hit these walls, switch to
Memory<T>/ReadOnlyMemory<T>β the heap-friendly cousin. - Strings are immutable; spans over them must be read-only. That is why
string.AsSpan()returnsReadOnlySpan<char>. - Don't cache slices. A method that returns a span into a buffer it does
not own is a bug factory. Document ownership, or materialize with
ToArray()/ToString()at the boundary. - Measure. Spans shine in hot paths (parsers, serializers, protocol code,
bulk text processing). In cold paths, the extra ceremony is not worth it β a plain
Substringis perfectly fine code.
Where this shows up in the framework itself
Once you see spans you see them everywhere in modern .NET: string.Split has a
SpanCharEnumerator-style overload that avoids allocations, the JSON serializer's
UTF-8 path is span-based end to end, StreamReader, Socket, and
Pipelines accept spans, and Base64/HashCode/Utf8Parser all operate on spans. The
BCL uses them so hot paths can be allocation-free β and now your code can too.
Try it
The examples above compile as-is in a modern .NET console app. Benchmark with BenchmarkDotNet before/after on a real workload: the usual result on parsing-heavy code is fewer allocations (often to zero) and double-digit-percent throughput gains β the same work, the same correctness, just without asking the garbage collector to keep up.