Zero-Allocation Programming in C#: Span, Pools, stackalloc
Every tool in this Part has been a piece of the same puzzle. This lesson is where the pieces click together.
Look back at what this Part has handed you so far: Span<T> and ReadOnlySpan<T> to slice data without copying it, Memory<T> to carry a view across async boundaries, ArrayPool<T> and MemoryPool<T> to reuse buffers instead of allocating fresh ones, ref/in parameters to avoid copying large structs, readonly struct from Advanced Part I to avoid defensive copies, ref struct to guarantee it's all memory-safe, and stackalloc for small bounded buffers that never touch the heap at all. Individually, each one solved a specific, narrow problem. Together, they form a coherent style of writing C# — and that style has a name.
In this lesson, you'll see what "zero-allocation" (or "low-allocation") programming actually means in practice, watch a realistic hot-path rewrite that uses this Part's toolkit end to end, and — just as importantly — learn exactly when this style is worth the cost it demands, and when it very much isn't.
What Is It?
The Simple Explanation
Zero-allocation programming means deliberately writing a specific piece of code — usually one method or one tight loop, never "the whole app" — so that it allocates little or nothing on the managed heap while it runs. Low-allocation is the more honest, more common cousin: not literally zero, but dramatically fewer allocations than the naive version, on a path where that difference actually matters.
The Technical Definition
Concretely, it means minimizing managed heap allocations — new on a reference type, boxing a value type, an intermediate string, a LINQ operator's iterator object, a closure — on a specific hot path, in order to reduce the amount of work the garbage collector has to do because of that path. Tying this directly back to Advanced Part I's GC lessons: fewer allocations means fewer objects entering Gen0, which means Gen0 fills up more slowly, which means fewer collections overall, and (for anything that happens to survive) less pressure pushing objects into the more expensive Gen1 and Gen2 collections. Zero-allocation programming isn't a separate technique from what you've already learned about the GC — it's the direct, practical application of that knowledge to writing code.
Why Does It Exist?
The Problem
Modern .NET's generational GC (Advanced Part I) is genuinely excellent at handling ordinary allocation rates — Gen0 collections are fast, and most applications never think about this at all, correctly. But on a path that runs extremely frequently — a per-request hot loop in a high-throughput API, a per-frame update in a game, a per-message parse in a trading system or log pipeline — even small allocations add up to a real, measurable cost: more frequent Gen0 collections, more objects surviving into more expensive generations, and — in latency-sensitive systems — GC pauses landing at genuinely inconvenient moments. Advanced Part IV's high-throughput async lesson already touched on exactly this concern for async code specifically; this lesson generalizes it.
The Solution
Every tool this Part has introduced exists to let you do real work — slicing strings, passing data around, building buffers — without the default "allocate a new heap object for this" behavior that ordinary, idiomatic C# reaches for by default. None of them are new capabilities in the sense of doing something previously impossible; they're all ways of doing the same work with a different, more deliberate memory strategy, reserved for the specific paths where the default strategy's cost is actually a proven problem.
Big Picture — the toolkit, assembled
- Replaces
string.Substring, array slicing, and LINQ-over-strings — a view over existing memory instead of a fresh copy
- Replaces holding onto data across an
awaitor in a field, whereSpan<T>'s ref struct restrictions don't allow it
- Replaces
new T[size]for temporary, larger-than-stackalloc-appropriate buffers — rent, use, return, reuse
- Replaces pass-by-value copying of large structs into and around methods
- Replaces the compiler's defensive copying of a struct passed as
inor accessed through areadonlyfield, by proving up front there's nothing to defend against
- Replaces small, short-lived heap buffers entirely — memory that never enters the GC's world at all
How It Works — before and after, a log-line parser
Here's a plausible, ordinary hot path: parsing a structured log line like 2026-08-31T10:15:00Z|WARN|OrderService|Order 4821 exceeded retry limit into its four fields, called on every single incoming log line in a high-throughput service.
Before — the natural, idiomatic, allocation-heavy version
public record LogEntry(string Timestamp, string Level, string Source, string Message);
public static LogEntry ParseLogLine(string line)
{
string[] parts = line.Split('|'); // allocates a string[] AND four new strings
return new LogEntry(
parts[0],
parts[1],
parts[2],
parts[3]); // 'Message' — one more implicit allocation if it needed trimming
}
Every single call allocates: the string[] array from Split, each of the four substrings Split creates internally, and the LogEntry record itself. For a service processing tens of thousands of log lines per second, this is a steady, significant stream of small, short-lived Gen0 garbage — exactly the pattern the Generational GC lesson described as cheap individually, but not free in aggregate.
After — rewritten with this Part's toolkit
public readonly ref struct LogEntryView
{
public ReadOnlySpan<char> Timestamp { get; }
public ReadOnlySpan<char> Level { get; }
public ReadOnlySpan<char> Source { get; }
public ReadOnlySpan<char> Message { get; }
public LogEntryView(ReadOnlySpan<char> timestamp, ReadOnlySpan<char> level,
ReadOnlySpan<char> source, ReadOnlySpan<char> message)
{
Timestamp = timestamp;
Level = level;
Source = source;
Message = message;
}
}
public static LogEntryView ParseLogLine(ReadOnlySpan<char> line)
{
Span<Range> fieldRanges = stackalloc Range[4]; // tiny, fixed, bounded — safe per lesson 230
int found = 0;
int start = 0;
for (int i = 0; i < line.Length && found < 4; i++)
{
if (line[i] == '|')
{
fieldRanges[found++] = start..i;
start = i + 1;
}
}
if (found == 3)
fieldRanges[found++] = start..line.Length;
return new LogEntryView(
line[fieldRanges[0]],
line[fieldRanges[1]],
line[fieldRanges[2]],
line[fieldRanges[3]]);
}
What changed, and why it matters:
ReadOnlySpan<char>in and out — slicinglineproduces views into the original memory, never new heap strings.- A small, fixed-size
Span<Range> fieldRanges = stackalloc Range[4]replaces the heap-allocatedstring[]fromSplit— four is a known, bounded count, so this is exactly the safe pattern from the previous lesson. LogEntryViewis areadonly ref struct—readonlybecause none of its span fields ever change after construction (avoiding defensive copies wherever it's passed byin), andref structbecause it holds span fields at all, which per lesson 229 is only legal inside a ref struct.- This version allocates nothing on the heap for the parse itself — not the array, not the four field strings, not even the returned "record," since
LogEntryViewis a stack-only value type. - The trade-off, visible immediately:
LogEntryViewinherits everyref structrestriction — it can't be stored in a field, put in aList<LogEntryView>, or held across anawait. A caller that genuinely needs to keep a parsed entry around calls.ToString()on the fields it actually needs, at the point it needs them — converting a few of the four spans to real strings, rather than all four by default.
Real-World Example — this exact pattern in the BCL and popular libraries
This isn't a contrived teaching example — it's the same design pattern behind real, widely-used .NET code. System.Text.Json's Utf8JsonReader is a ref struct that walks JSON text as spans, without allocating strings for tokens it doesn't need materialized. High-performance HTTP header parsers in ASP.NET Core's Kestrel server use ReadOnlySpan<byte> extensively for exactly this reason — a server handling tens of thousands of requests per second cannot afford a fresh string allocation for every header on every request. The log-parser example above is a smaller-scale version of precisely the same reasoning these production systems apply at massive scale.
Analogy
The naive parser is like photocopying every page of a book just to underline four sentences — you get your own copy to mark up freely, but you paid for the whole photocopy, every time, for every book. The zero-allocation version is like sticking small annotated tabs directly onto the original pages: you point at exactly the text you need, you never touched the printer, and when you're done reading you just remove the tabs — no paper wasted, nothing to recycle. The trade-off is real, though: those tabs only make sense while you still have the original book open in front of you. Close the book (leave the method), and the tabs stop meaning anything — which is exactly the ref struct restriction from two lessons ago, showing up again here.
Under the Hood — why this actually reduces GC pauses, not just allocation counts
- Every allocation avoided is one less object the bump-pointer allocator has to place, and one less object a future Gen0 collection has to trace.
- Since Gen0 collections trigger once its budget is exhausted, allocating less means triggering fewer of them across the same volume of work — not just making each one cheaper.
- An object that's never allocated can never survive a collection and get promoted — one less contributor to the more expensive higher-generation collections the Generational GC lesson described.
- For latency-sensitive services, this shows up not just as "less CPU spent in GC" but as fewer moments where a GC pause happens to land in the middle of handling a request — the whole point, for the systems where this style earns its keep.
Common Confusion
1. "Zero-allocation means literally zero allocations, everywhere in the app"
No — it means zero (or dramatically fewer) allocations on one specific, deliberately targeted hot path. The rest of the same application — startup code, configuration loading, admin endpoints hit once a minute — has no reason to adopt this style at all, and shouldn't.
2. "This is a totally new set of skills, unrelated to what I already know about the GC"
Not at all — this lesson is the direct application of the Generational GC lesson's mental model. If you understand why Gen0 pressure matters, you already understand why this style helps; this lesson mainly supplied the how.
3. "If it's available, I should just always write code this way"
This is the single most important misconception to avoid, and it gets its own full section next — see When Should I Use It? below.
Common Mistakes
Mistake 1 — Applying this style to code that isn't actually a hot path
Rewriting a configuration parser that runs once at startup into a ref struct-and-stackalloc version, because it "seemed like good practice."
Save this toolkit for paths that run often enough for allocation count to matter in aggregate — the next lesson gives you the tool to actually confirm that, rather than assuming it.
Mistake 2 — Rewriting for zero allocation without measuring the before-and-after
Assuming the rewrite helped because it "should" — the intermediate representation might still box somewhere, or a small allocation might have been genuinely negligible next to a much larger, unrelated cost elsewhere in the same request.
Verify allocation reduction directly, rather than trusting intuition — exactly the subject of the next lesson.
Mistake 3 — Letting a low-allocation type leak into the rest of the codebase's API surface
Making a public API return a ref struct view type like LogEntryView when most callers just want an ordinary, storable LogEntry they can put in a list or pass to another service — forcing every caller to deal with ref struct restrictions they don't need.
Keep the zero-allocation representation as an internal implementation detail of the hot path itself; convert to an ordinary, storable type at the boundary where the data needs to travel further or persist.
When Should I Use It?
Reach for zero-allocation style when
- The path is genuinely hot — called extremely frequently, or in a latency-sensitive context where GC pauses matter directly.
- You've measured (or will measure, per the next two lessons) that allocation is a real, contributing cost on this specific path.
- The complexity cost — ref struct restrictions, span juggling, manual buffer management — stays contained to a small, well-isolated piece of code.
Skip it — and just write ordinary, clear code — when
- The code runs rarely, or isn't on any latency- or throughput-sensitive path.
- You haven't measured, and are optimizing purely on instinct or aesthetic preference.
- The readability and maintainability cost would outweigh a performance gain the application doesn't actually need.
Mental Model
The toolkit is additive, not exotic: spans replace copies, pools replace fresh allocations,
ref/in/readonly struct avoid struct-copy overhead, stackalloc avoids the heap entirely for small bounded buffers.The trade you're making, every time: more complex, more restricted code, in exchange for less GC pressure — worth it exactly where measurement says it's worth it, and nowhere else by default.
Key Takeaway
- Zero/low-allocation programming means deliberately minimizing managed heap allocations on one targeted hot path, to reduce GC pressure — fewer, shorter, less disruptive collections.
- This Part's tools compose directly: spans for zero-copy slicing,
Memory<T>for async-friendly storage, pools for buffer reuse,ref/in/readonly structto avoid copy overhead,stackallocto skip the heap entirely. - The rewritten log-line parser demonstrated the pattern concretely:
ReadOnlySpan<char>slicing plus a smallstackallocscratch buffer eliminated every intermediate string and array allocation the naivestring.Splitversion produced. - This style is genuinely valuable — but only on paths that are actually hot and actually measured. Most application code should not default to it; the complexity trade-off usually isn't worth it for code that isn't a proven bottleneck.
- The next lesson gives you the tool to know whether a path is hot enough to deserve this treatment in the first place — measurement, not intuition.
Check Your Understanding
You've seen this Part's toolkit assembled into one coherent style. Let's confirm you understand both how it works and when to reach for it.
1. What does "zero-allocation programming" most accurately mean in practice?
Show answer
Correct: B
Why B is correct: This lesson defines the term precisely as a targeted, deliberate practice applied to specific hot paths — not a whole-application philosophy or an absolute, literal guarantee of zero allocation everywhere.
Why A is incorrect: No realistic .NET application avoids all allocation everywhere — startup, configuration, and most ordinary code paths allocate freely and correctly.
Why C is incorrect: The GC isn't disabled — this style simply reduces how much work it has to do on a specific path by allocating less there.
Why D is incorrect: Reference types remain essential and appropriate throughout most of any codebase — this style is about reducing allocation on specific hot paths, not eliminating reference types generally.
Reinforcement: "Targeted, not universal" is the key qualifier that distinguishes this from an unrealistic, all-or-nothing interpretation.
2. In the log-line parser rewrite, what specifically replaced the heap-allocated string[] that string.Split would have produced?
Show answer
Correct: B
Why B is correct: The rewrite used Span<Range> fieldRanges = stackalloc Range[4] — a small, fixed, bounded stack buffer holding the start/end boundaries of each field, entirely avoiding the heap-allocated array Split would have produced.
Why A is incorrect: A List<string> is still heap-allocated (and typically less efficient than an array for a fixed, known count) — it wouldn't reduce allocation at all.
Why C is incorrect: The example didn't declare four separate string variables — it worked with ReadOnlySpan<char> slices via the ranges, avoiding string allocation for fields that don't need to become full strings.
Why D is incorrect: ArrayPool<T> is a real tool from this Part, but wasn't the one used here — for a buffer this small and fixed (4 elements), stackalloc is the more appropriate, lighter-weight choice.
Reinforcement: A small, fixed-count stackalloc'd Span<Range> is a genuinely idiomatic way to avoid an array allocation for exactly this kind of bounded field-splitting work.
3. Why does reducing allocations on a hot path tend to reduce Gen1 and Gen2 collection pressure as well, not just Gen0?
Show answer
Correct: B
Why B is correct: Objects only reach Gen1/Gen2 by surviving earlier collections. An allocation avoided entirely can never be a candidate for survival or promotion, so eliminating it removes any possibility of it contributing to higher-generation pressure downstream.
Why A is incorrect: There's no such automatic deletion mechanism tied to Gen0 allocation rate — generations are collected independently based on their own triggers.
Why C is incorrect: The GC doesn't disable higher-generation collection based on Gen0 rate — it simply has less work to do there because fewer objects survived to need it.
Why D is incorrect: This directly contradicts the Under the Hood section — the effect does cascade through the generations, which is precisely why the effect on overall GC pressure is larger than "just fewer Gen0 collections" alone.
Reinforcement: The cascading effect through generations is exactly why this style matters more for sustained, high-frequency hot paths than an isolated allocation count might suggest.
4. A developer rewrites a startup configuration-loading method — called exactly once when the application starts — using spans, stackalloc, and ref structs to eliminate every possible allocation. According to this lesson, is this a good use of the technique?
Show answer
Correct: B
Why B is correct: This is exactly Mistake 1 from the lesson — applying zero-allocation techniques to code that isn't actually a hot path. A once-per-process startup method gains essentially nothing from this treatment while paying real complexity and restriction costs.
Why A is incorrect: This is precisely the "more is always better" mindset the lesson's honest caveat warns against — the cost (complexity, restrictions) has to be weighed against a benefit that, here, is negligible.
Why C is incorrect: Neither readonly struct nor ref struct is required for correctness anywhere in C# — they're optional, deliberate performance tools, not language mandates.
Why D is incorrect: The CLR version is irrelevant to this judgment — the deciding factor is whether the code is actually a measured, genuine hot path, which a once-at-startup method clearly is not.
Reinforcement: The question to ask is never "could I reduce allocation here?" — it's "does this path run often enough, under real conditions, for that reduction to actually matter?"
5. According to this lesson's honest caveat, what is the correct default posture for most application code with respect to this Part's zero-allocation toolkit?
Show answer
Correct: B
Why B is correct: The lesson is explicit: this level of optimization is for genuinely hot, measured paths, and most application code should not default to this style because it trades real readability for a performance gain most code doesn't need — directly echoing the broader "premature optimization" principle.
Why A is incorrect: This is the exact overapplication the lesson's caveat warns against — the toolkit has real costs (complexity, restrictions) that aren't justified without a proven need.
Why C is incorrect: The lesson ties the decision to whether a path is hot and measured, not to who wrote the code — there's no such author-based standard described anywhere in this lesson.
Why D is incorrect: This overcorrects in the other direction — the lesson clearly endorses using this toolkit, specifically and deliberately, on genuinely hot, measured paths in production code (as the Real-World Example's mention of Kestrel and System.Text.Json demonstrates).
Reinforcement: "Reserved for proven hot paths, not a default style" is the balanced, correct takeaway this whole lesson builds toward.
You've seen this Part's whole toolkit work together on a realistic example — and learned exactly when to reach for it. Next: how to actually prove a path is hot enough to deserve this treatment, instead of guessing.