Using the stackalloc Keyword for Stack Allocation in C#

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.

BasicStackAlloc.cs - Safe stack allocation
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.

SpanStackAlloc.cs - Span-based stack allocation
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.

SafeSizing.cs - Size-aware allocation
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.

RealWorld.cs - Production patterns
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

  1. Create project: dotnet new console -n SpanDemo
  2. Navigate: cd SpanDemo
  3. Edit Program.cs with the code below
  4. Confirm SpanDemo.csproj matches configuration
  5. Run: dotnet run
Main.cs
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}");
    }
}
SpanDemo.csproj
<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.

Performance Considerations and Benchmarks

Stack allocation provides measurable benefits when allocations occur frequently in hot paths. The performance gain comes from reduced GC pressure rather than faster allocation itself. In cold code or code that runs infrequently, the benefit is negligible.

When to measure versus assume: Always benchmark before optimizing. Stackalloc helps in tight loops processing millions of items, request handlers in high-throughput services, and parsers working through large files. For code that runs once during initialization or infrequently during normal operation, heap allocation is simpler and equally fast.

Hot path optimization: The biggest win comes from reducing Gen0 collections. When you allocate thousands of small arrays per second, the garbage collector runs frequently to clean them up. Stack allocation removes this pressure entirely. Monitor GC stats with dotnet-counters to see if allocation is actually a bottleneck before optimizing.

Caveat about stack overflow: Large allocations can cause crashes with no recovery. Always use size thresholds and fall back to ArrayPool or heap allocation for buffers over 1KB. In recursive code or deep call stacks, even small allocations accumulate. Profile with real workloads to ensure safety.

L1 cache benefits: Stack memory tends to stay in CPU cache because it's accessed sequentially and discarded quickly. Heap allocations scatter across memory and may cause cache misses. This secondary benefit matters less than GC reduction but can contribute to overall throughput improvements in tight loops.

Trade-offs with readability: Span-based code is more complex than array code. You lose the ability to use LINQ, return buffers from methods, or store them in fields. For most business logic, these limitations outweigh performance gains. Reserve stackalloc for proven bottlenecks where measurement shows allocation matters.

Benchmark example comparison: A typical benchmark might show string.Split allocating 1000 arrays per second creates noticeable Gen0 pressure. The Span-based stackalloc version measures 20-30% faster in microbenchmarks and shows zero Gen0 collections. However, if that parsing represents 1% of request time, the overall impact is minimal. Always measure end-to-end impact.

Troubleshooting

Is stackalloc safe in modern C#?

Yes, when used with Span<T>. The old unsafe pointer syntax required unsafe blocks, but Span-based stackalloc is fully safe. The compiler prevents escaping stack memory, so you can't accidentally return it or store it in fields. Use it freely in safe contexts.

How much can I allocate on the stack?

Limit stack allocations to a few KB. The stack size is typically 1MB per thread, and large allocations risk StackOverflowException. For buffers over 1KB, consider using ArrayPool<T> or heap allocation instead. Benchmark to verify performance gains justify the risk.

Can stackalloc work with reference types?

No. Stackalloc requires value types because stack memory has deterministic lifetime. Reference types need garbage collection tracking. You can stack-allocate value types that contain references, but the root allocation must be a struct or primitive.

Does stackalloc improve all performance scenarios?

No. It helps when allocation and GC pressure matter—hot loops, high-frequency methods, or latency-sensitive paths. For cold paths or small arrays used infrequently, heap allocation is fine. Always measure with BenchmarkDotNet before optimizing.

What happens if I return a stackalloc buffer?

The compiler prevents this. Span<T> is a ref struct, so it cannot escape the stack frame. Attempting to return it or store it in a field causes a compilation error. This safety mechanism prevents dangling memory references.

How does stackalloc compare to ArrayPool?

Stackalloc is faster for small buffers but limited to method scope. ArrayPool works for larger buffers and across method boundaries. For buffers under 512 bytes in tight loops, use stackalloc. For larger or reusable buffers, use ArrayPool. Combine both with conditional logic.

Back to Articles