High-performance, zero-encoding, source-generated binary serializer for modern .NET.
SharpPack is an independently maintained fork of Cysharp/MemoryPack. It retains the original MemoryPack binary wire format while using a fully renamed API, context-owned formatter graphs, modern .NET 10 runtime targets, and aggressive performance optimizations. See NOTICE for attribution.
Its speed comes from a C#-specific binary format, modern .NET APIs, generic static formatter dispatch, and an incremental source generator. The format copies compatible C# memory directly where possible and does not require a special schema object.
Other than performance, SharpPack has these features.
- Support modern I/O APIs (
IBufferWriter<byte>,ReadOnlySpan<byte>,ReadOnlySequence<byte>) - Native AOT friendly Source Generator based code generation, no Dynamic CodeGen (IL.Emit)
- Deserialize into existing instance
- Polymorphism (Union) serialization
- Limited version-tolerant (fast/default) and full version-tolerant support
- Circular reference serialization
- PipeWriter/Reader based streaming serialization
- TypeScript code generation
- Explicit formatter contexts for collectible
AssemblyLoadContextand RPC graphs
SharpPack 1.x targets .NET 10 and uses C# 14.
Install the aggregate runtime and source-generator package from NuGet:
dotnet add package SharpPack --version 1.1.0Versioned packages and symbols are also attached to GitHub Releases.
The runtime packages target net10.0. SharpPack.Generator targets
netstandard2.0 only because Roslyn analyzers are loaded by the compiler host;
that target does not add a netstandard runtime support promise.
Define a struct or class to be serialized and annotate it with the [SharpPackable] attribute and the partial keyword.
using SharpPack;
[SharpPackable]
public partial class Person
{
public int Age { get; set; }
public string Name { get; set; }
}Serialization code is generated by the C# source generator feature which implements the ISharpPackable<T> interface. In Visual Studio you can check a generated code by using a shortcut Ctrl+K, R on the class name and select *.SharpPackFormatter.g.cs.
Call SharpPackSerializer.Serialize<T>/Deserialize<T> to serialize/deserialize an object instance.
var v = new Person { Age = 40, Name = "John" };
var bin = SharpPackSerializer.Serialize(v);
var val = SharpPackSerializer.Deserialize<Person>(bin);Serialize supports byte[], IBufferWriter<byte> and Stream.
Deserialize supports ReadOnlySpan<byte>, ReadOnlySequence<byte> and
Stream. Serializer operations are generic-only.
The ordinary byte[] serializer path, with or without an explicit Context,
retains an 8 KB unpinned first buffer per thread. Applications dominated by
payloads around 64 KB can select the 80 KB
high-throughput preset during startup, before the first retained serializer
state is created:
SharpPackSerializer.ConfigureRuntime(
SharpPackSerializerRuntimeOptions.HighThroughput);You can also create SharpPackSerializerRuntimeOptions with any custom
ThreadBufferSize from 1 through Array.MaxLength; the presets are only
convenience choices. For example:
SharpPackSerializer.ConfigureRuntime(new()
{
ThreadBufferSize = 128 * 1024,
PinThreadBuffer = false,
});The retained buffer is allocated once per thread that reaches an ordinary
byte[] return path, so increasing it trades process memory for fewer pooled
segments and copies. Direct raw-unmanaged, fixed-size and exact-size paths do
not create or freeze this state. PinThreadBuffer is an advanced
native-interop option; it does not normally improve managed serialization.
Runtime options freeze when the first retained byte-array serializer state is
created. IBufferWriter<byte>, Stream and Streaming paths continue to use
their caller-owned or pooled buffers.
The parameterless serializer APIs use process-lifetime generic formatter slots
directly; there is no global default-context object or mutable formatter graph.
This is the fastest path and is appropriate for application types loaded into
the default AssemblyLoadContext. Using these APIs with a type from a
collectible AssemblyLoadContext does not provide an unload guarantee.
Use an explicit SharpPackSerializerContext when formatter lifetime must
follow a plugin or an RPC endpoint:
var context = new SharpPackSerializerContext();
var bin = SharpPackSerializer.Serialize(pluginValue, context);
var value = SharpPackSerializer.Deserialize<PluginDto>(bin, context);The same context is propagated through the complete object graph, including arrays, lists, dictionaries, generated types, unions and custom formatters, except across raw-copy unmanaged struct interiors as described below. Collectible types and registered overrides always use the context-owned graph; an empty/configuration-only context may share type-only static slots for non-collectible types.
Build a frozen context when custom formatters or UTF-16 output are needed:
var context = new SharpPackSerializerContextBuilder()
.Configure(SharpPackSerializerConfiguration.Utf16)
.Register<IPluginContract>(new PluginContractFormatter())
.Build();There is no mutable global formatter provider. Release the context together
with plugin values, assemblies, reflection objects and delegates before
unloading the plugin AssemblyLoadContext.
See Formatter contexts and collectible AssemblyLoadContext
for lifecycle rules, migration details and architecture. A complete executable
example is available in
sandbox/CollectibleAlcSample.
See SharpPack versus MemoryPack benchmarks for the
reproducible NuGet-only comparison and current latency, throughput and
allocation results. SharpPack 1.1.0 matches MemoryPack 1.21.4 for unmanaged
byte[] serialization and is about 9% faster on the pre-sized unmanaged
IBufferWriter<byte> path in the published ARM64 run. On the generated object
graph, deserialization is about 2% faster, while byte[] serialization ranges
from 1% to 7% slower and the 1024-item writer path is 3% slower. Managed
allocations are identical, including zero serializer allocation on pre-sized
writer paths. Separate unpinned 8/80/256 KB measurements found all three buffer
sizes equivalent within 3%, supporting the 8 KB default.
These types can be serialized by default:
- .NET primitives (
byte,int,bool,char,double, etc.) - Unmanaged types (Any
enum, Any user-definedstructwhich doesn't contain reference types) string,decimal,Half,Int128,UInt128,Guid,Rune,BigIntegerTimeSpan,DateTime,DateTimeOffset,TimeOnly,DateOnly,TimeZoneInfoComplex,Plane,QuaternionMatrix3x2,Matrix4x4,Vector2,Vector3,Vector4Uri,Version,StringBuilder,Type,BitArray,CultureInfoT[],T[,],T[,,],T[,,,],Memory<>,ReadOnlyMemory<>,ArraySegment<>,ReadOnlySequence<>Nullable<>,Lazy<>,KeyValuePair<,>,Tuple<,...>,ValueTuple<,...>List<>,LinkedList<>,Queue<>,Stack<>,HashSet<>,SortedSet<>,PriorityQueue<,>Dictionary<,>,SortedList<,>,SortedDictionary<,>,ReadOnlyDictionary<,>Collection<>,ReadOnlyCollection<>,ObservableCollection<>,ReadOnlyObservableCollection<>IEnumerable<>,ICollection<>,IList<>,IReadOnlyCollection<>,IReadOnlyList<>,ISet<>IDictionary<,>,IReadOnlyDictionary<,>,ILookup<,>,IGrouping<,>,ConcurrentBag<>,ConcurrentQueue<>,ConcurrentStack<>,ConcurrentDictionary<,>,BlockingCollection<>- Immutable collections (
ImmutableList<>, etc.) and interfaces (IImmutableList<>, etc.)
[SharpPackable] can annotate any class, struct, record, record struct
or interface. As in MemoryPack, an unmanaged struct or record struct is
serialized directly from its memory layout. Member annotations, including
[SharpPackCustomFormatter], ignore/include, constructors and callbacks, are
not applied. To customize its representation, register a formatter for the
whole unmanaged type in a SharpPackSerializerContext.
Otherwise, by default, [SharpPackable] serializes public instance properties or fields. You can use [SharpPackIgnore] to remove serialization target, [SharpPackInclude] promotes a private member to serialization target.
[SharpPackable]
public partial class Sample
{
// these types are serialized by default
public int PublicField;
public readonly int PublicReadOnlyField;
public int PublicProperty { get; set; }
public int PrivateSetPublicProperty { get; private set; }
public int ReadOnlyPublicProperty { get; }
public int InitProperty { get; init; }
public required int RequiredInitProperty { get; init; }
// these types are not serialized by default
int privateProperty { get; set; }
int privateField;
readonly int privateReadOnlyField;
// use [SharpPackIgnore] to remove target of a public member
[SharpPackIgnore]
public int PublicProperty2 => PublicProperty + PublicField;
// use [SharpPackInclude] to promote a private member to serialization target
[SharpPackInclude]
int privateField2;
[SharpPackInclude]
int privateProperty2 { get; set; }
}SharpPack's code generator adds information about what members are serialized to the <remarks /> section. This can be viewed by hovering over the type with Intellisense.
All members must be sharppack-serializable, if not the code generator will emit an error.
SharpPack has 35 diagnostics rules (SHARPPACK001 to SHARPPACK035) to be defined comfortably.
If target type is defined SharpPack serialization externally and registered, use [SharpPackAllowSerialize] to silent diagnostics.
[SharpPackable]
public partial class Sample2
{
[SharpPackAllowSerialize]
public NotSerializableType? NotSerializableProperty { get; set; }
}Member order is important, SharpPack does not serialize the member-name or other information, instead serializing fields in the order they are declared. If a type is inherited, serialization is performed in the order of parent → child. The order of members can not change for the deserialization. For the schema evolution, see the Version tolerant section.
The default order is sequential, but you can choose the explicit layout with [SharpPackable(SerializeLayout.Explicit)] and [SharpPackOrder()].
// serialize Prop0 -> Prop1
[SharpPackable(SerializeLayout.Explicit)]
public partial class SampleExplicitOrder
{
[SharpPackOrder(1)]
public int Prop1 { get; set; }
[SharpPackOrder(0)]
public int Prop0 { get; set; }
}SharpPack supports both parameterized and parameterless constructors. The selection of the constructor follows these rules. (Applies to classes and structs).
- If there is
[SharpPackConstructor], use it. - If there is no explicit constructor (including private), use a parameterless one.
- If there is one parameterless/parameterized constructor (including private), use it.
- If there are multiple constructors, then the
[SharpPackConstructor]attribute must be applied to the desired constructor (the generator will not automatically choose one), otherwise the generator will emit an error. - If using a parameterized constructor, all parameter names must match corresponding member names (case-insensitive).
[SharpPackable]
public partial class Person
{
public readonly int Age;
public readonly string Name;
// You can use a parameterized constructor - parameter names must match corresponding members name (case-insensitive)
public Person(int age, string name)
{
this.Age = age;
this.Name = name;
}
}
// also supports record primary constructor
[SharpPackable]
public partial record Person2(int Age, string Name);
public partial class Person3
{
public int Age { get; set; }
public string Name { get; set; }
public Person3()
{
}
// If there are multiple constructors, then [SharpPackConstructor] should be used
[SharpPackConstructor]
public Person3(int age, string name)
{
this.Age = age;
this.Name = name;
}
}When serializing/deserializing, SharpPack can invoke a before/after event using the [SharpPackOnSerializing], [SharpPackOnSerialized], [SharpPackOnDeserializing], [SharpPackOnDeserialized] attributes. It can annotate both static and instance (non-static) methods, and public and private methods.
[SharpPackable]
public partial class MethodCallSample
{
// method call order is static -> instance
[SharpPackOnSerializing]
public static void OnSerializing1()
{
Console.WriteLine(nameof(OnSerializing1));
}
// also allows private method
[SharpPackOnSerializing]
void OnSerializing2()
{
Console.WriteLine(nameof(OnSerializing2));
}
// serializing -> /* serialize */ -> serialized
[SharpPackOnSerialized]
static void OnSerialized1()
{
Console.WriteLine(nameof(OnSerialized1));
}
[SharpPackOnSerialized]
public void OnSerialized2()
{
Console.WriteLine(nameof(OnSerialized2));
}
[SharpPackOnDeserializing]
public static void OnDeserializing1()
{
Console.WriteLine(nameof(OnDeserializing1));
}
// Note: instance method with SharpPackOnDeserializing, that not called if instance is not passed by `ref`
[SharpPackOnDeserializing]
public void OnDeserializing2()
{
Console.WriteLine(nameof(OnDeserializing2));
}
[SharpPackOnDeserialized]
public static void OnDeserialized1()
{
Console.WriteLine(nameof(OnDeserialized1));
}
[SharpPackOnDeserialized]
public void OnDeserialized2()
{
Console.WriteLine(nameof(OnDeserialized2));
}
}Callbacks allows parameterless method and ref reader/writer, ref T value method. For example, ref callbacks can write/read custom header before serialization process.
[SharpPackable]
public partial class EmitIdData
{
public int MyProperty { get; set; }
[SharpPackOnSerializing]
static void WriteId<TBufferWriter>(ref SharpPackWriter<TBufferWriter> writer, ref EmitIdData? value)
where TBufferWriter : IBufferWriter<byte> // .NET Standard 2.1, use where TBufferWriter : class, IBufferWriter<byte>
{
writer.WriteUnmanaged(Guid.NewGuid()); // emit GUID in header.
}
[SharpPackOnDeserializing]
static void ReadId(ref SharpPackReader reader, ref EmitIdData? value)
{
// read custom header before deserialize
var guid = reader.ReadUnmanaged<Guid>();
Console.WriteLine(guid);
}
}Implement SharpPackFormatter<T> and register the closed formatter while building a context:
var context = new SharpPackSerializerContextBuilder()
.Register<ExternalType>(new ExternalTypeFormatter())
.Build();Registrations are frozen by Build and are isolated to that context. A generated wrapper type remains the simplest option when you control the RPC contract.
SharpPack— runtime plus source generatorSharpPack.Core— runtime primitives and formatter graphSharpPack.Generator— Roslyn incremental source generatorSharpPack.Streaming—PipeReader/PipeWriterand streaming APIs
SharpPack supports TypeScript code generation. It generates class and serialization code from C#, In other words, you can share types with the Browser without using OpenAPI, proto, etc.
Code generation is integrated with Source Generator, the following options(SharpPackGenerator_TypeScriptOutputDirectory) set the output directory for TypeScript code. Runtime code is output at the same time, so no additional dependencies are required.
<!-- output sharppack TypeScript code to directory -->
<ItemGroup>
<CompilerVisibleProperty Include="SharpPackGenerator_TypeScriptOutputDirectory" />
</ItemGroup>
<PropertyGroup>
<SharpPackGenerator_TypeScriptOutputDirectory>$(MSBuildProjectDirectory)\wwwroot\js\sharppack</SharpPackGenerator_TypeScriptOutputDirectory>
</PropertyGroup>A C# SharpPackable type must be annotated with [GenerateTypeScript].
[SharpPackable]
[GenerateTypeScript]
public partial class Person
{
public required Guid Id { get; init; }
public required int Age { get; init; }
public required string FirstName { get; init; }
public required string LastName { get; init; }
public required DateTime DateOfBirth { get; init; }
public required Gender Gender { get; init; }
public required string[] Emails { get; init; }
}
public enum Gender
{
Male, Female, Other
}Runtime code and TypeScript type will be generated in the target directory.
The generated code is as follows, with simple fields and static methods for serialize/serializeArray and deserialize/deserializeArray.
import { SharpPackWriter } from "./SharpPackWriter.js";
import { SharpPackReader } from "./SharpPackReader.js";
import { Gender } from "./Gender.js";
export class Person {
id: string;
age: number;
firstName: string | null;
lastName: string | null;
dateOfBirth: Date;
gender: Gender;
emails: (string | null)[] | null;
constructor() {
// snip...
}
static serialize(value: Person | null): Uint8Array {
// snip...
}
static serializeCore(writer: SharpPackWriter, value: Person | null): void {
// snip...
}
static serializeArray(value: (Person | null)[] | null): Uint8Array {
// snip...
}
static serializeArrayCore(writer: SharpPackWriter, value: (Person | null)[] | null): void {
// snip...
}
static deserialize(buffer: ArrayBuffer): Person | null {
// snip...
}
static deserializeCore(reader: SharpPackReader): Person | null {
// snip...
}
static deserializeArray(buffer: ArrayBuffer): (Person | null)[] | null {
// snip...
}
static deserializeArrayCore(reader: SharpPackReader): (Person | null)[] | null {
// snip...
}
}You can use this type like following.
let person = new Person();
person.id = crypto.randomUUID();
person.age = 30;
person.firstName = "foo";
person.lastName = "bar";
person.dateOfBirth = new Date(1999, 12, 31, 0, 0, 0);
person.gender = Gender.Other;
person.emails = ["[email protected]", "[email protected]"];
// serialize to Uint8Array
let bin = Person.serialize(person);
let blob = new Blob([bin.buffer], { type: "application/x-sharppack" })
let response = await fetch("http://localhost:5260/api",
{ method: "POST", body: blob, headers: { "Content-Type": "application/x-sharppack" } });
let buffer = await response.arrayBuffer();
// deserialize from ArrayBuffer
let person2 = Person.deserialize(buffer);There are a few restrictions on the types that can be generated. Among the primitives, char and decimal are not supported. Also, OpenGenerics type cannot be used.
| C# | TypeScript | Description |
|---|---|---|
bool |
boolean |
|
byte |
number |
|
sbyte |
number |
|
int |
number |
|
uint |
number |
|
short |
number |
|
ushort |
number |
|
long |
bigint |
|
ulong |
bigint |
|
float |
number |
|
double |
number |
|
string |
string | null |
|
Guid |
string |
In TypeScript, represents as string but serialize/deserialize as 16byte binary |
DateTime |
Date |
DateTimeKind will be ignored |
enum |
const enum |
long and ulong underlying type is not supported |
T? |
T | null |
|
T[] |
T[] | null |
|
byte[] |
Uint8Array | null |
|
: ICollection<T> |
T[] | null |
Supports all ICollection<T> implemented type like List<T> |
: ISet<T> |
Set<T> | null |
Supports all ISet<T> implemented type like HashSet<T> |
: IDictionary<K,V> |
Map<K, V> | null |
Supports all IDictionary<K,V> implemented type like Dictionary<K,V>. |
[SharpPackable] |
class |
Supports class only |
[SharpPackUnion] |
abstract class |
[GenerateTypeScript] can only be applied to classes and is currently not supported by struct.
In default, SharpPack generates file extension as .js like import { SharpPackWriter } from "./SharpPackWriter.js";. If you want to change other extension or empty, use SharpPackGenerator_TypeScriptImportExtension to configure it.
Also the member name is automatically converted to camelCase. If you want to use original name, use SharpPackGenerator_TypeScriptConvertPropertyName to false.
<ItemGroup>
<CompilerVisibleProperty Include="SharpPackGenerator_TypeScriptOutputDirectory" />
<CompilerVisibleProperty Include="SharpPackGenerator_TypeScriptImportExtension" />
<CompilerVisibleProperty Include="SharpPackGenerator_TypeScriptConvertPropertyName" />
<CompilerVisibleProperty Include="SharpPackGenerator_TypeScriptEnableNullableTypes" />
</ItemGroup>
<PropertyGroup>
<SharpPackGenerator_TypeScriptOutputDirectory>$(MSBuildProjectDirectory)\wwwroot\js\sharppack</SharpPackGenerator_TypeScriptOutputDirectory>
<!-- allows empty -->
<SharpPackGenerator_TypeScriptImportExtension></SharpPackGenerator_TypeScriptImportExtension>
<!-- default is true -->
<SharpPackGenerator_TypeScriptConvertPropertyName>false</SharpPackGenerator_TypeScriptConvertPropertyName>
<!-- default is false -->
<SharpPackGenerator_TypeScriptEnableNullableTypes>true</SharpPackGenerator_TypeScriptEnableNullableTypes>
</PropertyGroup>SharpPackGenerator_TypeScriptEnableNullableTypes allows C# nullable annotations to be reflected in TypeScript code. The default is false, making everything nullable.
SharpPack.Streaming provides SharpPackStreamingSerializer, which adds additional support for serializing and deserializing collections with streams.
public static class SharpPackStreamingSerializer
{
public static async ValueTask SerializeAsync<T>(PipeWriter pipeWriter, int count, IEnumerable<T> source, int flushRate = 4096, CancellationToken cancellationToken = default)
public static async ValueTask SerializeAsync<T>(Stream stream, int count, IEnumerable<T> source, int flushRate = 4096, CancellationToken cancellationToken = default)
public static async IAsyncEnumerable<T?> DeserializeAsync<T>(PipeReader pipeReader, int bufferAtLeast = 4096, int readMinimumSize = 8192, [EnumeratorCancellation] CancellationToken cancellationToken = default)
public static IAsyncEnumerable<T?> DeserializeAsync<T>(Stream stream, int bufferAtLeast = 4096, int readMinimumSize = 8192, CancellationToken cancellationToken = default)
}Implement SharpPackFormatter<T> and register it through SharpPackSerializerContextBuilder. There is no process-wide formatter provider.
var context = new SharpPackSerializerContextBuilder()
.Register<Skelton>(new SkeltonFormatter())
.Build();SharpPackWriterOptionalStatePool.Rent(context) and SharpPackReaderOptionalStatePool.Rent(context) are available when using the low-level reader/writer API directly.
For length-prefixed transports, SharpPack.Streaming exposes SerializeFrameAsync and DeserializeFrameAsync. The frame boundary is transport metadata and does not alter the SharpPack payload.
Generated formatters and the generic runtime path are designed for Native AOT. Reflection-based cold shape discovery must be preserved when trimming an application that relies on runtime generic shapes.
Closed generic shapes used only as serializer roots, such as a custom
T[], may not otherwise have native generic code rooted by a generated
model. Reference their closed formatter statically, or register it through a
Context during startup. For example, a custom public factory can be rooted
without reflection by using
SharpPackSerializerContextBuilder.RegisterFactory<T, TFactory>(). The
default generated factories are explicit static interface implementations and
are preserved automatically. A public CreateFormatter discovered only by
reflection remains a JIT compatibility path; NativeAOT callers should use the
static registration API.
The type of T defined in Serialize<T> and Deserialize<T> is called C# schema. SharpPack format is not self-described format. Deserialize requires the corresponding C# schema. These types exist as internal representations of binaries, but types cannot be determined without a C# schema.
Endian must be Little Endian. However, reference C# implementation does not care about endianness so can not use on big-endian machine. However, modern computers are usually little-endian.
There are eight types of format.
- Unmanaged struct
- Object
- Version Tolerant Object
- Circular Reference Object
- Tuple
- Collection
- String
- Union
Unmanaged struct is C# struct that doesn't contain reference types, similar constraint of C# Unmanaged types. Serializing struct layout as it is, includes padding.
(byte memberCount, [values...])
Object has 1byte unsigned byte as member count in header. Member count allows 0 to 249, 255 represents object is null. Values store sharppack value for the number of member count.
(byte memberCount, [varint byte-length-of-values...], [values...])
Version Tolerant Object is similar as Object but has byte length of values in header. varint follows these spec, first sbyte is value or typeCode and next X byte is value. 0 to 127 = unsigned byte value, -1 to -120 = signed byte value, -121 = byte, -122 = sbyte, -123 = ushort, -124 = short, -125 = uint, -126 = int, -127 = ulong, -128 = long.
(byte memberCount, [varint byte-length-of-values...], varint referenceId, [values...])
(250, varint referenceId)
Circular Reference Object is similar as Version Tolerant Object but if memberCount is 250, next varint(unsigned-int32) is referenceId. If not, after byte-length-of-values, varint referenceId is written.
(values...)
Tuple is fixed-size, non-nullable value collection. In .NET, KeyValuePair<TKey, TValue> and ValueTuple<T,...> are serialized as Tuple.
(int length, [values...])
Collection has 4 byte signed integer as data count in header, -1 represents null. Values store sharppack value for the number of length.
(int utf16-length, utf16-value)
(int ~utf8-byte-count, int utf16-length, utf8-bytes)
String has two-forms, UTF16 and UTF8. If first 4byte signed integer is -1, represents null. 0, represents empty. UTF16 is same as collection(serialize as ReadOnlySpan<char>, utf16-value's byte count is utf16-length * 2). If first signed integer <= -2, value is encoded by UTF8. utf8-byte-count is encoded in complement, ~utf8-byte-count to retrieve count of bytes. Next signed integer is utf16-length, it allows -1 that represents unknown length. utf8-bytes store bytes for the number of utf8-byte-count.
(byte tag, value)
(250, ushort tag, value)
First unsigned byte is tag that for discriminated value type or flag, 0 to 249 represents tag, 250 represents next unsigned short is tag, 255 represents union is null.
This library is licensed under the MIT License.


