AlgoMaster Logo

stackalloc

Low Priority21 min readUpdated June 6, 2026

The stackalloc expression carves out a chunk of the current method's stack frame and gives you a buffer of value-type elements that lives there, not on the heap. Since C# 7.2 you can assign the result to Span<T> or ReadOnlySpan<T> and use it from ordinary, safe code, with no unsafe block in sight. This chapter is about when that's a win, the rules the compiler enforces to keep it sound, and the very real ways it can blow up if you misuse it.

What stackalloc Actually Does

A normal allocation of an array sits on the managed heap, and the garbage collector tracks it until nothing references it any more. A stackalloc allocation is different: the buffer is part of the current method's stack frame, the same region of memory the CLR already uses for local variables, parameters, and return addresses. There's no heap allocation, no GC tracking, and no future collection pass to clean it up. When the method returns, the stack frame is popped, and the buffer disappears with it.

The syntax looks like this.

Three things to note. The element type is byte, a value type with no managed references in it. The length is a compile-time-ish integer; here it's a literal 16, but it can also be a variable computed at runtime. The result is stored in a Span<byte>, the safe wrapper, not a raw pointer.

A span is a ref struct: it can hold a reference into any contiguous region of memory (managed array, unmanaged buffer, or a stack frame), and the compiler enforces that it can't escape the method it was created in. That escape rule is the same machinery that makes safe stackalloc sound. Because the span can't outlive the current frame, and the buffer also can't outlive the current frame, the two lifetimes match exactly.

A stackalloc allocation is effectively free at runtime: no allocator call, no GC bookkeeping, no zeroing on most paths. The cost is paid by the stack itself: every byte allocated is a byte less of stack available for the rest of the method's locals, parameters, and any methods it calls. Going too big crashes the thread.

Why It Exists

Heap allocations are not free. Every new byte[256] walks through the allocator, takes a slot in the young GC generation, and adds work to a future collection pass. For one-off allocations in a request handler, that cost is invisible. For something called a million times a second, like parsing inputs, formatting outputs, or hashing short keys, the per-call cost adds up, and the GC pressure can stall the whole process under load.

stackalloc exists for that hot-path case. A short-lived buffer that you fill, read from, and discard within a single method doesn't need to live on the heap. The stack is already there, already paid for, and the buffer goes away on its own when the method returns. No allocator, no GC, no waste.

The catch is that this only works for buffers small enough to fit on the stack and short enough in lifetime to die with the frame. Most buffers in a real program are neither. The trick is recognizing the ones that are.

The C# 7.2 Change: stackalloc Without unsafe

Before C# 7.2, stackalloc was only legal inside an unsafe block, and the result was a raw pointer (int*, byte*) that you used through pointer arithmetic. That made it powerful but also dangerous: nothing stopped you from indexing past the end of the buffer, dereferencing after the method returned, or stashing the pointer somewhere it would outlive the frame.

C# 7.2 added a safer form. When the result of stackalloc is assigned to a Span<T> or ReadOnlySpan<T>, no unsafe block is needed. The compiler treats the operation as safe because the span carries length information (so indexing is bounds-checked) and the span is a ref struct (so it can't escape the method through fields, async state machines, or boxed objects).

Here's the contrast between the two forms.

And the old pointer form, shown only for contrast.

Both produce the same number. The safe version is the right default for new code. The pointer form is still legal for interop with native APIs and for the rare hot path that needs raw addresses, but it requires the project to enable <AllowUnsafeBlocks>true</AllowUnsafeBlocks> in the .csproj, and it loses every safety net the language provides.

The rest of this chapter sticks to the safe form.

The Unmanaged Constraint

You can't stackalloc arrays of arbitrary types. The element type has to be unmanaged, which is a precise term in C#: a type whose layout in memory does not contain any managed references. All the built-in numeric types fit (byte, int, long, float, double, decimal, bool, char), as do enums and any struct whose fields are themselves unmanaged.

The reason for the constraint is the way the GC works. The garbage collector scans the heap for live references and updates them when it moves objects around. The stack is also scanned, but stack scanning expects a known layout for each frame: which words are references and which are raw bytes. A stackalloc buffer is treated as raw bytes. If the elements contained managed references (say, string fields), those references would be invisible to the GC, and the strings they point to could be collected out from under you while the buffer still held the now-dangling pointers.

So the following won't compile.

What's wrong with this code?

CartLine has a string field, so it's a managed struct. stackalloc rejects it. The fix is to remove the managed field, store an index or hash instead, or just use a regular array.

Fix:

The constraint sounds restrictive, and it is, but the buffers stackalloc is good for, parsing, formatting, hashing, copying bytes, are exactly the cases where the elements are primitives or small unmanaged structs anyway.

Lifetime: The Buffer Dies With the Frame

The single rule that drives everything else about stackalloc is that the buffer lives in the current method's stack frame, and the frame is gone the instant the method returns. You can't return the buffer. You can't store it in a field. You can't capture it in a lambda that outlives the method. The compiler enforces this through the ref struct rules that Span<T> already follows.

The diagram shows a method's stack frame with three things inside it: ordinary locals, the stackalloc buffer, and the Span<byte> that points into the buffer. The span is a ref struct, so it can't be returned, stored in a heap object, or captured by a lambda that survives the method. The red arrow shows the case the compiler refuses to compile: returning the span to the caller would hand back a pointer to memory that's about to be reused by whatever the next call does.

Here's what the compile error looks like.

What's wrong with this code?

The method tries to return a span that points into its own stack frame. By the time the caller sees the return value, the frame is gone and the bytes belong to whatever the next stack-using operation grabs. The compiler stops this at compile time with a "cannot use local 'header' in this context because it may expose referenced variables outside of their declaration scope" error.

The fix is one of three options.

Fix 1: do the work inside the method and return a result that doesn't reference the buffer.

Fix 2: take the destination span as a parameter so the caller owns the storage.

Fix 3: allocate on the heap instead. If the buffer really has to outlive the call, use new byte[16] or rent from a pool.

The escape rule is a compile-time guarantee, not a runtime check, so there's zero overhead for the safety. The cost is design friction: API shapes that work for heap arrays (returning the array, stashing it in a field) don't work for stack buffers. The work has to happen where the buffer lives, or the buffer must be handed in from the caller.

Stack Size and StackOverflowException

The stack is a fixed-size region per thread. On Windows .NET, the default thread stack is around 1 MB. On Linux and macOS it's typically a bit larger (8 MB by default for the main thread), but library code that targets multiple platforms has to assume the smaller number. ASP.NET worker threads and thread-pool threads also use the smaller default. The same 1 MB stack holds every local variable, every parameter, and every nested call frame for the entire chain of method calls leading to the current point. stackalloc eats into the same pool.

The math gets unfriendly fast. A stackalloc byte[1024] is 1 KB, perfectly fine. A stackalloc byte[100_000] is 100 KB, large but probably OK if no caller is also using a lot of stack. A stackalloc byte[1_000_000] is 1 MB, which is the whole budget, and it will throw StackOverflowException on most threads.

Output (typical, on a default thread):

Two things make this category of bug nasty. First, StackOverflowException is not catchable in the normal sense. The CLR considers it unrecoverable and terminates the process. The try/catch above does not save the program. Second, the failure is timing-dependent: a buffer that's borderline might work in tests (called from a shallow stack) and crash in production (called from a deep call chain that has already used most of the available stack).

A StackOverflowException terminates the process. There's no graceful recovery, no log line in the handler, no second chance. Treat any stackalloc N where N is user-controlled or unbounded as a process-crash risk.

The rule that follows: never stackalloc a size that isn't bounded at compile time, and even bounded sizes need to be small.

The Hybrid Pattern: Stack for Small, Heap for Large

Real parsers and formatters take input of varying sizes. A receipt header might be 30 bytes one call and 30,000 the next. You can't stackalloc 30,000 bytes safely, and stackalloc 30 followed by "what do I do for the long one" isn't a strategy.

The standard pattern is the small-buffer optimization: pick a threshold, stackalloc for inputs below it, and fall back to a heap array (or a pooled buffer) for inputs at or above it. Both branches assign into a Span<T> variable, so the rest of the method doesn't need to care which path it took.

The threshold (MaxStack = 128) is a judgment call, not a fixed law. Common picks in the BCL are 128 to 256 elements for char or byte buffers, which works out to 256 to 512 bytes on the stack for chars (two bytes per char) and 128 to 256 bytes for bytes. Larger thresholds save more allocations but eat more stack; pick something small enough that even nested calls can each do it without trouble.

A couple of subtleties are worth flagging. The buffer variable always has length MaxStack when the stack path is taken, even if the input is shorter, so the code slices to buffer[..input.Length] to get a working span of exactly the right size. And the threshold is checked against input.Length, not against some arbitrary number; the buffer has to fit the actual input or the slicing logic falls apart.

When stackalloc Helps (and When It Doesn't)

stackalloc fits a narrow band of cases. Outside that band, it's a worse choice than a normal heap array or a pooled buffer.

SituationGood fit for stackalloc?Why
Short-lived parsing buffer, size known and smallYesBuffer dies with the method, no GC pressure
Formatting numbers or short strings to bytesYesOutput buffer is bounded by the format spec
Computing a hash of a small inputYesHash state is tiny, lives only during the call
Copying a small fixed-size payload between APIsYesAvoids a throwaway heap array per call
Buffer that needs to outlive the methodNoCan't be returned or stored in a field
Size depends on user input with no known capNoStackOverflowException risk
Large buffer (more than a few KB)NoEats too much stack, fragile under deep call chains
Buffer reused across many callsNoStack buffer dies each call; use ArrayPool<T> instead
Element type contains managed referencesNoWon't compile, the unmanaged constraint forbids it

The pattern that ties the "yes" cases together is small, short-lived, value-typed. Any one of those three failing pushes you toward a heap array or a pool.

A common mistake is reaching for stackalloc because heap allocations "feel slow." On a cold path, the cost of one new byte[256] is well under a microsecond and the GC will reclaim it without breaking a sweat. The win from stackalloc only shows up when the path is hot enough that the per-call allocation cost is measurable, and even then, a pooled buffer is often the better long-term answer because it handles arbitrary sizes without the stack-size risk.

E-Commerce Examples

Three short, realistic uses of stackalloc in the kind of code that ships in a real product.

Parsing a receipt header

A receipt arrives as a string with the order ID embedded in the first eight characters. The parser needs to copy those bytes into a working buffer to validate and normalize them.

The buffer is exactly 8 chars (16 bytes), and it lives only for the duration of ParseReceiptHeader. If this method is called once per request in an API that handles thousands of requests a second, the stackalloc saves thousands of tiny heap allocations every second.

Normalizing a coupon code

A coupon code arrives with mixed casing, possibly with embedded spaces or dashes. The normalizer strips the noise and uppercases the rest before comparing to the stored canonical form.

The stackalloc char[32] reserves 64 bytes of stack. The method does no heap allocation regardless of how many times it's called. The pre-check on input.Length > 32 guards the stack-size constraint by rejecting anything that wouldn't fit before the buffer is even touched.

Hashing a short customer email

A short hash of an email address can be useful for cache keys, bucket assignment, or fingerprinting. A small stackalloc'd byte buffer holds the input for a hash function that wants bytes.

Two things to note. The UTF-8 conversion writes directly into the stack buffer through Encoding.UTF8.GetBytes(email, bytes), no intermediate byte[] needed. The hash function only reads from the buffer, returning a plain int; nothing about the buffer escapes the method, so the compiler accepts it.

UTF-8 encoding of ASCII characters is one byte per char, but non-ASCII can be up to four bytes per char. A 64-char email could need up to 256 bytes of buffer in the worst case. For non-ASCII input, size the buffer for the worst case (email.Length * 4 for UTF-8) or use the hybrid pattern to fall back to a heap array.

Interaction With Loops and Recursion

One subtlety. stackalloc is an expression, not a statement, and the compiler accepts it anywhere an expression is legal, including inside a loop. That's almost always wrong.

The bug in this code:

The buffer is allocated on the stack on every iteration, and the stack frame keeps growing because the CLR is not required to reclaim a stackalloc allocation when its lexical scope ends, only when the method returns. After enough iterations, the stack overflows.

Fix:

Allocate the buffer once, outside the loop, and reuse it. The same buffer is fine to use repeatedly because the method's frame holds it for the duration of the method anyway.

Recursive methods have a similar trap: every recursive call adds another frame, and if each frame does its own stackalloc, the per-call cost adds up. A method that's known to recurse should either not use stackalloc at all or pass a buffer down from the top of the recursion through a parameter.

A Quick Recap of the Ref Struct Rules

The escape restrictions on a Span<T> returned from stackalloc are exactly the ref-struct rules, applied to the specific case of a span pointing into a stack frame. Three things to note:

  • A Span<T> (or ReadOnlySpan<T>) backed by stackalloc cannot be returned from the method that created it.
  • It cannot be stored in a field of a class or non-ref struct.
  • It cannot be captured by a lambda or local function that outlives the method, and it cannot cross an await or yield boundary.

All three rules exist because the buffer dies with the frame. The compiler checks each rule at compile time, and the error messages (CS8347, CS8350, CS8352) point at the specific escape path the code attempted. If the design requires a buffer that outlives the call, the right answer is a heap array or a pooled buffer, not a workaround for the escape rules.

Quiz

stackalloc Quiz

10 quizzes