The Unsafe Trap vs Safe Performance
It's tempting to reach for unsafe and raw pointers when you need temporary buffers in hot code paths. It works—until you accidentally return stack memory, create dangling pointers, or face a StackOverflowException because you allocated 100KB on the stack. Unsafe code trades compiler safety for manual control, and that trade rarely pays off in modern C#.
The stackalloc keyword combined with Span<T> gives you stack allocation without unsafe blocks. You get zero-allocation temporary buffers, compiler-enforced safety that prevents escaping stack references, and performance that matches unsafe code. The modern pattern emerged with C# 7.2 and became the standard approach for hot path optimization in .NET.
You'll learn how to use stackalloc safely with Span, when stack allocation beats heap allocation and ArrayPool, and how to write allocation-free parsing and formatting code. You'll see benchmarks comparing approaches and understand the performance characteristics that make stackalloc valuable in specific scenarios. By the end, you'll know exactly when to reach for stackalloc and when to stick with heap allocation.
Stack Allocation Fundamentals
The stack is a region of memory with automatic lifetime management. When you enter a method, the runtime allocates stack space for local variables. When the method returns, that space is automatically reclaimed. This is faster than heap allocation because there's no garbage collector involvement, no scanning, and no compaction.
The stackalloc keyword allocates memory on the stack instead of the heap. In modern C#, you assign the result to a Span<T>, which provides safe, bounds-checked access to that memory. The compiler ensures the Span doesn't escape the method, preventing dangling references.
using System;
namespace StackAllocDemo;
public class StackAllocBasics
{
public static void ProcessData()
{
// Allocate 256 bytes on the stack (64 ints × 4 bytes)
Span<int> buffer = stackalloc int[64];
// Use like a regular array
for (int i = 0; i < buffer.Length; i++)
{
buffer[i] = i * 2;
}
// Process the data
int sum = 0;
foreach (int value in buffer)
{
sum += value;
}
Console.WriteLine($"Sum: {sum}");
// Stack memory is automatically freed when method returns
}
// Conditional allocation: stack for small, heap for large
public static double CalculateAverage(int count)
{
Span<double> values = count <= 128
? stackalloc double[count]
: new double[count];
// Initialize with some values
for (int i = 0; i < values.Length; i++)
{
values[i] = Random.Shared.NextDouble() * 100;
}
double sum = 0;
foreach (double v in values)
{
sum += v;
}
return sum / values.Length;
}
}
The buffer variable is a Span pointing to 256 bytes on the stack. You access it just like an array with indexing and foreach loops. When the method returns, the stack unwinds and the memory is immediately available for reuse—no GC pause, no allocation tracking.
The conditional allocation pattern in CalculateAverage shows a common idiom: use stackalloc for small buffers where stack overflow isn't a concern, fall back to heap allocation for larger ones. Both paths assign to the same Span<T> type, so the rest of your code doesn't change.
Working with Span and stackalloc
Span<T> is a ref struct that can point to stack memory, heap memory, or native memory. This makes it the perfect abstraction for stackalloc. The compiler tracks Span lifetimes at compile time, preventing you from storing them in fields, returning them from methods (unless properly scoped), or boxing them.
These restrictions ensure stack-allocated memory never outlives its stack frame. You get safety without runtime overhead—all checks happen at compile time. This is why modern stackalloc always targets Span rather than unsafe pointers.
using System;
using System.Text;
namespace StackAllocDemo;
public class SpanStackAlloc
{
// Parse CSV without heap allocations
public static void ParseCsvLine(ReadOnlySpan<char> line)
{
Span<Range> ranges = stackalloc Range[16]; // Max 16 columns
int count = line.Split(ranges, ',');
for (int i = 0; i < count; i++)
{
ReadOnlySpan<char> field = line[ranges[i]];
Console.WriteLine($"Field {i}: {field}");
}
}
// Format number without StringBuilder
public static string FormatCurrency(decimal amount)
{
Span<char> buffer = stackalloc char[32];
if (amount.TryFormat(buffer, out int written, "C2"))
{
return new string(buffer[..written]);
}
return amount.ToString("C2"); // Fallback
}
// Temp buffer for string manipulation
public static string ReverseWords(string input)
{
Span<char> buffer = stackalloc char[input.Length];
input.AsSpan().CopyTo(buffer);
int start = 0;
for (int i = 0; i <= buffer.Length; i++)
{
if (i == buffer.Length || buffer[i] == ' ')
{
// Reverse the word in place
buffer[start..i].Reverse();
start = i + 1;
}
}
return new string(buffer);
}
// Combine stackalloc with slicing
public static void ProcessBatch(ReadOnlySpan<int> data)
{
const int BatchSize = 64;
Span<int> batchBuffer = stackalloc int[BatchSize];
for (int offset = 0; offset < data.Length; offset += BatchSize)
{
int size = Math.Min(BatchSize, data.Length - offset);
ReadOnlySpan<int> batch = data.Slice(offset, size);
// Copy to stack buffer for processing
batch.CopyTo(batchBuffer[..size]);
// Process batch
for (int i = 0; i < size; i++)
{
batchBuffer[i] *= 2;
}
Console.WriteLine($"Processed batch at offset {offset}");
}
}
}
The ParseCsvLine method shows how to avoid allocating arrays for temporary data. The Range array lives on the stack, and Split works directly with spans. For parsing millions of lines, this eliminates GC pressure entirely.
The FormatCurrency method uses a stack-allocated char buffer with TryFormat. This pattern appears in high-performance formatting code where heap allocations would create GC overhead. The ReverseWords example demonstrates in-place manipulation using stack memory as a working buffer.
These patterns all rely on Span's ability to point to stack memory while providing safe, bounds-checked access. You write code that looks like normal array operations, but it runs without allocation or GC involvement.
Understanding Stack Size Constraints
The stack has limited size—typically 1MB for 64-bit processes. Large allocations can cause StackOverflowException, which terminates your process. There's no recovery from stack overflow, so you must design defensively.
A good rule of thumb is to limit stackalloc to a few KB at most. For larger buffers, use ArrayPool<T> which provides reusable heap-allocated arrays with minimal GC overhead. Many codebases use a threshold like 512 bytes or 1KB—small enough to be safe even with deep call stacks.
using System;
using System.Buffers;
namespace StackAllocDemo;
public class SafeSizing
{
private const int StackAllocThreshold = 512;
// Hybrid approach: stack for small, pool for large
public static void ProcessBuffer(int size)
{
if (size <= StackAllocThreshold)
{
// Safe to use stack
Span<byte> buffer = stackalloc byte[size];
ProcessData(buffer);
}
else
{
// Rent from pool for large buffers
byte[] rented = ArrayPool<byte>.Shared.Rent(size);
try
{
Span<byte> buffer = rented.AsSpan(0, size);
ProcessData(buffer);
}
finally
{
ArrayPool<byte>.Shared.Return(rented);
}
}
}
private static void ProcessData(Span<byte> buffer)
{
// Fill with pattern
for (int i = 0; i < buffer.Length; i++)
{
buffer[i] = (byte)(i % 256);
}
}
// Generic helper for size-aware allocation
public static void WithBuffer<T>(int size, Action<Span<T>> action)
where T : struct
{
int byteSize = size * Unsafe.SizeOf<T>();
if (byteSize <= StackAllocThreshold)
{
Span<T> buffer = stackalloc T[size];
action(buffer);
}
else
{
T[] rented = ArrayPool<T>.Shared.Rent(size);
try
{
action(rented.AsSpan(0, size));
}
finally
{
ArrayPool<T>.Shared.Return(rented);
}
}
}
}
// Usage
SafeSizing.WithBuffer<int>(100, buffer =>
{
for (int i = 0; i < buffer.Length; i++)
buffer[i] = i * i;
});
The threshold pattern protects against stack overflow while giving you performance benefits when safe. The WithBuffer helper abstracts the decision, letting callers focus on logic rather than allocation strategy. This is a common pattern in high-performance .NET libraries.
Notice how both paths produce a Span<T>, so the processing logic is identical. This abstraction lets you change allocation strategy without touching business logic. When you benchmark and find stack allocation doesn't help, changing the threshold is trivial.
Practical Applications in Real Code
Stack allocation shines in specific scenarios: parsing text without temporary strings, formatting output without StringBuilder, buffering data in tight loops, and any situation where you need temporary storage for a single method call. Libraries like ASP.NET Core use stackalloc extensively in hot paths.
The key insight is locality: stackalloc works when data doesn't need to escape the method. If you need to return the buffer or store it, you must use heap allocation or ArrayPool instead.
using System;
using System.Globalization;
namespace StackAllocDemo;
public class RealWorldExamples
{
// Parse query string parameters without allocating
public static bool TryGetQueryParam(ReadOnlySpan<char> query,
ReadOnlySpan<char> key, out ReadOnlySpan<char> value)
{
value = default;
Span<Range> pairs = stackalloc Range[32];
int pairCount = query.Split(pairs, '&');
for (int i = 0; i < pairCount; i++)
{
ReadOnlySpan<char> pair = query[pairs[i]];
int equalsIndex = pair.IndexOf('=');
if (equalsIndex > 0)
{
ReadOnlySpan<char> pairKey = pair[..equalsIndex];
if (pairKey.SequenceEqual(key))
{
value = pair[(equalsIndex + 1)..];
return true;
}
}
}
return false;
}
// Convert hex string to bytes without intermediate strings
public static bool TryParseHex(ReadOnlySpan<char> hex,
Span<byte> destination)
{
if (hex.Length % 2 != 0 || hex.Length / 2 > destination.Length)
return false;
for (int i = 0; i < hex.Length; i += 2)
{
if (!byte.TryParse(hex.Slice(i, 2),
NumberStyles.HexNumber, null, out destination[i / 2]))
{
return false;
}
}
return true;
}
// Build path without string concatenation
public static string CombinePath(ReadOnlySpan<char> part1,
ReadOnlySpan<char> part2)
{
int totalLength = part1.Length + 1 + part2.Length;
Span<char> buffer = totalLength <= 256
? stackalloc char[totalLength]
: new char[totalLength];
int pos = 0;
part1.CopyTo(buffer[pos..]);
pos += part1.Length;
buffer[pos++] = '/';
part2.CopyTo(buffer[pos..]);
return new string(buffer);
}
// Custom number formatting
public static bool TryFormatWithSuffix(int value, Span<char> destination,
out int charsWritten)
{
Span<char> temp = stackalloc char[16];
if (!value.TryFormat(temp, out int written))
{
charsWritten = 0;
return false;
}
ReadOnlySpan<char> suffix = value == 1 ? " item" : " items";
int totalLength = written + suffix.Length;
if (totalLength > destination.Length)
{
charsWritten = 0;
return false;
}
temp[..written].CopyTo(destination);
suffix.CopyTo(destination[written..]);
charsWritten = totalLength;
return true;
}
}
These examples show patterns from real codebases. The query string parser avoids substring allocations by working with spans throughout. The hex parser writes directly to the output buffer without intermediate arrays. The path combiner uses stack memory for small paths, avoiding StringBuilder overhead.
Each method processes data in place using temporary stack buffers. This approach eliminates allocation churn in hot paths—critical for high-throughput services processing thousands of requests per second. The performance gain comes from avoiding GC pressure, not from raw speed differences.
Try It Yourself — Span Playground
Experiment with stackalloc and Span by building a simple string tokenizer that processes text without heap allocations. This demonstrates the performance characteristics and safety guarantees in a compact example.
Steps
- Create project:
dotnet new console -n SpanDemo
- Navigate:
cd SpanDemo
- Edit Program.cs with the code below
- Confirm SpanDemo.csproj matches configuration
- Run:
dotnet run
using System;
var text = "apple,banana,cherry,date,elderberry";
Console.WriteLine($"Input: {text}\n");
TokenizeAndPrint(text.AsSpan());
static void TokenizeAndPrint(ReadOnlySpan<char> input)
{
Span<Range> tokens = stackalloc Range[10];
int count = input.Split(tokens, ',');
Console.WriteLine($"Found {count} tokens:");
for (int i = 0; i < count; i++)
{
ReadOnlySpan<char> token = input[tokens[i]];
// Process without allocation
Span<char> upper = stackalloc char[token.Length];
for (int j = 0; j < token.Length; j++)
{
upper[j] = char.ToUpper(token[j]);
}
Console.WriteLine($" {i + 1}. {token} → {upper}");
}
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Output
Input: apple,banana,cherry,date,elderberry
Found 5 tokens:
1. apple → APPLE
2. banana → BANANA
3. cherry → CHERRY
4. date → DATE
5. elderberry → ELDERBERRY
This tokenizer allocates zero heap memory during parsing. The Range array and uppercase buffer both live on the stack. For processing large files line by line, this pattern eliminates GC overhead entirely.