AlgoMaster Logo

Span<T> & Arrays

Last Updated: May 17, 2026

11 min read

Span<T> is a window into a chunk of memory you already have. When you slice an array with .. you get a brand new array, which means a fresh allocation and a copy of every element. Span<T> lets you describe the same slice without copying anything, so parsing a price out of a CSV row, looking at one page of product IDs, or comparing two halves of a cart costs almost nothing. This chapter covers what Span<T> and ReadOnlySpan<T> actually are, how to create them from arrays, what changes when you write through them, why they're stack-only, and where stackalloc and Memory<T> fit in.

The Problem: Slicing an Array Allocates

Slicing an array with the range operator looks cheap, but it isn't. Every .. on an array calls RuntimeHelpers.GetSubArray, which allocates a new array and copies the elements over.

Two things to notice. page and productIds are separate arrays, so writing to page[0] doesn't touch productIds. And we just allocated a three-element int[] on the heap to hold a copy of data we already had.

For one slice in startup code, that's fine. For thousands of slices in a parser or a request handler, the garbage collector starts to feel it.

What we want is a way to say "look at indices 3 through 5 of this array without making a new one." That's Span<T>.

What Span<T> Is

Span<T> is a small value type that holds two things: a reference to the start of a contiguous block of memory and a length. It doesn't own the memory. It's a view. The memory it points at can live on the heap (an array, a string buffer) or on the stack (a stackalloc block).

The array on the heap holds the actual integers. The Span<int> is two fields: a pointer-like reference to index 3 and the number 3 for the length. Reading or writing through the span goes straight to the array's memory. No copy.

The type is declared as a ref struct, which is C#'s name for a value type that can only live on the stack. Reading and writing through a span are direct memory accesses, with bounds checks like an array but without the allocation.

ReadOnlySpan<T> is the same idea but you can't write through it. It's the right type for parameters that only need to read, and it's what string exposes when you call AsSpan() on it (strings are immutable, so the read-write Span<char> form would be wrong).

Why It's Stack-Only

The "stack-only" rule isn't arbitrary. Because a span holds a reference into the middle of something else, the runtime has to be sure the span never outlives the thing it points at. The stack-only restriction is what gives that guarantee:

  • A span can't be a field of a class. Classes live on the heap, and a heap object holding a reference into another object's middle would break GC's assumptions.
  • A span can't be boxed. Boxing puts a value on the heap, same problem.
  • A span can't be captured by a lambda that escapes the current method.
  • A span can't be used as a generic type argument (List<Span<int>> won't compile).
  • A span can't cross an await or a yield return boundary, because those store local state on the heap.

The upside: when you stay inside one method, the compiler proves the span is safe to use, and you get array-like access with no allocation.

Creating a Span from an Array

The most common starting point is an existing array. AsSpan() is an extension method on T[] that hands you a span over the whole array.

AsSpan() returns a Span<int> that covers the entire array. Indexing and Length work the same way they do on the array. The ^1 end-relative index works on spans too.

You can also pass a starting position and a length, which is the no-allocation way to take a slice:

The span exposes only three items, but the underlying array still has all eight. The Length of the span is 3, not 8. That's the whole point: the span describes a window.

A third option: the range indexer applied to a span. This is the read-without-allocating version of array[a..b].

productIds[3..6] allocates. productIds.AsSpan()[3..6] doesn't. The compiler turns the second form into a call to Slice(int, int) on the span, which just returns a new span with a different start and length. No new array.

For completeness, the new form works too: new Span<int>(productIds), new Span<int>(productIds, 3, 3). The AsSpan extension is more common in modern C# code.

Reading and Writing Through a Span

Reading through a span looks exactly like reading through an array:

view covers { 2, 3, 4 }, sum is 9.

Writing through a span writes into the underlying array. This is the part that catches people who think of a span as a "copy".

middle[0] and stock[1] are the same memory cell. Writing through one is visible through the other. This is identical to passing the array around by reference, just with a window over it instead of the whole thing.

If you want a read-only view (parameter, return value, or just to be explicit about intent), use ReadOnlySpan<T> instead. The compiler will reject any write through it:

A Span<T> converts implicitly to a ReadOnlySpan<T>, so a method that accepts the read-only form will happily take a writable span as well. The other direction isn't allowed.

A Worked Example: Comparing Two Halves of a Cart

A common pattern: take a single array and treat it as two contiguous regions. With a regular array, you'd either pass start/length pairs around or slice and copy. With spans, you split once and pass spans.

SequenceEqual on spans walks both regions element by element. No copies, no LINQ, no allocations. The two spans look at different windows of the same array.

The reason this matters in real code: imagine a cart that was duplicated by accident, or two halves of a request that should match. Doing the same check with cart[..3].SequenceEqual(cart[3..]) would allocate two new arrays first.

Passing Spans to Methods

A method that takes a span can be called with an array, a slice of an array, or a stack buffer. The caller picks. The method doesn't care where the memory came from, only that it's contiguous.

int[] converts implicitly to ReadOnlySpan<int>, so the first call doesn't need AsSpan(). The other two pass slices, and again, no allocations happen. One method, three reuses.

This is why a lot of BCL methods now have span overloads. They let callers pass either a full collection or a portion of one without paying for a copy.

Parsing Without Substring Allocations

Parsing is where ReadOnlySpan<char> earns its keep. int.TryParse has an overload that takes ReadOnlySpan<char>, and string exposes AsSpan() to get one cheaply.

The naive way:

That call to Split(',') allocates a string[] of length 4, and each entry is a freshly allocated substring. Four strings born, four strings ready for the GC. For one row, fine. For a CSV with a million rows, painful.

The span way, walking the row in place:

row.AsSpan() is free. rowSpan[start..i] is a slice of the span, also free. decimal.TryParse(ReadOnlySpan<char>, ...) reads characters directly from the original string's memory. The whole parse happens without allocating a single substring.

The code is longer than the Split version, and you'd reach for it only when allocations actually hurt: parsing log files, ingesting CSVs, reading protocol headers in a loop. For ordinary cart-display code, Split is fine.

stackalloc for Small Fixed-Size Buffers

stackalloc allocates a block of memory on the current method's stack frame. When you assign the result to a Span<T>, you get a span over that stack memory. No heap, no GC.

The buffer lives on the stack frame of the enclosing method. When the method returns, the stack frame is gone, and so is the buffer. That's why you can never return a Span<T> that points at stackalloc memory: by the time the caller would use it, the stack frame holding the data is already overwritten.

A small e-commerce use case: counting how many of the last few product ratings are above a threshold without allocating a temporary array.

stackalloc int[4] reserves 16 bytes on the stack. CopyTo writes into that buffer. The counting loop reads from it. The method returns, and the stack reclaims the space automatically.

Rules of stackalloc

Stack memory is bounded, so this isn't a tool for big buffers. A few rules to keep in mind:

  • The size has to be small. A few hundred bytes is comfortable. A few kilobytes is risky. A few megabytes will overflow the stack and crash the process.
  • The size should ideally be a compile-time constant or a small, validated runtime value. Allocating stackalloc int[userSuppliedNumber] without checking the number first is a stack overflow waiting to happen.
  • The memory is valid only until the enclosing method returns. You can pass the span down into other methods, but you can't return it up or store it anywhere outside the method's scope.
  • The buffer is zero-initialized by default in safe code. (You can use stackalloc in unsafe code to skip initialization, but that's a separate topic.)

A safer pattern when you're not sure of the size: a small stackalloc for the common case, a heap array for the rare big case.

This pattern shows up across the BCL. Reserve a small stack buffer for the common path, fall back to a heap allocation when the size grows.

Processing a Chunk of Order IDs

Putting reading, slicing, and writing together. We have a long list of order IDs and want to walk it one "chunk" at a time, doubling each chunk's values into a separate output array. A chunked pass is the kind of thing that benefits from spans because each chunk is a slice of the same underlying array.

Two spans on every chunk, one read-only over the input, one writable over the matching part of the output. No copying into temporary arrays. The last chunk is shorter than three because nine isn't a multiple of three; Math.Min keeps the span lengths valid.

This is one of those places where a span shines: the work is "scan and write in place across a region," and spans describe that region without owning it.

Memory<T> for the Heap-Friendly Case

Spans are powerful because they're stack-only, but stack-only is also their biggest restriction. The moment you need to:

  • Store a view in a field of a class
  • Pass a view across an await boundary in async code
  • Return a view from an async method

a Span<T> won't compile. Memory<T> is the BCL's answer. It represents the same idea (a view over contiguous memory with a length) but lives on the heap, so it can be stored, captured, and awaited.

The pattern is: store and pass Memory<T>, read and write through .Span. Inside one method, the .Span property gives you the fast spanwise access. Across methods or across awaits, you keep the Memory<T> form.

This is a short preview. The _Memory Management_ section covers Memory<T>, IMemoryOwner<T>, pinning, and pooling in depth. For now, the takeaway is: when you hit a "spans can't go here" wall, Memory<T> is the next tool to reach for.

When to Reach for Span (and When Not To)

Span<T> is a sharp tool. It's the right answer when allocations matter, but it's overkill for ordinary code.

SituationUse a span?
Parsing CSVs, log lines, or wire protocols in a hot loopYes
Slicing a large array many times in a methodYes
Stack buffers for short fixed-size workYes (stackalloc)
Calling a BCL method that has a span overload (TryParse, IndexOf, etc.)Yes
Cart logic running once per user requestNo, plain arrays are fine
Storing a slice in a class fieldNo, use Memory<T> or an array
Code that crosses await boundariesNo, use Memory<T>
Iterating once and reading valuesNo, foreach over the array is just as fast and simpler

The honest rule of thumb: in application code (cart math, controller actions, business logic) you almost never need Span<T>. In framework code, parsers, serializers, and anything that gets called millions of times per second, you'll see spans everywhere. Learning the shape now means you can read that code when you hit it.

A Larger Example: Splitting a CSV Row by Spans

Wrapping up with a slightly bigger example. A CSV-style row, three fields, parse the price and quantity without allocating any substrings.

A few things to call out. The method takes ReadOnlySpan<char>, so the caller can pass a full string, a slice of a string, or a stackalloc char buffer. All the slicing inside the method (row[..firstComma], row[(firstComma + 1)..secondComma]) is span slicing, not string slicing. IndexOf works directly on spans. decimal.TryParse and int.TryParse both have span overloads that read straight from the span without going through string.

We accept exactly one allocation: turning the product ID slice into a real string because the caller wants one back. If the caller could live with ReadOnlySpan<char> for the ID too, even that would go away.

Compare this with a version that does row.Split(','). The split version allocates one string[] and three substrings. The span version allocates one string. For a parser running on millions of rows, that's the difference between hitting the GC every few rows and barely touching it.

Summary

  • A regular array slice with array[a..b] allocates a new array and copies. Span<T> is a (reference, length) view that lets you describe the same slice without copying.
  • Span<T> is a ref struct, which means it can only live on the stack. It can't be a field, can't be boxed, can't cross await or yield, and can't be a generic type argument.
  • Create a span from an array with arr.AsSpan(), arr.AsSpan(start, length), or arr.AsSpan()[a..b]. The range form on the span doesn't allocate; the same form on the array does.
  • ReadOnlySpan<T> is the read-only flavor. Use it for parameters and any view that shouldn't write. Span<T> converts to ReadOnlySpan<T> implicitly.
  • Writes through a span go to the underlying array. The span is a view, not a copy, so mutations are visible everywhere that backing array is.
  • BCL parsing methods like int.TryParse(ReadOnlySpan<char>, out int) and decimal.TryParse(ReadOnlySpan<char>, out decimal) let you parse a section of a string without first allocating a substring. This is the main reason spans show up in CSV and log parsing.
  • stackalloc T[n] reserves a small block of memory on the current stack frame and is usually assigned to a Span<T>. The buffer disappears when the method returns, and the size has to stay small.
  • Memory<T> is the heap-friendly cousin of Span<T>. Use it when you need to store a view in a field or pass one across an await. The _Memory Management_ section goes deeper.