Zero-allocation serialization library with source generators for .NET 10+
High-performance, zero-allocation serialization library for binary data and strings. Uses Roslyn source generators to create optimized, type-specific formatting code at compile time - no reflection, no boxing, no runtime code generation. Perfect for network protocols, CAN/FlexRay communication, embedded systems, logging, and any scenario where performance and memory efficiency are critical.
💡 Main Idea: ZeroAlloc provides an interpolated-string-like API that feels natural and readable, while eliminating heap allocations in 99% of use cases. Focus on writing clean code - let the source generator handle the performance optimizations.
- Quick Start
- Core Concepts
- String Formatting
- UTF-8 Generation
- Localized Formatting
- Binary Serialization
- Binary Parsing
- Type Wrappers
- Custom Types
- Configuration
- Installation
using ZeroAlloc;
// Step 1: Create a class inheriting from ZeroAllocBase (must be internal partial!)
internal partial class ZA : ZeroAllocBase { }
// Step 2: Use with implicit conversion (convenient, allocates result)
string greeting = ZA.String("User ", userId, " logged in at ", timestamp);
byte[] utf8Data = ZA.Utf8("Count: ", count);
byte[] binary = ZA.Bytes(new U16BE(0x1234), new Utf8("Hello"));
// Step 3: Use with explicit span (zero-allocation, must dispose)
using TempString temp = ZA.String("User ", userId, " logged in");
ReadOnlySpan<char> span = temp.AsSpan();
// Step 4: Use culture-sensitive formatting
string localized = ZA.LocalizedString(CultureInfo.GetCultureInfo("de-DE"), "Price: ", amount, " €");
byte[] localizedUtf8 = ZA.LocalizedUtf8(culture, "Date: ", DateTime.Now);
// Step 5: Parse binary data
if (PacketHeader.TryParse(data, out var header, out int consumed))
{
Console.WriteLine($"Version: {header.Version.Value}");
}The zero-allocation promise is valid up to the configurable buffer size (default: 2 MiB per thread):
- Compile-time: Set
ZeroAlloc_DefaultBufferSizeMSBuild property - Runtime: Use
ZeroAllocHelper.ResizeCharBuffer()/ResizeByteBuffer()
All Temp structs must be properly disposed after use!*
ZeroAlloc provides two usage patterns, each with different allocation behavior:
When you assign directly to string or byte[], implicit conversion operators handle the conversion and dispose automatically:
// ✅ Implicit conversion - allocates result string, auto-disposes buffer
string greeting = ZA.String("Hello ", name);
// ✅ Implicit conversion - allocates result byte[], auto-disposes buffer
byte[] utf8 = ZA.Utf8("Count: ", count);
byte[] binary = ZA.Bytes(header, payload);
// ✅ Culture-sensitive versions work the same way
string localized = ZA.LocalizedString(culture, "Price: ", amount);
byte[] localizedUtf8 = ZA.LocalizedUtf8(culture, "Date: ", DateTime.Now);For true zero-allocation scenarios, use the using statement and work with spans:
// ✅ Zero-allocation: using statement + span access
using TempString temp = ZA.String("Hello ", name);
ReadOnlySpan<char> span = temp.AsSpan();
// Work with span here...
// ✅ Zero-allocation UTF-8
using TempBytes utf8 = ZA.Utf8("Count: ", count);
await stream.WriteAsync(utf8.AsSpan().ToArray()); // Or use span directly
// ✅ Zero-allocation with culture
using TempString localized = ZA.LocalizedString(culture, "Price: ", amount);
ProcessSpan(localized.AsSpan());
// ❌ Wrong: No dispose - buffer stays locked!
var temp = ZA.String("Hello ", name);
// Buffer is never released, next call may allocate on heapBe careful when using using var (without braces) with multiple ZeroAlloc calls!
The using var declaration keeps the buffer locked until the end of the enclosing scope. If you make multiple ZeroAlloc API calls in the same scope, only the first one uses the ThreadStatic buffer - subsequent calls trigger heap allocation fallback.
// ❌ PROBLEMATIC: All three calls share the same scope!
void ProcessData()
{
using var temp1 = ZA.String("First ", value1); // Uses ThreadStatic buffer ✅
using var temp2 = ZA.String("Second ", value2); // Buffer locked! Falls back to heap ⚠️
using var temp3 = ZA.String("Third ", value3); // Buffer locked! Falls back to heap ⚠️
// temp1 is still holding the ThreadStatic buffer until method ends!
DoSomething(temp1.AsSpan());
DoSomething(temp2.AsSpan()); // temp2 was heap-allocated
DoSomething(temp3.AsSpan()); // temp3 was heap-allocated
}
// ✅ CORRECT: Use explicit scopes to release buffers immediately
void ProcessDataCorrectly()
{
{
using var temp1 = ZA.String("First ", value1); // Uses ThreadStatic buffer ✅
DoSomething(temp1.AsSpan());
} // temp1 disposed, buffer released
{
using var temp2 = ZA.String("Second ", value2); // Uses ThreadStatic buffer ✅
DoSomething(temp2.AsSpan());
} // temp2 disposed, buffer released
{
using var temp3 = ZA.String("Third ", value3); // Uses ThreadStatic buffer ✅
DoSomething(temp3.AsSpan());
} // temp3 disposed, buffer released
}
// ✅ ALTERNATIVE: Use implicit conversion (allocates result, auto-disposes)
void ProcessDataSimple()
{
string result1 = ZA.String("First ", value1); // Allocates string, buffer released
string result2 = ZA.String("Second ", value2); // Allocates string, buffer released
string result3 = ZA.String("Third ", value3); // Allocates string, buffer released
}Key Insight: The ThreadStatic buffer is shared per-thread. When you hold a reference to it (via TempString, TempBytes, etc.), subsequent calls on the same thread must use heap allocation. Check IsHeapAllocated property to detect this situation:
using var temp = ZA.String("Hello ", name);
if (temp.IsHeapAllocated)
{
// This indicates nested usage or a very rare concurrent access issue
Console.WriteLine("Warning: Heap allocation was used!");
}ZeroAlloc provides four builder types for manual string and binary construction. Each has specific use cases and trade-offs:
ZeroAlloc uses disposable ref structs to provide zero-allocation access to formatted content:
| Return Type | From Methods | Implicit Conversion | Buffer Source | Dispose |
|---|---|---|---|---|
| TempString | ZA.String(), ZA.LocalizedString() |
string, ReadOnlySpan<char> |
ThreadStatic | ✅ Required (auto on implicit cast) |
| TempBytes | ZA.Utf8(), ZA.Bytes(), ZA.LocalizedUtf8() |
byte[], ReadOnlySpan<byte> |
ThreadStatic | ✅ Required (auto on implicit cast) |
| Builder | Buffer Source | Dispose | Auto-Grow | Best For |
|---|---|---|---|---|
| TempStringBuilder | ThreadStatic | ✅ Required | ✅ Yes | General string building with unknown sizes |
| TempBytesBuilder | ThreadStatic | ✅ Required | ✅ Yes | General binary building with unknown sizes |
| SpanStringBuilder | User-provided | ❌ Not needed | ❌ No | Hot paths with known maximum sizes |
| SpanBytesBuilder | User-provided | ❌ Not needed | ❌ No | Hot paths with known maximum sizes |
When to use:
- Building content with unknown or variable size
- Conditional logic that affects output length
- Processing collections where total size is unknown
- General-purpose building without size constraints
Characteristics:
- Uses ThreadStatic buffer (one per thread, no heap allocation in normal case)
- Must be disposed to release the buffer for reuse
- Auto-grows if content exceeds buffer (triggers one-time allocation)
Usage pattern:
using var builder = TempStringBuilder.Create();
builder.Append("User ");
builder.Append(userId);
if (isAdmin) builder.Append(" [ADMIN]");
ReadOnlySpan<char> result = builder.AsSpan();- Only one ThreadStatic buffer per thread - nested calls fall back to heap allocation
- Always use
usingstatement to ensure disposal - Not thread-safe (each thread has its own buffer)
When to use:
- Hot paths where every nanosecond counts
- Known maximum output size
- Tight loops processing many items
- When you want zero heap allocations guaranteed
- Inside
TempString/TempBytescallbacks (to avoid nested ThreadStatic usage)
Characteristics:
- Uses user-provided buffer (typically
stackallocor pre-allocated array) - No dispose needed - you manage the buffer lifetime
- Throws
InvalidOperationExceptionif buffer is too small - Slightly faster than TempBuilder due to no ThreadStatic lookup
Usage pattern:
Span<byte> buffer = stackalloc byte[256];
var builder = new SpanBytesBuilder(buffer);
builder.AppendUInt16BigEndian(0x1234);
builder.AppendUtf8("Hello");
ReadOnlySpan<byte> result = builder.AsSpan();
// No dispose needed!- You must know the maximum size upfront
- Throws if buffer overflows (no recovery)
- Stack size is limited (~1 MB on most platforms)
| Scenario | Recommended Builder | Reason |
|---|---|---|
| Unknown/variable size | TempStringBuilder / TempBytesBuilder |
Auto-grow handles any size |
| Known max size, hot path | SpanStringBuilder / SpanBytesBuilder |
Zero overhead, user-managed buffer |
Inside TempString callback |
SpanStringBuilder |
Avoids nested ThreadStatic usage |
| Large outputs (>1 KB) | TempStringBuilder / TempBytesBuilder |
Stack size limits stackalloc |
The source generator creates optimized methods for each call site. Returns a TempString that can be used in two ways:
// Implicit conversion to string (allocates string, auto-disposes)
string result = ZA.String("User ", userId, " logged in at ", timestamp);
// Zero-allocation with span (must dispose manually)
using TempString temp = ZA.String("User ", userId, " logged in at ", timestamp);
ReadOnlySpan<char> span = temp.AsSpan();| Category | Types |
|---|---|
| Text | string, char, ReadOnlySpan<char> |
| Signed Integers | sbyte, short, int, long, Int128, nint |
| Unsigned Integers | byte, ushort, uint, ulong, UInt128, nuint |
| Floating Point | Half, float, double, decimal |
| Date/Time | DateTime, DateTimeOffset, TimeSpan, DateOnly, TimeOnly |
| Other | Guid, bool |
| Custom | Any type implementing ISpanFormattable |
| With Size Hint | Any type implementing IStringSize (for optimal pre-allocation) |
Use when you need conditional logic or loops.
using var builder = TempStringBuilder.Create();
builder.Append("User ");
builder.Append(userId);
if (isAdmin) builder.Append(" [ADMIN]");
builder.AppendLine();
ReadOnlySpan<char> result = builder.AsSpan();| Category | Types |
|---|---|
| Text | string, char, ReadOnlySpan<char> |
| Signed Integers | sbyte, short, int, long, Int128, nint |
| Unsigned Integers | byte, ushort, uint, ulong, UInt128, nuint |
| Floating Point | Half, float, double, decimal |
| Date/Time | DateTime, DateTimeOffset, TimeSpan, DateOnly, TimeOnly |
| Other | Guid, bool |
| Formatted | Append<T>(T value, format, provider) where T : ISpanFormattable |
| Method | Description | Example |
|---|---|---|
AppendLine() |
Appends newline | |
AppendLine(string) |
Appends text + newline | |
AppendHex2(byte) |
2 hex chars | 0xAB → "AB" |
AppendHex4(ushort) |
4 hex chars | 0x1234 → "1234" |
AppendHex8(uint) |
8 hex chars | 0xDEADBEEF → "DEADBEEF" |
AppendHex16(ulong) |
16 hex chars | |
AppendBinary8(byte) |
8 binary chars | 0b10101010 → "10101010" |
AppendBinary16(ushort) |
16 binary chars | |
AppendBinary32(uint) |
32 binary chars | |
AppendBinary64(ulong) |
64 binary chars |
For maximum performance with known buffer sizes. No dispose needed.
Span<char> buffer = stackalloc char[128];
var builder = new SpanStringBuilder(buffer);
builder.Append("Value: ");
builder.Append(42);
ReadOnlySpan<char> result = builder.AsSpan();Note:
SpanStringBuilderthrows if the buffer is too small. UseTempStringBuilderfor auto-growing buffers.
Supported types: Same as TempStringBuilder
Generate UTF-8 encoded bytes without intermediate string allocation. Returns a TempBytes that can be used in two ways:
// Implicit conversion to byte[] (allocates array, auto-disposes)
byte[] result = ZA.Utf8("User ", userId, " logged in");
// Zero-allocation with span (must dispose manually)
using TempBytes temp = ZA.Utf8("User ", userId, " logged in");
await stream.WriteAsync(temp.AsSpan().ToArray());| Category | Types |
|---|---|
| Text | string, char, ReadOnlySpan<char> |
| Signed Integers | sbyte, short, int, long, Int128, nint |
| Unsigned Integers | byte, ushort, uint, ulong, UInt128, nuint |
| Floating Point | Half, float, double, decimal |
| Date/Time | DateTime, DateTimeOffset, TimeSpan, DateOnly, TimeOnly |
| Other | Guid, bool |
| Custom | Any type implementing IUtf8SpanFormattable |
| With Size Hint | Any type implementing IUtf8Size (for optimal pre-allocation) |
For culture-sensitive formatting of numbers, dates, and other values, use the LocalizedString and LocalizedUtf8 methods. These produce output identical to standard C# interpolated strings with the given culture.
Generate culture-formatted strings. Returns a TempString that can be implicitly converted to string or used as a span:
using System.Globalization;
// Implicit conversion to string (allocates string, auto-disposes)
CultureInfo german = CultureInfo.GetCultureInfo("de-DE");
string price = ZA.LocalizedString(german, "Price: ", 1234.56, " €");
// Result: "Price: 1234,56 €" (German decimal separator)
// Zero-allocation with span (must dispose manually)
using TempString temp = ZA.LocalizedString(german, "Date: ", DateTime.Now);
ReadOnlySpan<char> span = temp.AsSpan();| Category | Types |
|---|---|
| Text | string, char, ReadOnlySpan<char> |
| Signed Integers | sbyte, short, int, long, Int128, nint |
| Unsigned Integers | byte, ushort, uint, ulong, UInt128, nuint |
| Floating Point | Half, float, double, decimal |
| Date/Time | DateTime, DateTimeOffset, TimeSpan, DateOnly, TimeOnly |
| Other | Guid, bool |
| Custom | Any type implementing ISpanFormattable |
Generate culture-formatted UTF-8 bytes directly. Returns a TempBytes that can be implicitly converted to byte[] or used as a span:
using System.Globalization;
// Implicit conversion to byte[] (allocates array, auto-disposes)
CultureInfo french = CultureInfo.GetCultureInfo("fr-FR");
byte[] utf8 = ZA.LocalizedUtf8(french, "Total: ", 9876.54);
// Result: UTF-8 bytes for "Total: 9876,54" (French decimal separator)
// Zero-allocation with span (must dispose manually)
using TempBytes temp = ZA.LocalizedUtf8(french, "Date: ", DateTime.Now);
await stream.WriteAsync(temp.AsSpan().ToArray());| Category | Types |
|---|---|
| Text | string, char, ReadOnlySpan<char> |
| Signed Integers | sbyte, short, int, long, Int128, nint |
| Unsigned Integers | byte, ushort, uint, ulong, UInt128, nuint |
| Floating Point | Half, float, double, decimal |
| Date/Time | DateTime, DateTimeOffset, TimeSpan, DateOnly, TimeOnly |
| Other | Guid, bool |
| Custom | Any type implementing IUtf8SpanFormattable |
For buffer-based formatting with culture support:
// TryLocalizedString - write to char buffer
Span<char> charBuffer = stackalloc char[256];
if (ZA.TryLocalizedString(german, charBuffer, out int charsWritten, "Price: ", amount))
{
// Use charBuffer[..charsWritten]
}
// TryLocalizedUtf8 - write to byte buffer
Span<byte> byteBuffer = stackalloc byte[256];
if (ZA.TryLocalizedUtf8(german, byteBuffer, out int bytesWritten, "Price: ", amount))
{
// Use byteBuffer[..bytesWritten]
}Build binary data with precise control over byte order and encoding. Returns a TempBytes that can be used in two ways:
// Implicit conversion to byte[] (allocates array, auto-disposes)
byte[] binary = ZA.Bytes(
new U16BE(0x1234), // Big-endian ushort (short form)
new Utf8Var("Hello"), // VarInt-prefixed UTF-8 string
new Raw(payload) // Raw byte array
);
// Zero-allocation with span (must dispose manually)
using TempBytes temp = ZA.Bytes(header, payload);
await stream.WriteAsync(temp.AsSpan().ToArray());| Category | Types |
|---|---|
| Big-Endian Integers | U16BE, U32BE, U64BE, U128BE, I16BE, I32BE, I64BE, I128BE |
| Little-Endian Integers | U16LE, U32LE, U64LE, U128LE, I16LE, I32LE, I64LE, I128LE |
| Floating Point | F32BE, F32LE, F64BE, F64LE |
| Variable-Length | VarInt, VarIntZigZag |
| UTF-8 Strings | Utf8, Utf8Z, Utf8Var, Utf8FixBE, Utf8FixLE, Utf8Fix16LE |
| Other Encodings | Ascii, Latin1, Utf16BE, Utf16LE |
| Raw Bytes | Raw |
| Custom | Any type implementing IBinarySerializable or IUtf8SpanFormattable |
Use when you need conditional logic or loops.
using var builder = TempBytesBuilder.Create();
builder.AppendUInt16BigEndian(0x0100); // Version
builder.AppendUtf8WithVarIntPrefix(message); // VarInt-prefixed string
if (hasPayload) builder.Append(payload);
stream.Write(builder.AsSpan());| Category | Types |
|---|---|
| Raw Bytes | byte, byte[], ReadOnlySpan<byte> |
| Big-Endian Wrappers | U16BE, U32BE, U64BE, U128BE, I16BE, I32BE, I64BE, I128BE |
| Little-Endian Wrappers | U16LE, U32LE, U64LE, U128LE, I16LE, I32LE, I64LE, I128LE |
| String Wrappers | Utf8Raw, Utf8NullTerminated, Utf8VarInt, Utf8BEFixed, Utf8LEFixed |
| Variable-Length | VarInt, VarIntZigZag |
| Custom | IBinarySerializable |
| Big-Endian | Little-Endian | Size |
|---|---|---|
AppendInt16BigEndian(short) |
AppendInt16LittleEndian(short) |
2 bytes |
AppendInt32BigEndian(int) |
AppendInt32LittleEndian(int) |
4 bytes |
AppendInt64BigEndian(long) |
AppendInt64LittleEndian(long) |
8 bytes |
AppendInt128BigEndian(Int128) |
AppendInt128LittleEndian(Int128) |
16 bytes |
AppendUInt16BigEndian(ushort) |
AppendUInt16LittleEndian(ushort) |
2 bytes |
AppendUInt32BigEndian(uint) |
AppendUInt32LittleEndian(uint) |
4 bytes |
AppendUInt64BigEndian(ulong) |
AppendUInt64LittleEndian(ulong) |
8 bytes |
AppendUInt128BigEndian(UInt128) |
AppendUInt128LittleEndian(UInt128) |
16 bytes |
| Big-Endian | Little-Endian | Size |
|---|---|---|
AppendHalfBigEndian(Half) |
AppendHalfLittleEndian(Half) |
2 bytes |
AppendSingleBigEndian(float) |
AppendSingleLittleEndian(float) |
4 bytes |
AppendDoubleBigEndian(double) |
AppendDoubleLittleEndian(double) |
8 bytes |
| Method | Description |
|---|---|
AppendUtf8(string) |
Raw UTF-8 bytes |
AppendUtf8NullTerminated(string) |
UTF-8 + null byte |
AppendUtf8WithVarIntPrefix(string) |
VarInt length prefix + UTF-8 |
AppendUtf8WithLengthPrefixBE(string) |
4-byte BE length + UTF-8 |
AppendUtf8WithLengthPrefixLE(string) |
4-byte LE length + UTF-8 |
| Method | Description |
|---|---|
AppendVarInt(ulong) |
7-bit encoded unsigned integer |
AppendVarIntZigZag(long) |
ZigZag encoded signed integer |
AppendVarIntZigZag(int) |
ZigZag encoded signed 32-bit integer |
| Method | Description |
|---|---|
AppendHex2(byte) |
2 hex ASCII bytes |
AppendHex4(ushort) |
4 hex ASCII bytes |
AppendHex8(uint) |
8 hex ASCII bytes |
AppendHex16(ulong) |
16 hex ASCII bytes |
AppendBinary8(byte) |
8 binary ASCII bytes |
AppendBinary16(ushort) |
16 binary ASCII bytes |
AppendBinary32(uint) |
32 binary ASCII bytes |
AppendBinary64(ulong) |
64 binary ASCII bytes |
AppendUtf8Formattable<T>(T) |
Any IUtf8SpanFormattable |
For maximum performance with known buffer sizes. No dispose needed.
Span<byte> buffer = stackalloc byte[256];
var builder = new SpanBytesBuilder(buffer);
builder.AppendUInt16BigEndian(0x1234);
builder.AppendUtf8("Hello");
ReadOnlySpan<byte> result = builder.AsSpan();Note:
SpanBytesBuilderthrows if the buffer is too small. UseTempBytesBuilderfor auto-growing buffers.
Supported types: Same as TempBytesBuilder
Parse binary data into tuples using source-generated methods.
byte[] data = [0x12, 0x34, 0xDE, 0xAD, 0xBE, 0xEF];
ZA.ParseBytes(data, out (U16BE header, U32BE value) result);
Console.WriteLine($"Header: 0x{result.header.Value:X4}"); // 0x1234
Console.WriteLine($"Value: 0x{result.value.Value:X8}"); // 0xDEADBEEFYou can use any [BinaryParsable] struct as a tuple member:
[BinaryParsable]
public readonly partial struct PacketHeader
{
public U16BE Version { get; init; }
public U16BE Flags { get; init; }
}
// Use PacketHeader directly in ParseBytes tuple
ZA.ParseBytes(data, out (PacketHeader header, U32BE payload) result);
Console.WriteLine($"Version: {result.header.Version.Value}");| Category | Types |
|---|---|
| Big-Endian Integers | U16BE, U32BE, U64BE, U128BE, I16BE, I32BE, I64BE, I128BE |
| Little-Endian Integers | U16LE, U32LE, U64LE, U128LE, I16LE, I32LE, I64LE, I128LE |
| Floating Point | F32BE, F32LE, F64BE, F64LE |
| Variable-Length | VarInt, VarIntZigZag |
| Raw Bytes | Raw |
| Custom | Any type implementing IBinaryParsable<T> (including [BinaryParsable] structs) |
Use the [BinaryParsable] attribute to auto-generate TryParse methods for structs.
[BinaryParsable]
public readonly partial struct PacketHeader
{
public U16BE Version { get; init; }
public U32BE MessageType { get; init; }
public U32BE PayloadLength { get; init; }
}
// Generated methods:
// - static bool TryParse(ReadOnlySpan<byte> source, out PacketHeader value, out int bytesConsumed)
// - static PacketHeader Parse(ReadOnlySpan<byte> source)
// - static int FixedSize { get; } → Returns fixed byte count, or -1 for variable-length types
// Usage
if (PacketHeader.TryParse(data, out var header, out int consumed))
{
Console.WriteLine($"Version: {header.Version.Value}");
}The generated struct implements IBinaryParsable<T> which includes:
| Member | Description |
|---|---|
static bool TryGetSerializedSize(out int size) |
Returns true and the fixed byte count for fixed-size types. Returns false for variable-length types (containing strings, VarInt, or variable arrays). |
static bool TryParse(...) |
Parses an instance from binary data, returning bytes consumed. |
static T Parse(...) |
Parses or throws if insufficient data. |
| Category | Types |
|---|---|
| Big-Endian Integers | U16BE, U32BE, U64BE, U128BE, I16BE, I32BE, I64BE, I128BE |
| Little-Endian Integers | U16LE, U32LE, U64LE, U128LE, I16LE, I32LE, I64LE, I128LE |
| Floating Point | F32BE, F32LE, F64BE, F64LE |
| Primitives | byte, sbyte |
| Primitive Integers | short, ushort, int, uint, long, ulong, float, double, Half, Int128, UInt128 (requires DefaultEndianness) |
| Variable-Length | VarInt, VarIntZigZag |
| Bit Fields | Any integer with [BinaryField(BitCount = n)] |
| Byte Arrays | byte[] with [BinaryFixedLength(n)] or [BytesLengthVarInt], etc. |
| Memory Types | Memory<byte> / ReadOnlyMemory<byte> with bytes length attributes |
| Strings | string with string length attributes |
| String Wrappers | Utf8Var, Utf8FixBE, Utf8FixLE, Utf8Fix16LE, Utf8Z |
| Nested Types | Any type implementing IBinaryParsable<T> |
⚠️ Byte-Alignment Requirement: Variable-length types (VarInt,VarIntZigZag), strings, byte arrays, and nested parsable types can only be used when they are byte-aligned. If you use bit fields before these types, ensure the total bit count is a multiple of 8, or use[BinaryField(PaddingBits = n)]to add padding bits after the field.
| Attribute | Properties | Description |
|---|---|---|
[BinaryOrder(n)] |
Order |
Explicit member order (0-based). If used on ANY member, ALL non-ignored members must have it. |
[BinaryIgnore] |
- | Exclude member from parsing (computed properties, cached values). |
[BinaryFixedLength(n)] |
Length |
Fixed byte count for byte[] arrays. |
[BinaryField(...)] |
BitCount, Endianness, PaddingBits |
Bit-level field width, endianness override, and/or padding bits to skip. |
Strings must specify how their length is encoded using one of the dedicated string length attributes:
| Attribute | Description | Example |
|---|---|---|
[StringLengthVarInt] |
VarInt-prefixed length | Most compact |
[StringLengthBE(n)] |
Big-endian length prefix (1, 2, or 4 bytes) | Network protocols |
[StringLengthLE(n)] |
Little-endian length prefix (1, 2, or 4 bytes) | Windows formats |
[StringNullTerminated] |
Null-terminated (C-style) | File paths, BSTR |
[StringFixedLength(n)] |
Fixed byte count (null-padded) | Fixed record formats |
[StringLengthFromField(name)] |
Length from another field | Dynamic protocols |
[BinaryParsable]
public readonly partial struct Message
{
[StringLengthVarInt]
public string Name { get; init; } // VarInt-prefixed (most compact)
[StringLengthBE(2)]
public string ShortText { get; init; } // 2-byte big-endian length prefix
[StringLengthLE(4)]
public string LongText { get; init; } // 4-byte little-endian length prefix
[StringNullTerminated]
public string Path { get; init; } // Null-terminated (C-style)
[StringFixedLength(32)]
public string FixedName { get; init; } // Exactly 32 bytes (padded with nulls)
}For protocols where the length is stored in a separate field:
[BinaryParsable]
public readonly partial struct DynamicMessage
{
public U16BE NameLength { get; init; } // Length field FIRST
[StringLengthFromField(nameof(NameLength))]
public string Name { get; init; } // Uses NameLength bytes
}
⚠️ Field Order Requirement: The length field MUST be declared BEFORE the data field. The generator validates this at compile time.
Byte arrays and Memory<byte> types require length specification:
| Attribute | Description | Applies To |
|---|---|---|
[BinaryFixedLength(n)] |
Fixed byte count | byte[] only |
[BytesLengthVarInt] |
VarInt-prefixed length | byte[], Memory<byte>, ReadOnlyMemory<byte> |
[BytesLengthBE(n)] |
Big-endian length prefix | byte[], Memory<byte>, ReadOnlyMemory<byte> |
[BytesLengthLE(n)] |
Little-endian length prefix | byte[], Memory<byte>, ReadOnlyMemory<byte> |
[BytesLengthFromField(name)] |
Length from another field | byte[], Memory<byte>, ReadOnlyMemory<byte> |
[BinaryParsable]
public readonly partial struct DataPacket
{
// Fixed-length byte array
[BinaryFixedLength(6)]
public byte[] MacAddress { get; init; }
// Dynamic-length byte array with VarInt prefix
[BytesLengthVarInt]
public byte[] Payload { get; init; }
// Memory with length from field
public U32BE DataLength { get; init; }
[BytesLengthFromField(nameof(DataLength))]
public Memory<byte> Data { get; init; }
}| Encoding | Description | Example Bytes for [0xAA, 0xBB] |
|---|---|---|
VarInt |
VarInt length prefix + data | [0x02, 0xAA, 0xBB] |
FixedBE |
N-byte big-endian length + data | [0x00, 0x02, 0xAA, 0xBB] (2-byte) |
FixedLE |
N-byte little-endian length + data | [0x02, 0x00, 0xAA, 0xBB] (2-byte) |
NullTerminated |
N/A for byte arrays | - |
Fixed |
Fixed byte count | [0xAA, 0xBB, 0x00, ...] |
Use [BinaryField(PaddingBits = n)] to skip reserved bits after a field:
[BinaryParsable]
public readonly partial struct FlagsPacket
{
[BinaryField(BitCount = 4, PaddingBits = 4)] // 4 bits for Flags, skip 4 reserved bits after → byte-aligned!
public byte Flags { get; init; }
public U32BE Payload { get; init; } // 4 bytes (requires byte alignment)
}Note:
PaddingBitsspecifies bits to skip after reading the field, not before. This makes it intuitive: "read my field, then skip N reserved bits."
| Rule | Behavior |
|---|---|
[BinaryOrder] on one → all need it |
If any member uses [BinaryOrder], ALL non-ignored members must specify order. Compile error otherwise. |
[BinaryOrder] + [BinaryIgnore] conflict |
Using both on the same member is an error - ignored members cannot have an order. |
| Bit fields must end byte-aligned | The total bit count must be a multiple of 8 before byte-aligned types. Use PaddingBits to align. |
| Strings require length attribute | All string members must have a string length attribute. Compile error otherwise. |
| Bytes/Memory require length attribute | Dynamic byte[]/Memory<byte> members require a length attribute. |
FromField order requirement |
Length fields must be declared BEFORE the data field using [StringLengthFromField] or [BytesLengthFromField]. |
Legacy Support: The legacy
[BinaryStringLength(StringLengthEncoding.VarInt)]attribute is still supported for backwards compatibility, but the new dedicated attributes are recommended for clarity.
For protocols with non-byte-aligned fields (CAN, FlexRay, etc.):
[BinaryParsable]
public readonly partial struct CANHeader
{
[BinaryField(BitCount = 11)]
public ushort Identifier { get; init; } // 11 bits
[BinaryField(BitCount = 1)]
public byte RTR { get; init; } // 1 bit
[BinaryField(BitCount = 1)]
public byte IDE { get; init; } // 1 bit
[BinaryField(BitCount = 1)]
public byte Reserved { get; init; } // 1 bit (padding/reserved)
[BinaryField(BitCount = 4)]
public byte DLC { get; init; } // 4 bits
}
// Total: 18 bits → 3 bytes (rounded up)
// Note: Last 6 bits of byte 3 are unused (padding to byte boundary)Use PaddingBits on the last bit field to align before byte-aligned types:
[BinaryParsable]
public readonly partial struct MixedPacket
{
[BinaryField(BitCount = 4, PaddingBits = 4)] // 4 bits + 4 padding after → byte-aligned!
public byte Flags { get; init; }
public U32BE Payload { get; init; } // 4 bytes (requires byte alignment)
[BinaryStringLength(StringLengthEncoding.VarInt)]
public string Message { get; init; } // Variable-length string
}Use DefaultEndianness in [BinaryParsable] to parse primitive integers:
[BinaryParsable(DefaultEndianness = Endianness.BigEndian)]
public readonly partial struct NumericPacket
{
public short Int16Value { get; init; } // 2 bytes BE
public int Int32Value { get; init; } // 4 bytes BE
public float SingleValue { get; init; } // 4 bytes BE
public double DoubleValue { get; init; } // 8 bytes BE
}Supported primitive types: sbyte, short, ushort, int, uint, long, ulong, float, double, nint, nuint, Half, Int128, UInt128
For fine-grained control over parsing.
var parser = new BinaryParser(data);
ushort version = parser.ReadUInt16BE();
uint length = parser.ReadUInt32BE();
ReadOnlySpan<byte> payload = parser.ReadBytes((int)length);| Category | Methods |
|---|---|
| Big-Endian Integers | ReadInt16BE(), ReadInt32BE(), ReadInt64BE(), ReadUInt16BE(), ReadUInt32BE(), ReadUInt64BE() |
| Little-Endian Integers | ReadInt16LE(), ReadInt32LE(), ReadInt64LE(), ReadUInt16LE(), ReadUInt32LE(), ReadUInt64LE() |
| Floating Point | ReadF32BE(), ReadF32LE(), ReadF64BE(), ReadF64LE() |
| VarInt | ReadVarInt(), ReadVarIntZigZag() |
| Bytes | ReadByte(), ReadSByte(), ReadBytes(int), Skip(int) |
| UTF-8 Strings | ReadUtf8Bytes(int), ReadUtf8Var(), ReadUtf8FixedBE16(), ReadUtf8FixedBE32(), ReadUtf8Null(), ReadAsciiBytes(int) |
| Generic | Read<T>(), TryRead<T>(out T) where T : IBinaryParsable<T> |
| Arrays | ReadArray<T>(count, span), ReadArrayVarInt<T>(span), ReadArrayBE16<T>(span), ReadArrayBE32<T>(span) |
For protocols with non-byte-aligned fields.
var reader = new BitReader(data);
// Read individual bits
bool flag = reader.ReadBit1().Value != 0;
byte nibble = reader.ReadNibble().Value; // 4 bits
byte priority = reader.ReadBit3().Value; // 3 bits
// Variable-width integers
ulong value12 = reader.ReadBits(12); // 12 bits
ulong value29 = reader.ReadBits(29); // 29 bits
// Standard aligned types
ushort id = reader.ReadUInt16();
uint payload = reader.ReadUInt32();| Method | Description |
|---|---|
ReadBit1() |
Read 1 bit → Bit1 |
ReadBit2() |
Read 2 bits → Bit2 |
ReadBit3() |
Read 3 bits → Bit3 |
ReadNibble() |
Read 4 bits → Nibble |
ReadBit5() |
Read 5 bits → Bit5 |
ReadBit6() |
Read 6 bits → Bit6 |
ReadBit7() |
Read 7 bits → Bit7 |
ReadBits(int) |
Read N bits → ulong |
ReadUIntBits(byte) |
Read N bits → UIntBits |
ReadIntBits(byte) |
Read N bits (signed) → IntBits |
ReadByte() |
Read 8 bits → byte |
ReadUInt16() |
Read 16 bits → ushort |
ReadUInt32() |
Read 32 bits → uint |
ReadUInt64() |
Read 64 bits → ulong |
ReadInt16() |
Read 16 bits → short |
ReadInt32() |
Read 32 bits → int |
ReadInt64() |
Read 64 bits → long |
AlignToNextByte() |
Skip to next byte boundary |
SkipBits(int) |
Skip N bits |
ReadBytes(int) |
Read N bytes (must be byte-aligned) |
| Type | Size | Description |
|---|---|---|
U16BE / U16LE |
2 bytes | Unsigned 16-bit |
U32BE / U32LE |
4 bytes | Unsigned 32-bit |
U64BE / U64LE |
8 bytes | Unsigned 64-bit |
U128BE / U128LE |
16 bytes | Unsigned 128-bit |
I16BE / I16LE |
2 bytes | Signed 16-bit |
I32BE / I32LE |
4 bytes | Signed 32-bit |
I64BE / I64LE |
8 bytes | Signed 64-bit |
I128BE / I128LE |
16 bytes | Signed 128-bit |
| Big-Endian | Little-Endian | Size |
|---|---|---|
F32BE |
F32LE |
4 bytes (float) |
F64BE |
F64LE |
8 bytes (double) |
| Type | Description |
|---|---|
VarInt |
7-bit encoded unsigned integer (1-10 bytes) |
VarIntZigZag |
ZigZag encoded signed integer (efficient for small absolute values) |
These wrappers implement both IUtf8SpanFormattable (for serialization) and IBinaryParsable<T> (for parsing), making them usable in both directions.
| Wrapper | Description | Serialization | Parsing |
|---|---|---|---|
Utf8 |
Raw UTF-8 bytes | ✅ | ❌ |
Utf8Z |
Null-terminated UTF-8 | ✅ | ✅ |
Utf8Var |
VarInt length prefix + UTF-8 | ✅ | ✅ |
Utf8FixBE |
4-byte BE length prefix + UTF-8 | ✅ | ✅ |
Utf8FixLE |
4-byte LE length prefix + UTF-8 | ✅ | ✅ |
Utf8Fix16LE |
2-byte LE length prefix + UTF-8 | ✅ | ✅ |
Ascii |
ASCII encoding (1 byte/char) | ✅ | ❌ |
Latin1 |
ISO-8859-1 (1 byte/char) | ✅ | ❌ |
Usage in [BinaryParsable] structs:
[BinaryParsable]
public readonly partial struct MessagePacket
{
public U16BE Id { get; init; }
public Utf8Var Name { get; init; } // Wrapper type, no attribute needed!
public Utf8FixBE Payload { get; init; } // 4-byte BE length prefix
}| Type | Bits | Range |
|---|---|---|
Bit1 |
1 | 0-1 |
Bit2 |
2 | 0-3 |
Bit3 |
3 | 0-7 |
Nibble |
4 | 0-15 |
Bit5 |
5 | 0-31 |
Bit6 |
6 | 0-63 |
Bit7 |
7 | 0-127 |
UIntBits |
1-64 | variable |
IntBits |
1-64 | signed |
| Type | Description |
|---|---|
Raw |
Wraps byte[] or ReadOnlySpan<byte> for direct inclusion |
Implement for custom binary serialization:
public readonly struct NetworkPacket : IBinarySerializable
{
public ushort Version { get; init; }
public string Payload { get; init; }
public bool TryGetSerializedSize(out int size)
{
size = 2 + Encoding.UTF8.GetByteCount(Payload);
return true;
}
public bool TryWrite(Span<byte> destination, out int bytesWritten)
{
var builder = new SpanBytesBuilder(destination);
builder.Append(new U16BE(Version));
builder.AppendUtf8(Payload);
bytesWritten = builder.Length;
return true;
}
}Implement alongside ISpanFormattable to provide size hints:
public readonly struct IpAddress : IStringSize, ISpanFormattable
{
public uint Value { get; init; }
public bool TryGetStringSize(ReadOnlySpan<char> format, IFormatProvider? provider, out int size)
{ size = 15; return true; } // "255.255.255.255"
public bool TryFormat(Span<char> dest, out int written, ReadOnlySpan<char> format, IFormatProvider? provider)
{ /* ... */ }
}Implement for custom parsing:
public readonly struct CustomPacket : IBinaryParsable<CustomPacket>
{
public ushort Version { get; init; }
public static bool TryGetSerializedSize(out int size)
{
size = 2;
return true; // Fixed size of 2 bytes
}
public static bool TryParse(ReadOnlySpan<byte> source, out CustomPacket value, out int bytesConsumed)
{
if (source.Length < 2) { value = default; bytesConsumed = 0; return false; }
value = new CustomPacket { Version = BinaryPrimitives.ReadUInt16BigEndian(source) };
bytesConsumed = 2;
return true;
}
}Configure via MSBuild properties in your .csproj:
<PropertyGroup>
<!-- Initial buffer size (default: 2 MiB = 2097152) -->
<ZeroAlloc_DefaultBufferSize>4194304</ZeroAlloc_DefaultBufferSize>
<!-- Heap fallback on nested/recursive calls (default: true) -->
<ZeroAlloc_RecursiveHeapFallback>true</ZeroAlloc_RecursiveHeapFallback>
<!-- Buffer overflow behavior: Grow, HeapFallback, or Throw (default: Grow) -->
<ZeroAlloc_BufferOverflowBehavior>Grow</ZeroAlloc_BufferOverflowBehavior>
</PropertyGroup>| Property | Default | Description |
|---|---|---|
ZeroAlloc_DefaultBufferSize |
2097152 |
Initial ThreadStatic buffer size in bytes/chars |
ZeroAlloc_RecursiveHeapFallback |
true |
Heap fallback on nested calls; false throws exception |
ZeroAlloc_BufferOverflowBehavior |
Grow |
Grow=grow buffer, HeapFallback=heap alloc, Throw=exception |
// Get current buffer sizes
int charSize = ZeroAllocHelper.GetCharBufferSize();
int byteSize = ZeroAllocHelper.GetByteBufferSize();
// Resize buffers (must not be in use)
ZeroAllocHelper.ResizeCharBuffer(4_194_304); // 4 MiB
ZeroAllocHelper.ResizeByteBuffer(1_048_576); // 1 MiB
// Release buffers to free memory
ZeroAllocHelper.ReleaseBuffers();dotnet add package ZeroAlloc
dotnet add package ZeroAlloc.GeneratorOr in .csproj:
<ItemGroup>
<PackageReference Include="ZeroAlloc" Version="1.0.0" />
<PackageReference Include="ZeroAlloc.Generator" Version="1.0.0"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>Each assembly must define its own ZeroAllocBase subclass:
// In your project (must be internal partial!)
using ZeroAlloc;
internal partial class ZA : ZeroAllocBase { }Requirements:
- .NET 10.0 or later
- C# 13.0 or later
MIT License - See LICENSE file for details.