← Open in the full interactive course (progress tracking, search & more)

A window onto memory you already own — not a new box to put it in.

Suppose you have a 10,000-element byte[] holding a file you just read from disk, and you need to process bytes 200 through 400 — maybe to parse a header. The obvious move is data.Skip(200).Take(200).ToArray(), or a hand-rolled loop that copies those 200 bytes into a brand-new array. Either way, you've just allocated a second array, copied 200 bytes into it, and left the GC to eventually clean up that copy — just to look at data that was already sitting in memory, perfectly accessible, the whole time.

Multiply that by a hot loop processing thousands of chunks a second — a network protocol parser, a log ingestion pipeline, a high-throughput API — and "just make a copy" stops being a shrug and starts being the reason your GC is working overtime, exactly the kind of hot-path allocation pressure Advanced Part IV's closing lesson on high-throughput async code flagged as worth hunting down.

In this lesson, you'll meet Span<T> — a type that lets you work with a slice of existing memory directly, with zero copying, while keeping the type safety and bounds-checking C# always gives you.

What Is It?

The Simple Explanation

Span<T> is a view — a window — onto a contiguous stretch of memory that already exists somewhere. It doesn't own that memory and it doesn't copy it. It just gives you a convenient, safe, array-like way to read and write a specific range of it.

Think of it like a bookmark and a ruler laid across a shelf of books: it tells you "start here, this many books, in this order," without moving a single book off the shelf.

The Technical Definition

Span<T> (in System) is a type-safe, memory-safe representation of a contiguous region of arbitrary memory. That memory can come from several different places:

Structurally, a Span<T> is astonishingly small: internally, it's little more than a pointer to the start of the memory plus a length. Indexing into it (span[i]) computes an address from that pointer and performs a bounds check, the same discipline C# already applies to ordinary arrays — you get array-like safety without an array's ownership.

Span<T> is a ref struct — a special kind of struct with genuine, compiler-enforced restrictions on where it can live and how long it can be kept around. That's deliberate and important, but it's also a big enough topic that it gets its own dedicated lesson later in this Part. For now, the practical takeaway is simpler: a Span<T> is a short-lived, local tool you use and discard within the same method — not something you stash in a field or hand off across an await.

Why Does It Exist?

The Problem — "slicing" always meant copying

Before Span<T>, every mainstream way of working with "a piece of" a bigger collection involved an allocation:

The Solution

Span<T> solves both problems at once. It represents "a contiguous run of T, starting here, this long" as a single, tiny, uniform value — regardless of whether the underlying memory is an array, a slice of an array, stack memory, or unmanaged memory. Slicing a Span<T> doesn't copy anything; it just produces a new, smaller Span<T> pointing at a narrower window of the same underlying memory. And because it's one unified type, API authors can write a single method that accepts a Span<T> and have it work uniformly whether the caller passed an array, an array slice, or a stack buffer.

Big Picture

WITHOUT Span<T> vs. WITH Span<T>
WITHOUT — "give me bytes 200..400"
WITH Span<T> — "give me a window onto bytes 200..400"

An array is a good mental starting point, because a Span<T> behaves a lot like one — indexing, iteration, a .Length property. The difference is ownership: an array is the memory; a Span<T> only points at memory that something else owns. That's exactly why creating one never allocates a copy of the data it views.

How It Works

FROM ARRAY TO SPAN TO SLICE
1. START WITH AN ARRAY
int[] numbers = [10, 20, 30, 40, 50];
2. CREATE A Span<T> OVER IT — .AsSpan()
Span<int> span = numbers.AsSpan();
3. SLICE IT — .Slice(start, length) OR RANGE SYNTAX
Span<int> middle = span.Slice(1, 3);   // { 20, 30, 40 }
Span<int> sameThing = span[1..4];      // C# range syntax — identical result
4. READ AND WRITE THROUGH IT — IT'S A VIEW, NOT A COPY
middle[0] = 999;
Console.WriteLine(numbers[1]); // 999 — the original array changed too!

Simple Example

int[] scores = [72, 85, 91, 60, 78, 95, 88];

// A view over the whole array — no copy
Span<int> allScores = scores.AsSpan();

// A view over just the last three scores — still no copy
Span<int> recentScores = scores.AsSpan(4..7); // { 78, 95, 88 }

int sum = 0;
foreach (int score in recentScores)
    sum += score;

Console.WriteLine($"Average of last 3: {sum / recentScores.Length}"); // 87

// Mutate through the span — this changes 'scores' itself
recentScores[0] = 100;
Console.WriteLine(scores[4]); // 100, not 78 — recentScores is a view, not a copy

What's happening: scores.AsSpan() wraps the existing array with zero copying. scores.AsSpan(4..7) narrows that view to just three elements — again, no copying, just a different pointer-and-length pair aimed at the same underlying memory. Summing over recentScores reads directly from scores' own storage. And assigning into recentScores[0] writes directly back into scores[4], because they are, physically, the exact same memory.

Real-World Example

A very common real pattern: you've read a chunk of bytes from a network socket or a file into one big buffer, and you need to process it in fixed-size records without allocating a new array per record.

// Imagine this came from a socket read or a file read — one big buffer
byte[] buffer = ReadPacketBytes(); // e.g. 300 bytes: 10 records of 30 bytes each

const int RecordSize = 30;
int recordCount = buffer.Length / RecordSize;

for (int i = 0; i < recordCount; i++)
{
    // A zero-allocation view over just this record's bytes
    Span<byte> record = buffer.AsSpan(i * RecordSize, RecordSize);

    ProcessRecord(record); // no per-record array allocation, ever
}

void ProcessRecord(Span<byte> record)
{
    // Read header fields directly out of the shared buffer
    int id = BitConverter.ToInt32(record[0..4]);
    byte status = record[4];
    // ...
}

Without Span<T>, "give me record i" would mean allocating a fresh byte[30] and copying into it, ten times per buffer. At real network or file-processing throughput — thousands of buffers a second — that's thousands of short-lived array allocations doing nothing but standing in for a slice that Span<T> gives you for free.

Analogy

A window frame, not a photograph

Imagine a long mural painted on a wall. If you want to show someone "just this section," you have two choices: photograph that section (make a copy — now you own a separate picture, and if the mural changes, your photo doesn't) or hold up an empty window frame in front of that section (a view — you're still looking at the actual wall, and if someone repaints part of what's inside your frame, you see the change instantly, because it's the same wall).

Span<T> is the window frame. It never copies the mural — it just tells you exactly where to look and how wide to look. Move the frame (slice it further), and you're still looking at the same wall, just a narrower part of it. Paint through the frame (write to the span), and you've painted the actual wall, not some copy of it.

Under the Hood

WHAT A Span<T> ACTUALLY IS, PHYSICALLY
1. TWO FIELDS, ESSENTIALLY: A REFERENCE AND A LENGTH
2. WHY IT MUST BE A ref struct
3. THE READ-ONLY COUNTERPART

Common Confusion

1. "A Span<T> is basically an array" — close, but ownership is the whole point

They behave alike day-to-day — indexing, .Length, foreach — but an array is the storage, while a Span<T> only views storage that lives (and is owned) somewhere else. That distinction is exactly why slicing a span is free and slicing with Skip/Take/ToArray is not — and why a Span<T> can view things an array never could, like a slice of a stack buffer.

2. "Slicing a Span<T> copies the data, just like Substring does"

No — this is the exact mistake this lesson exists to prevent. .Slice() and range syntax on a Span<T> never copy anything; they compute a new starting reference and length within the same underlying memory. string.Substring copies precisely because string is immutable and has no span-like view over itself by default — which is exactly the problem the next lesson's ReadOnlySpan<char> solves.

3. "I can store a Span<T> in a field to reuse it later"

You can't — not in an ordinary class, anyway. Because Span<T> is a ref struct, the compiler refuses to let it become a field of a class, be captured by a lambda, or cross an await. This isn't an arbitrary restriction; it's what makes the safety guarantee in "Under the Hood" possible. When you genuinely need span-like data to survive past one synchronous stretch of code, reach for Memory<T> instead — the topic of a lesson two steps from here.

Common Mistakes

Mistake 1 — Forgetting a write through a span mutates the source

Slicing a span off an array you still need unchanged elsewhere, then writing into the span expecting it to behave like an independent copy.

int[] original = [1, 2, 3, 4, 5];
Span<int> window = original.AsSpan(1, 2);
window[0] = -1; // surprise: original[1] is now -1 too

If you need an independent copy, say so explicitly — call .ToArray() on the span (which does allocate and copy, deliberately) when isolation is actually what you want.

Mistake 2 — Reaching for Span<T> everywhere, including cold, ordinary code

Rewriting straightforward, infrequently-called application code to use Span<T> "for performance," adding real readability friction for a saving that never mattered.

Reach for Span<T> deliberately, in code that actually runs often enough for allocation avoidance to matter — parsers, buffer processing, hot loops — not as a reflexive habit everywhere arrays appear.

Mistake 3 — Trying to store a Span<T> as a class field

Attempting private Span<int> _cachedView; as a field, expecting it to work like any other struct field.

public class BufferHolder
{
    private Span<byte> _view; // compile error: ref struct cannot be a field of a class
}

This is the compiler catching a real safety problem, not a limitation to work around with a hack — reach for Memory<byte> if you need the equivalent capability stored in a field.

When Should I Use It?

Reach for Span<T> when

Skip it when

Rule of thumb: Reach for Span<T> when you catch yourself about to allocate a new array or string purely to look at part of one you already have. If the code is cold (rarely executed), the ordinary array-slicing approach is fine — clarity first, allocation-avoidance second.

Mental Model

Span<T> = a pointer and a length, aimed at memory someone else owns.
Slicing a span = moving the window, never copying the wall.
Writing through a span = writing to the original memory, always.

Remember: if you find yourself asking "did that just allocate a copy?" — with Span<T>, the answer to slicing and indexing is always no.

Key Takeaway


Check Your Understanding

You've seen what Span<T> is and why it exists. Let's confirm the view-vs-copy distinction actually stuck.

1. What does numbers.AsSpan(1, 3) actually do, in terms of memory?

Show answer

Correct: B

Why B is correct: Span<T> is fundamentally a reference plus a length. AsSpan(1, 3) computes where index 1 lives in numbers' existing memory and records a length of 3 — it never allocates new storage or copies any elements.

Why A is incorrect: This describes what numbers.Skip(1).Take(3).ToArray() would do — allocate and copy. The entire reason Span<T> exists is to avoid exactly this.

Why C is incorrect: Span<T> (the mutable version) doesn't mark anything read-only — it can be both read from and written to, and writes affect the original array.

Why D is incorrect: Nothing is relocated. The elements stay exactly where they are in numbers; the span merely records where to find them.

Reinforcement: Creating and slicing a Span<T> is pure pointer-and-length arithmetic — never allocation, never copying.

2. Given int[] arr = [1, 2, 3]; Span<int> s = arr.AsSpan(); s[0] = 99; — what is arr[0] afterward?

Show answer

Correct: B

Why B is correct: s points directly at arr's memory. Writing s[0] = 99 writes to that exact memory location, which is the same location arr[0] reads from — so arr[0] is 99 immediately, with no separate "sync" step.

Why A is incorrect: This is the core misconception the lesson warns against — a Span<T> is never an independent copy of the source data.

Why C is incorrect: Plain Span<T> supports both reads and writes; only its read-only counterpart, ReadOnlySpan<T>, prevents writes.

Why D is incorrect: .ToArray() creates a brand-new, independent array copy of the span's contents — it doesn't "sync" anything, and no such sync step is ever needed since the span already shares memory with arr.

Reinforcement: A span is a live view — reads and writes go straight through to the original memory, with no copying or syncing involved.

3. Why is Span<T> declared as a ref struct rather than an ordinary struct or class?

Show answer

Correct: B

Why B is correct: A span can point at stack memory or pinned memory that's only valid briefly. Making it a ref struct forces it to live only on the stack itself, with a lifetime tied to the method call that created it — the compiler can then guarantee it never dangles past the memory it views.

Why A is incorrect: A ref struct is specifically restricted from being heap-allocated at all — that's the opposite of what's happening here.

Why C is incorrect: Being generic and being a ref struct are unrelated — plenty of generic types are ordinary classes or structs. Span<T>'s ref struct status is a deliberate safety choice specific to what it represents.

Why D is incorrect: ref struct types cannot be boxed at all — boxing would place them on the heap, which is exactly the outcome the restriction prevents.

Reinforcement: The ref struct restriction isn't a limitation to work around — it's the mechanism that makes Span<T> safe to use without runtime checks for dangling memory.

4. You're writing a class with a field meant to hold onto a slice of a byte buffer for use later, after the constructor returns. Which type should that field be?

Show answer

Correct: B

Why B is correct: Span<byte> is a ref struct and the compiler refuses to let it be a field of an ordinary class. Memory<byte> — covered in an upcoming lesson — exists precisely for this situation: it's not a ref struct, so it can be stored in a field and converted to a Span<byte> later, at the point you're ready to do fast, direct work with it.

Why A is incorrect: This is exactly the restriction covered in Common Mistakes — attempting a Span<byte> field produces a compile error.

Why C is incorrect: Spans absolutely can be created over arrays via .AsSpan(); the issue here is specifically about storing the span itself as a field, not about creating it from an array.

Why D is incorrect: It's entirely possible — just not with Span<T> itself. Memory<T> is the tool designed for exactly this case.

Reinforcement: When span-like data needs to outlive one synchronous method call, that's the signal to reach for Memory<T> instead — a preview of the lesson coming up shortly.

You can now describe and use Span<T> precisely — as a zero-copy view, not a copy, and not an ordinary struct. Next: the read-only counterpart that makes this safe for string.


dotnetmadeeasy.com — Learn C# and .NET, the right way.