Everything You Need to Know About Span and ReadonlySpan in C#

 Memory views, lifetime rules, and why Span<T> is not just a faster array

Introduction:

Span<T> was introduced to solve a problem that long existed in .NET but was rarely addressed cleanly: working with contiguous memory efficiently, safely, and without allocations, while still staying inside the managed runtime.

If you’re not a member, I’ve got you covered!

If you enjoy it, consider clapping, subscribing, or buying me a coffee to show your support! ❤

Most experienced .NET developers have encountered Span<T> by now. Many avoid it because it feels restrictive or awkward. Others use it assuming it is simply a faster array. Both positions stem from the same issue, an incorrect mental model.

Span<T> is not a collection. It does not own data. It is a temporary, constrained view over memory with compiler enforced lifetime rules. Those rules are not accidental, they are the entire design.

This article explains how Span<T> and ReadOnlySpan<T> actually behave, why the restrictions exist, and how to use them correctly in real production code.


The Core Idea: A View Over Memory, Not an Owner

A Span<T> represents a window over a contiguous region of memory. That memory might come from an array, the stack, a string, or unmanaged memory. The span itself owns nothing.

This is the first mental shift most developers miss.

An array owns memory. A List<T> owns memory. A Span<T> only describes memory. It knows where the memory starts and how long it is, nothing more.

That distinction explains almost every design decision around spans. Creating a slice does not copy data. It simply creates another view over the same memory.

void Example()
{
int[] data = { 1, 2, 3, 4 };
Span<int> slice = data.AsSpan(1, 2);
slice[0] = 42; // data is now { 1, 42, 3, 4 }
}

There is no allocation here. No copying. Mutation flows straight through because both the array and the span refer to the same underlying memory.

Where Span<T> Gets Its Memory

A span can only exist over contiguous memory. The most common sources are arrays.

void Process(Span<byte> buffer)
{
buffer[0] = 0xFF;
}

byte[] data = new byte[128];
Process(data);

Here, the span points directly into the array’s backing storage.

Spans can also be created over stack memory using stackalloc.

void ProcessPacket(ReadOnlySpan<byte> packet)
{
Span<byte> header = stackalloc byte[16];
packet.Slice(0, 16).CopyTo(header);
ParseHeader(header);
}

This memory lives only for the duration of the method call. When the method returns, it is gone. The compiler knows this and enforces rules to prevent misuse.

Strings are another important source, but only for ReadOnlySpan<char>.

bool HasPrefix(string input)
{
ReadOnlySpan<char> span = input;
return span.StartsWith("CMD:", StringComparison.Ordinal);
}

This creates a read-only view over the string’s internal character buffer. No substring allocation occurs.

Writable spans over strings are not allowed because strings are immutable by design.

Why Span<T> Is a ref struct

Span<T> is declared as a ref struct. This single keyword explains nearly every restriction developers encounter.

A ref struct is stack-bound. The compiler enforces that it cannot outlive the stack frame in which it was created. Because of this, a Span<T> cannot be stored in fields, boxed, captured by lambdas, or used across async boundaries.

Consider this example.

Span<byte> GetBuffer()
{
Span<byte> buffer = stackalloc byte[128];
return buffer;
}

This does not compile. The returned span would point to stack memory that is destroyed when the method returns. The compiler rejects this code before it can ever run.

With unsafe pointers, this would compile and fail at runtime. With spans, the bug is made unrepresentable.

The same reasoning applies to async code.

async Task ProcessAsync(Span<byte> buffer)
{
await Task.Delay(10);
Use(buffer);
}

This also does not compile. After await, execution may resume on a different stack. Any stack-backed memory the span referred to could already be gone.

These restrictions are not limitations of the type system. They are safety guarantees.

Lifetime Rules and What the Compiler Is Protecting You From

The compiler tracks where a span comes from and how long the underlying memory is guaranteed to live.

Returning a span backed by an array is allowed.

Span<int> GetSlice(int[] data)
{
return data.AsSpan(0, 4);
}

Returning a span backed by stack memory is forbidden.

Span<int> GetSlice()
{
Span<int> temp = stackalloc int[4];
return temp;
}

This difference is not arbitrary. Arrays live on the heap and outlive the method call. Stack memory does not.

The compiler enforces lifetime correctness deterministically. There is no GC magic involved here.

Span<T> vs ReadOnlySpan<T>

The difference between these two types is semantic and practical.

Span<T> allows mutation of the underlying memory. ReadOnlySpan<T> does not.

This affects which sources they can wrap and how they should be exposed in APIs.

ReadOnlySpan<T> can safely wrap strings. Span<T> cannot.

In API design, preferring ReadOnlySpan<T> communicates intent clearly and avoids accidental mutation of buffers you do not own.

void ParseHeader(ReadOnlySpan<byte> data)
{
if (data.Length < 8)
throw new ArgumentException("Invalid header");
int version = data[0];
int flags = data[1];
}

This method can accept arrays, stack buffers, pooled memory, or slices without allocating and without granting write access.

Performance Reality: Where the Wins Actually Come From

Spans do perform bounds checks. They are not magically unchecked.

The performance gains come from avoiding allocations and unnecessary copying, not from skipping safety.

Consider this common pattern.

string ExtractToken(string input)
{
return input.Substring(0, 5);
}

This allocates a new string every time.

Now compare it to this.

ReadOnlySpan<char> ExtractToken(ReadOnlySpan<char> input)
{
return input.Slice(0, 5);
}

No allocation occurs. No copying occurs. The span is simply a view.

In tight loops, parsing code, and IO heavy paths, removing these allocations changes both throughput and GC behavior dramatically.

The JIT can often eliminate bounds checks in span-based loops because slicing encodes bounds explicitly, but that is an optimization detail, not the primary goal.

Common Misconceptions That Lead to Bugs

One common misconception is that Span<T> is unsafe. In reality, it exists to make unsafe scenarios safe.

Another is that Span<T> replaces arrays. It does not. Arrays own memory. Spans borrow memory.

Some developers believe spans are only for performance tuning. In practice, they are just as valuable for correctness. They make illegal memory lifetimes impossible to express.

When Span<T> Is the Wrong Tool

If you need to store data, a span is wrong. If you need to cross async boundaries, a span is wrong. If you need long-lived state, a span is wrong.

In those cases, Memory<T> exists specifically to fill that gap.

async Task ProcessAsync(Memory<byte> buffer)
{
await Task.Delay(10);
Use(buffer.Span);
}

Memory<T> is heap-safe and async-safe. It trades some constraints for flexibility. The distinction between the two types is intentional and fundamental.

Conclusion:

Span<T> and ReadOnlySpan<T> are not collections, wrappers, or micro-optimizations. They are temporary, non-owning views over memory with strict, compiler enforced lifetime rules.

When you treat spans as short-lived windows into memory rather than things to store or pass around freely, the design makes sense.

Used correctly, spans reduce allocations, improve performance, and prevent entire classes of memory bugs. More importantly, they force explicit thinking about ownership and lifetime, which is exactly what most production .NET code has been missing for years.

Writer : Gulam Ali H.


— Bhuwan Chettri
Editor, CodeToDeploy

CodeToDeploy Is a Tech-Focused Publication Helping Students, Professionals, And Creators Stay Ahead with AI, Coding, Cloud, Digital Tools, And Career Growth Insights.

Post a Comment

Previous Post Next Post