Table of Contents

Fixed-size string building

FixedSizeValueStringBuilder is a ref struct backed by a fixed, caller-supplied buffer. It never grows or rents a replacement buffer. Formatting arbitrary custom values and converting nonempty content to a string can still allocate on the heap.

var builder = new FixedSizeValueStringBuilder(stackalloc char[32]);
builder.Append("Hello World");
return builder.ToString();

When to use it

Scenario Type
Unknown or large output ValueStringBuilder
Small output, you expect it to fit but growing is acceptable ValueStringBuilder(stackalloc char[N])
Hard upper bound, the buffer must never be replaced FixedSizeValueStringBuilder(stackalloc char[N])

new ValueStringBuilder(stackalloc char[128]) already avoids allocation while the content fits. The moment it doesn't, it rents a larger buffer from ArrayPool<char>.Shared and copies into it - silently, and with nothing to tell you afterwards that it happened. FixedSizeValueStringBuilder removes that fallback: the buffer you hand it is the whole story.

Note the deliberate absence of Dispose. Nothing is ever rented, so there is nothing to return, and using var builder = new FixedSizeValueStringBuilder(...) will not compile. That is the intended signal.

The two rules

Everything about this type follows from two rules:

  1. Every append is atomic. It either fits entirely or writes nothing at all.
  2. Overflow latches. The first append that does not fit sets Overflowed, and every further append is a no-op.
var builder = new FixedSizeValueStringBuilder(stackalloc char[8]);

builder.Append("123456789");

builder.ToString();   // "" - empty, not "12345678"
builder.Overflowed;   // true

Rule 1 is why the result is empty rather than truncated. Truncation would be far more dangerous than it looks: the buffer above would have held "12345678", and had you appended the number 123456789 you would have ended up with a different number that looks completely valid. Formatted values, and surrogate pairs, must not be cut in half. The same reasoning is why ISpanFormattable.TryFormat, Span<T>.TryCopyTo and MemoryExtensions.TryWrite in .NET itself are all-or-nothing.

Why a later append can be dropped

This is the part that surprises people, so it is worth stating plainly:

var builder = new FixedSizeValueStringBuilder(stackalloc char[8]);

builder.Append("1234");     // fits
builder.Append("56789");    // does not fit -> dropped, and latches
builder.Append("!");        // WOULD fit, but is dropped as well

builder.ToString();   // "1234"
builder.Overflowed;   // true
builder.Remaining;    // 4 - there is room, but nothing more will be written

Remaining being greater than zero while Overflowed is true is expected, not a bug.

The reason is rule 2. Without it, the last line would produce "1234!" - a string that reads as though "56789" was never part of your code at all. With the latch, whatever you get back is always a prefix of what you intended to build. A prefix can be recognised as incomplete; a scrambled string cannot.

Carrying on anyway

The latch is a default, not a cage. Call ClearOverflow to keep going with whatever room is left:

var builder = new FixedSizeValueStringBuilder(stackalloc char[16]);
var truncated = false;

builder.Append("name=");
builder.Append(veryLongName);

if (builder.Overflowed)
{
    truncated = true;
    builder.ClearOverflow();   // deliberate: skip this field, keep building
}

builder.Append(" id=");
builder.Append(id);

return (builder.ToString(), truncated);

ClearOverflow resets the flag and keeps the content. Read Overflowed before calling it if you need to know whether anything was actually dropped. Clear resets the flag and the content, so the same buffer can be reused from the start:

Span<char> buffer = stackalloc char[32];
var builder = new FixedSizeValueStringBuilder(buffer);

builder.Append("First");
var first = builder.ToString();

builder.Clear();

builder.Append("Second");
var second = builder.ToString();

Checking the result

Always check Overflowed before trusting the output:

var builder = new FixedSizeValueStringBuilder(stackalloc char[64]);
builder.Append("id=");
builder.Append(userId);
builder.Append(" ts=");
builder.Append(timestamp, "O");

if (builder.Overflowed)
{
    return BuildWithoutLimit();   // fall back to ValueStringBuilder
}

return builder.ToString();

There is no exception anywhere on this path. A buffer that is too small is an ordinary, expected outcome which you handle with a branch, not a catch.

Interpolated strings

Interpolated strings work as you would expect:

var builder = new FixedSizeValueStringBuilder(stackalloc char[32]);
builder.Append($"user {userId} at {timestamp:O}");

Atomicity applies per literal and per hole, not to the interpolated string as a whole. The first part that does not fit latches and the remaining parts are skipped, so the content is still a valid prefix:

var builder = new FixedSizeValueStringBuilder(stackalloc char[5]);

builder.Append($"ab{42}cd");

builder.ToString();   // "ab42" - "cd" no longer fit
builder.Overflowed;   // true

If the literal parts alone already exceed the buffer, nothing is written at all and the whole interpolation is skipped.

Formatting values

Any ISpanFormattable can be appended, with an optional format string and format provider:

var builder = new FixedSizeValueStringBuilder(stackalloc char[32]);

builder.Append(3.14159f, "F2");
builder.Append(' ');
builder.Append(DateTime.UtcNow, "yyyy-MM-dd");

Unlike ValueStringBuilder.Append<T> there is no bufferSize parameter. The value is formatted straight into the remaining space; if it does not fit, the append is dropped. No intermediate buffer is needed, which is one of the places where the fixed-size builder is simply cheaper.

Growing out of the fixed buffer

Sometimes a hard limit is right for the common case but you still need a fallback for the rare oversized one. MoveToValueStringBuilder hands the buffer and its content over to a ValueStringBuilder, which can grow:

var builder = new FixedSizeValueStringBuilder(stackalloc char[64]);
builder.Append("id=");
builder.Append(userId);

if (builder.Remaining < worstCaseTail)
{
    using var grown = builder.MoveToValueStringBuilder();
    grown.Append(tail);
    return grown.ToString();
}

builder.Append(tail);
return builder.ToString();

Nothing is copied and nothing is rented: the ValueStringBuilder starts out pointing at the very same stack buffer with Length already set, so the move itself costs nothing. It only rents from the array pool once you exceed the buffer, exactly as it would have anyway - which is why the result must be disposed.

Both builders would otherwise write into the same memory, so the move consumes the source. What is left behind is an empty builder with Capacity of zero and Overflowed set to true. Reading it is safe and any further append is a no-op, so a stale use cannot corrupt the buffer its new owner is writing into:

var builder = new FixedSizeValueStringBuilder(stackalloc char[8]);
builder.Append("1234");

using var grown = builder.MoveToValueStringBuilder();

builder.Append("XYZ");   // dropped - the buffer is not his anymore
grown.ToString();        // "1234"

That reset can only neutralize the one variable it is called on. FixedSizeValueStringBuilder is a struct and the Span<char> it was handed is copyable, so the caller has to own the buffer uniquely at the point of the move:

Span<char> buffer = stackalloc char[16];
var builder = new FixedSizeValueStringBuilder(buffer);
builder.Append("hello");

using var grown = builder.MoveToValueStringBuilder();

buffer[0] = 'X';         // still aliases the same memory
grown.ToString();        // "Xello" - corrupted

The same goes for a struct copy taken before the move. Neither the original span nor such a copy may be written to afterwards; the compiler cannot detect it, so the discipline is yours. If you cannot guarantee that, copy the content out with ToString or TryCopyTo instead of moving it.

Moving an overflowed builder throws an InvalidOperationException. The content is an incomplete prefix and ValueStringBuilder has nowhere to carry that fact, so this is the last point at which the truncation can be caught. If it was deliberate, call ClearOverflow first - that is what it is for.

Available members

The type deliberately carries a smaller surface than ValueStringBuilder:

  • Append for char, string, ReadOnlySpan<char>, bool, Rune, any ISpanFormattable and interpolated strings (including alignment holes such as $"{value,10}", where value and padding are written together or not at all)
  • AppendLine, which writes the text and the newline together or not at all
  • Clear, ClearOverflow
  • MoveToValueStringBuilder to continue in a growable builder
  • Length, Capacity, Remaining, IsEmpty, Overflowed, and an indexer
  • AsSpan, TryCopyTo, ToString

Insert, Replace, Trim, Pad, AppendJoin and AppendFormat are not available - against a hard capacity limit each of them needs its own answer to "what happens when it does not fit". Use ValueStringBuilder when you need them.

Performance

See the comparison article for the numbers.