A high-performance, fully compliant .NET implementation of ULIDs (Universally Unique Lexicographically Sortable Identifiers), adhering to the official ULID specification.
- Introduction
- Features
- Installation
- Usage
- API
- Integration with Other Libraries
- Benchmarking
- Prior Art
- Contributing
- License
ULIDs (Universally Unique Lexicographically Sortable Identifiers) offer a modern, human-readable alternative to traditional GUIDs, optimized specifically for distributed systems and time-ordered data. ByteAether.Ulid delivers a high-performance, specification-compliant .NET implementation engineered to resolve critical concurrency and persistence edge cases unaddressed by alternative libraries.
During high-throughput transaction bursts within the same millisecond, the 80-bit random component of a ULID can saturate. Traditional libraries respond to this saturation by throwing an OverflowException to protect strict timestamp boundaries. ByteAether.Ulid introduces a non-blocking alternative: when the 80-bit random segment saturates during a high-throughput burst within a single millisecond, it gracefully increments the millisecond timestamp component instead of throwing. This ensures uninterrupted ID generation under extreme local loads.
While this introduces a micro-scale timestamp adjustment localized strictly to the executing instance, the system clock catches up immediately once the burst subsides. The drift remains well within standard network latency boundaries and aligns with the workarounds in ULID specification issue #39.
Monotonic identifiers generated in rapid succession can expose predictable sequences, leaving systems vulnerable to enumeration attacks. This library mitigates this risk by supporting configurable random increments (ranging from 1 to 4 bytes) applied to the random component, as discussed in ULID specification issue #105. This preserves strict lexicographical sortability while ensuring cryptographic unpredictability.
While modern standards like UUIDv7 introduce timestamp-based sorting, RFC 9562 treats sub-millisecond monotonicity as optional. The native .NET UUIDv7 provider (Guid.CreateVersion7) uses random bits within the sub-millisecond payload rather than a strict sequential counter, sacrificing true chronological ordering under heavy bursts.
Furthermore, using .NET's native Guid structures for sequential IDs introduces severe endianness conflicts. Because System.Guid utilizes a legacy mixed-endian internal structure, most database providers serialize this raw memory layout directly to disk without modification. This scrambles the big-endian timestamp layout, completely breaking chronological index sorting. For engines with highly rigid index layouts like Microsoft SQL Server, time-first structures natively conflict with custom uniqueidentifier indexing order, triggering catastrophic page fragmentation.
ByteAether.Ulid corrects this by mandating big-endian, strict lexicographical sortability directly at the specification level. It features optimized storage strategies (String, Binary, Guid, and SqlServerGuid) across major ORMs to maintain perfect index allocations and deterministic sorting whether targeting PostgreSQL, MS SQL Server, MySQL, or SQLite.
This library explicitly multi-targets each runtime version listed below, enabling native optimizations, zero-allocation memory abstractions, and performance benefits tailored specifically to each target platform.
- Universally Unique: Ensures global uniqueness across systems.
- Sortable: Lexicographically ordered for time-based sorting.
- Lock-Free Synchronization: Monotonic generation utilizes a high-performance, lock-free compare-and-exchange (CAS) approach.
- Specification-Compliant: Fully adheres to the ULID specification.
- Interoperable: Includes conversion methods to and from GUIDs, Crockford's Base32 strings, and byte arrays.
- Ahead-of-Time (AOT) Compilation: Fully compatible with Native AOT for improved startup performance and smaller binary footprints.
- Error-Free Generation: Prevents
OverflowExceptionby incrementing the timestamp component when the random part overflows, ensuring continuous unique ULID generation.
- π¦ Entity Framework Core
ByteAether.Ulid.EntityFrameworkCore - π¦ LinqToDB
ByteAether.Ulid.linq2db - π¦ Dapper
ByteAether.Ulid.Dapper - π¦ ASP.NET Core
ByteAether.Ulid.AspNetCore - π¦ CLI & .NET Tool
ByteAether.Ulid.Cli
These features collectively make ByteAether.Ulid a robust and efficient choice for managing unique identifiers in your .NET applications.
Install the latest stable package via NuGet:
dotnet add package ByteAether.UlidTo install a specific preview version, use the --version option:
dotnet add package ByteAether.Ulid --version <VERSION_NUMBER>Here is a basic example of how to use the ULID implementation:
using System;
using ByteAether.Ulid;
// Create a new ULID
var ulid = Ulid.New();
// Convert to byte array and back
byte[] byteArray = ulid.ToByteArray();
var ulidFromByteArray = Ulid.New(byteArray);
// Convert to GUID and back
Guid guid = ulid.ToGuid();
var ulidFromGuid = Ulid.New(guid);
// Convert to string and back
string ulidString = ulid.ToString();
var ulidFromString = Ulid.Parse(ulidString);
Console.WriteLine($"ULID: {ulid}, GUID: {guid}, String: {ulidString}");Because ULIDs embed a millisecond-precision timestamp and maintain lexicographical order, you can use Ulid.MinAt() and Ulid.MaxAt() to generate boundary instances for specific time windows. This approach provides a uniform mechanism for range filtering across both in-memory collections and abstract data layers:
// Define temporal boundaries for the target window
DateTimeOffset startTime = DateTimeOffset.UtcNow.AddDays(-7);
DateTimeOffset endTime = DateTimeOffset.UtcNow;
// Generate the minimum and maximum possible ULIDs for those precise timestamps
Ulid minBoundary = Ulid.MinAt(startTime);
Ulid maxBoundary = Ulid.MaxAt(endTime);
// Example 1: In-Memory Evaluation
var filteredItems = localItems
.Where(item => item.Id >= minBoundary && item.Id <= maxBoundary);
// Example 2: Parameterized Data Store Constraint
var query = "SELECT * FROM Records WHERE Id >= @Min AND Id <= @Max";Important
Database Persistence Considerations
While range evaluations remain consistent across in-memory object graphs, executing these queries against a relational data store introduces critical persistence dependencies:
- Storage Format & Byte Order: Certain database engines and native UUID data types utilize mixed-endian byte layouts. If a ULID is persisted using a strategy that reorders its raw big-endian bytes, chronological sorting behavior will diverge between the application and the database server.
- Index & Query Integrity: Mismatches between the database engine's native sorting rules and the chosen storage format can result in broken data retrieval, bypassed indexes, or incorrect query results during database-side range operations (
>=,<=) andORDER BYexecution.
Recommendation: Before implementing database-side time-range queries, ensure your chosen storage format (e.g., String, Binary, or provider-specific Guid) aligns with your target database engine's native indexing and evaluation mechanics.
You can customize ULID generation by providing GenerationOptions. This allows you to control monotonicity and the source of randomness.
The monotonicity state (last generated timestamp and 80-bit random payload) is bound directly to the lifecycle of the GenerationOptions instance.
- Instance Reuse (Recommended for Sequences): Reusing a single
GenerationOptionsinstance across calls guarantees strict, cross-thread monotonic ordering via lock-free atomic compare-and-exchange (CAS) operations. - Instance Isolation: Passing a new
GenerationOptionsinstance on each call isolates state, disabling monotonic sequence tracking between calls and eliminating CAS contention. - Copies: A copy made with a
withexpression (e.g.options with { Monotonicity = ... }) gets its own, fresh state. It never continues or shares the sequence of the instance it was copied from.
using System;
using ByteAether.Ulid;
using static ByteAether.Ulid.Ulid.GenerationOptions;
// Configure options for a 2-byte random increment
var options = new Ulid.GenerationOptions
{
Monotonicity = MonotonicityOptions.MonotonicRandom2Byte
};
// Generate a ULID with the specified options
var ulid = Ulid.New(options);
Console.WriteLine($"ULID with random increment: {ulid}");You can set default generation options for the entire application. This is useful for consistently applying specific behaviors, such as prioritizing performance over cryptographic security.
using System;
using ByteAether.Ulid;
using static ByteAether.Ulid.Ulid.GenerationOptions;
// Set default generation options for the entire application
Ulid.DefaultGenerationOptions = new()
{
Monotonicity = MonotonicityOptions.MonotonicIncrement,
InitialRandomSource = new PseudoRandomProvider(),
IncrementRandomSource = new PseudoRandomProvider()
};
// Now, any subsequent call to Ulid.New() will use these options
var ulid = Ulid.New();
Console.WriteLine($"ULID from pseudo-random source: {ulid}");The Ulid implementation provides the following properties and methods:
Ulid.New(GenerationOptions? options = null)
Generates a new ULID using default generation options. Accepts an optionalGenerationOptionsparameter to customize the generation behavior.Ulid.New(DateTimeOffset dateTimeOffset, GenerationOptions? options = null)
Generates a new ULID using the specifiedDateTimeOffsetand default generation options. Accepts an optionalGenerationOptionsparameter to customize the generation behavior.Ulid.New(long timestamp, GenerationOptions? options = null)
Generates a new ULID using the specified Unix timestamp in milliseconds (long) and default generation options. Accepts an optionalGenerationOptionsparameter to customize the generation behavior.Ulid.New(DateTimeOffset dateTimeOffset, ReadOnlySpan<byte> random)
Generates a new ULID using the specifiedDateTimeOffsetand a pre-existing random byte array.Ulid.New(long timestamp, ReadOnlySpan<byte> random)
Generates a new ULID using the specified Unix timestamp in milliseconds (long) and a pre-existing random byte array.Ulid.New(ReadOnlySpan<byte> bytes)
Creates a ULID from an existing byte array.Ulid.New(Guid guid)
Creates a ULID from an existingGuid.Ulid.MinAt(DateTimeOffset datetime)
Creates the minimum possible ULID value for the specifiedDateTimeOffset.Ulid.MinAt(long timestamp)
Creates the minimum possible ULID value for the specified Unix timestamp in milliseconds (long).Ulid.MaxAt(DateTimeOffset datetime)
Creates the maximum possible ULID value for the specifiedDateTimeOffset.Ulid.MaxAt(long timestamp)
Creates the maximum possible ULID value for the specified Unix timestamp in milliseconds (long).
Ulid.IsValid(string ulidString)
Validates whether the specified string represents a valid ULID.Ulid.IsValid(ReadOnlySpan<char> ulidString)
Validates whether the specified span of characters represents a valid ULID.Ulid.IsValid(ReadOnlySpan<byte> utf8Text)
Validates whether the specified UTF-8 encoded byte span represents a valid ULID string.
A valid ULID string is exactly 26 characters of Crockford's Base32 alphabet, read case-insensitively, with I and L accepted as 1, and O as 0. The first character must be between 0 and 7, as larger values do not fit into 128 bits.
Ulid.Parse(ReadOnlySpan<char> chars, IFormatProvider? provider = null)
Parses a ULID from a character span in canonical format. TheIFormatProvideris ignored.Ulid.TryParse(ReadOnlySpan<char> s, IFormatProvider? provider, out Ulid result)
Tries to parse a ULID from a character span in canonical format. Returnstrueif successful.Ulid.Parse(string s, IFormatProvider? provider = null)
Parses a ULID from a string in canonical format. TheIFormatProvideris ignored.Ulid.TryParse(string? s, IFormatProvider? provider, out Ulid result)
Tries to parse a ULID from a string in canonical format. Returnstrueif successful.Ulid.Parse(ReadOnlySpan<byte> bytes, IFormatProvider? provider = null)
Parses a ULID from a UTF-8 encoded byte span in canonical format. TheIFormatProvideris ignored.Ulid.TryParse(ReadOnlySpan<byte> s, IFormatProvider? provider, out Ulid result)
Tries to parse a ULID from a UTF-8 encoded byte span in canonical format. Returnstrueif successful.
Parsing applies the same rules as validation (see Checking Validity above). Parse throws a FormatException for input that IsValid rejects, while TryParse returns false without throwing.
Ulid.MinValue
Represents an empty ULID, equivalent todefault(Ulid)orUlid.New(new byte[16]).Ulid.MaxValue
Represents the maximum possible value for a ULID (all bytes set to0xFF).Ulid.Empty
Alias forUlid.MinValue.Ulid.DefaultGenerationOptions
Gets or sets the global defaultGenerationOptionsconfiguration for ULID generation when no options are provided by theUlid.New(...)call..Time
Gets the timestamp component of the ULID as aDateTimeOffset..TimeBytes
Gets the timestamp component of the ULID as aReadOnlySpan<byte>..Random
Gets the random component of the ULID as aReadOnlySpan<byte>.
.AsByteSpan()
Provides aReadOnlySpan<byte>representing the contents of the ULID..ToByteArray()
Converts the ULID to a byte array..ToGuid()
Converts the ULID to aGuid..ToString(string? format = null, IFormatProvider? formatProvider = null)
Converts the ULID to a canonical string representation. Format arguments are ignored.- Provides implicit operators to and from
Guidandstring.
- Supports all comparison operators:
==,!=,<,<=,>,>=. - Implements standard comparison and equality methods:
CompareTo,Equals,GetHashCode. - Implements the following .NET standard interfaces:
IMinMaxValue<Ulid>,IEquatable<Ulid>,IEqualityComparer<Ulid>,IComparable,IComparable<Ulid>,IComparisonOperators<Ulid, Ulid, bool>,IFormattable,IParsable<Ulid>,ISpanFormattable,ISpanParsable<Ulid>,IUtf8SpanFormattable,IUtf8SpanParsable<Ulid>.
The GenerationOptions class encapsulates generation strategy, state retention, and lock-free thread synchronization for monotonic ULID generation.
Configurable properties:
-
Monotonicity
Defines the monotonic strategy when generating multiple ULIDs within the same millisecond. Each instance maintains an atomic state machine using lock-free Compare-And-Swap (CAS) primitives to guarantee strict sequential ordering without mutex locking. Options include:NonMonotonic: Generates fully random 80-bit payloads without state tracking.MonotonicIncrement(Default): Increments the least significant bit of the random payload upon sub-millisecond collisions.MonotonicRandom1Byte,MonotonicRandom2Byte,MonotonicRandom3Byte,MonotonicRandom4Byte: Adds a random integer increment within the specified byte range to the payload, strengthening entropy against enumeration attacks while maintaining monotonicity.
-
InitialRandomSource
AnIRandomProviderfor generating the random bytes of a ULID. The defaultCryptographicallySecureRandomProviderensures robust, unpredictable ULIDs using a cryptographically secure generator. -
IncrementRandomSource
AnIRandomProviderthat provides randomness for monotonic random increments. The defaultPseudoRandomProvideris a faster, non-cryptographically secure source optimized for this specific purpose.
This library comes with two default IRandomProvider implementations:
CryptographicallySecureRandomProvider
UtilizesSystem.Security.Cryptography.RandomNumberGeneratorfor high-quality, cryptographically secure random data.PseudoRandomProvider
A faster, non-cryptographically secure option based onSystem.Random, ideal for performance-critical scenarios where cryptographic security is not required for random increments.
Custom IRandomProvider implementations can also be created.
This companion package is optional. The core ByteAether.Ulid library already supports ULIDs as ASP.NET Core route and query parameters through its built-in TypeConverter, and serializes ULIDs as strings with System.Text.Json. Install the companion package only if you need its additional ASP.NET Core integrations:
- A
:ulidroute constraint that matches only valid ULIDs. - Typed
Ulidroute link generation. - Optional OpenAPI schema support for Swashbuckle or built-in ASP.NET Core OpenAPI.
To use these additional features, install the package:
dotnet add package ByteAether.Ulid.AspNetCoreThen register the ulid route constraint during application startup:
using ByteAether.Ulid;
using ByteAether.Ulid.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddUlidRouteConstraint();
var app = builder.Build();
app.MapGet("/users/{id:ulid}", (Ulid id) => Results.Ok(id));
app.Run();The package depends on ByteAether.Ulid. OpenAPI packages remain optional and are not added transitively.
More details in the package's PACKAGE.md file.
Includes a JsonConverter for easy serialization and deserialization.
EF Core Integration β ByteAether.Ulid.EntityFrameworkCore
To seamlessly use ULIDs with Entity Framework Core, install the specialized extension package:
dotnet add package ByteAether.Ulid.EntityFrameworkCoreRegister the ULID conventions within your DbContext via the ConfigureConventions method. You can choose from various underlying storage strategies (String, Binary, Guid, or SqlServerGuid):
using ByteAether.Ulid.EntityFrameworkCore;
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
// Registers mapping for both Ulid and Ulid? types.
// Supports: UlidStorageFormat.String (Default), Binary, Guid, and SqlServerGuid
configurationBuilder
.RegisterUlid(UlidStorageFormat.Binary);
}If you need different storage formats for different tables or columns, bypass global conventions and configure specific ValueConverter classes directly on individual properties via OnModelCreating:
using ByteAether.Ulid.EntityFrameworkCore;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Store this specific ULID as a 26-character String
modelBuilder.Entity<User>()
.Property(u => u.Id)
.HasConversion<UlidToStringConverter>();
// Store this specific ULID as a 16-byte Binary array
modelBuilder.Entity<Order>()
.Property(o => o.Id)
.HasConversion<UlidToBytesConverter>();
// Store this specific ULID as a standard Native GUID
modelBuilder.Entity<Product>()
.Property(p => p.Id)
.HasConversion<UlidToGuidConverter>();
// Store this specific ULID optimized for MSSQL uniqueidentifier index sorting
modelBuilder.Entity<LogEntry>()
.Property(l => l.Id)
.HasConversion<UlidToSqlServerGuidConverter>();
}More details in the package's PACKAGE.md file.
LinqToDB Integration β ByteAether.Ulid.linq2db
To integrate with LinqToDB, install the specialized extension package:
dotnet add package ByteAether.Ulid.linq2dbRegister the ULID conventions for your DataOptions instance using your preferred storage backend format (String, Binary, Guid, or SqlServerGuid):
using LinqToDB;
using ByteAether.Ulid.LinqToDB;
var options = new DataOptions()
.UseSQLite()
.UseConnectionString(connectionString)
// Registers mapping for both Ulid and Ulid? types.
// Supports: UlidStorageFormat.String (Default), Binary, Guid, and SqlServerGuid
.RegisterUlid(UlidStorageFormat.Binary);More details in the package's PACKAGE.md file.
Dapper Integration β ByteAether.Ulid.Dapper
To integrate with Dapper, install the specialized extension package:
dotnet add package ByteAether.Ulid.DapperCall DapperUlid.RegisterUlid() during application startup (e.g., in Program.cs) before executing database queries. You can choose from various underlying storage formats (String, Binary, Guid, or SqlServerGuid):
using ByteAether.Ulid.Dapper;
// Registers the mapping globally for both Ulid and Ulid? types.
// Supports: UlidStorageFormat.String (Default), Binary, Guid, and SqlServerGuid
DapperUlid.RegisterUlid(UlidStorageFormat.Binary);Note
Dapper maps .NET types globally using a 1:1 scheme (Type β TypeHandler). A single global storage strategy must be selected for the entire application lifecycle. Mixing formats (e.g., String and Binary) across distinct tables within the same process instance is not supported.
More details in the package's PACKAGE.md file.
To use ULIDs with Newtonsoft.Json, you need to create a custom JsonConverter to handle the serialization and deserialization of ULID values. Here's how to set it up:
First, create a custom JsonConverter<Ulid> for Ulid to handle string serialization and deserialization:
using Newtonsoft.Json;
using System;
public class UlidJsonConverter : JsonConverter<Ulid>
{
public override Ulid ReadJson(
JsonReader reader,
Type objectType,
Ulid existingValue,
bool hasExistingValue,
JsonSerializer serializer
)
{
var value = (string)reader.Value;
return Ulid.Parse(value);
}
public override void WriteJson(
JsonWriter writer,
Ulid value,
JsonSerializer serializer
)
{
writer.WriteValue(value.ToString());
}
}Once the UlidJsonConverter is created, you need to register it with Newtonsoft.Json to handle Ulid serialization and deserialization. You can register the converter globally when configuring your JSON settings:
using Newtonsoft.Json;
using System.Collections.Generic;
JsonConvert.DefaultSettings = () => new JsonSerializerSettings
{
Converters = new List<JsonConverter> { new UlidJsonConverter() }
};Alternatively, you can specify the converter explicitly in individual serialization or deserialization calls:
var settings = new JsonSerializerSettings();
settings.Converters.Add(new UlidJsonConverter());
var json = JsonConvert.SerializeObject(myObject, settings);
var deserializedObject = JsonConvert.DeserializeObject<MyObject>(json, settings);To use ULIDs with MessagePack, you can create a custom MessagePackResolver to handle the serialization and deserialization of Ulid as byte[]. Here's how to set it up:
First, create a custom formatter for Ulid to handle its conversion to and from byte[]:
using MessagePack;
using MessagePack.Formatters;
public class UlidFormatter : IMessagePackFormatter<Ulid>
{
public Ulid Deserialize(
ref MessagePackReader reader,
MessagePackSerializerOptions options
)
{
var bytes = reader.ReadByteArray();
return Ulid.New(bytes);
}
public void Serialize(
ref MessagePackWriter writer,
Ulid value, MessagePackSerializerOptions options
)
{
writer.Write(value.ToByteArray());
}
}Once the UlidFormatter is created, you need to register it with the MessagePackSerializer to handle the Ulid type.
MessagePack.Resolvers.CompositeResolver.Register(
new IMessagePackFormatter[] { new UlidFormatter() },
MessagePack.Resolvers.StandardResolver.GetFormatterWithVerify<Ulid>()
);Alternatively, you can register the formatter globally when configuring MessagePack options:
MessagePackSerializer.DefaultOptions = MessagePackSerializer.DefaultOptions
.WithResolver(MessagePack.Resolvers.CompositeResolver.Create(
new IMessagePackFormatter[] { new UlidFormatter() },
MessagePack.Resolvers.StandardResolver.Instance
));.NET Tool & CLI Integration β ByteAether.Ulid.Cli
An official CLI tool and companion utility for ByteAether.Ulid, providing command-line generation and inspection of Universally Unique Lexicographically Sortable Identifiers (ULIDs). Optimized for scripting, pipelines, terminal workflows, and tool interop.
To install globally as a .NET Tool:
dotnet tool install -g ByteAether.Ulid.CliOr install locally in your repository/project:
dotnet new tool-manifest # if not already present
dotnet tool install ByteAether.Ulid.CliWhen installed globally, run ulid. When installed locally, prefix commands with dotnet ulid.
# Generate a single ULID
ulid
# Generate 5 ULIDs formatted as GUIDs
ulid -c 5 -f guid
# Inspect a ULID
ulid 01AN4Z07BY79KA1307SR9X4MV3
# Inspect from stdin and output as JSON
echo "01AN4Z07BY79KA1307SR9X4MV3" | ulid --jsonMore details in the package's PACKAGE.md file.
Benchmarking was performed using BenchmarkDotNet to demonstrate the performance and efficiency of this ULID implementation. Comparisons include NetUlid 2.1.0, Ulid 1.4.1, NUlid 1.7.3, and Guid for overlapping functionalities like creation, parsing, and byte conversions.
ByteAetherUlid: Standard generation using cryptographically secure defaults.ByteAetherUlidR1Bc/ByteAetherUlidR4Bc: Monotonic generation with a cryptographically secure random increment (1-byte / 4-byte).ByteAetherUlidR1Bp/ByteAetherUlidR4Bp: Monotonic generation optimized with a pseudo-random increment (1-byte / 4-byte).ByteAetherUlidP: Non-monotonic generation using a high-performance pseudo-random provider.
The following benchmarks were performed:
BenchmarkDotNet v0.15.8, Windows 10 (10.0.19044.7725/21H2/November2021Update)
AMD Ryzen 7 3700X 3.60GHz, 1 CPU, 8 logical and 4 physical cores
.NET SDK 10.0.401
[Host] : .NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v3
DefaultJob : .NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v3
Job=DefaultJob
| Type | Method | Mean | Error | Gen0 | Allocated |
|---------------- |------------------- |------------:|----------:|-------:|----------:|
| Generate | ByteAetherUlid | 38.1463 ns | 0.0679 ns | - | - |
| Generate | ByteAetherUlidR1Bp | 40.3767 ns | 0.1023 ns | - | - |
| Generate | ByteAetherUlidR4Bp | 45.8267 ns | 0.1110 ns | - | - |
| Generate | ByteAetherUlidR1Bc | 84.1466 ns | 0.4368 ns | - | - |
| Generate | ByteAetherUlidR4Bc | 87.0893 ns | 0.3336 ns | - | - |
| Generate | NetUlid *(1) | 159.8722 ns | 0.8177 ns | 0.0095 | 80 B |
| Generate | NUlid *(2) | 49.0189 ns | 0.1703 ns | - | - |
| GenerateNonMono | ByteAetherUlid | 86.2648 ns | 0.4680 ns | - | - |
| GenerateNonMono | ByteAetherUlidP | 37.8072 ns | 0.1707 ns | - | - |
| GenerateNonMono | Ulid *(3,4) | 39.4395 ns | 0.2104 ns | - | - |
| GenerateNonMono | NUlid | 93.3513 ns | 0.4815 ns | - | - |
| GenerateNonMono | Guid *(5) | 48.3969 ns | 0.2306 ns | - | - |
| GenerateNonMono | GuidV7 *(3,5) | 79.1905 ns | 0.2090 ns | - | - |
| FromByteArray | ByteAetherUlid | 0.6981 ns | 0.0093 ns | - | - |
| FromByteArray | NetUlid | 1.7206 ns | 0.0174 ns | - | - |
| FromByteArray | Ulid | 1.2139 ns | 0.0160 ns | - | - |
| FromByteArray | NUlid | 1.1370 ns | 0.0118 ns | - | - |
| FromByteArray | Guid | 1.0113 ns | 0.0072 ns | - | - |
| FromGuid | ByteAetherUlid | 0.6360 ns | 0.0117 ns | - | - |
| FromGuid | NetUlid | 1.7273 ns | 0.0226 ns | - | - |
| FromGuid | Ulid | 1.8704 ns | 0.0426 ns | - | - |
| FromGuid | NUlid | 0.8229 ns | 0.0090 ns | - | - |
| FromString | ByteAetherUlid | 15.7206 ns | 0.1081 ns | - | - |
| FromString | NetUlid | 27.4088 ns | 0.0853 ns | - | - |
| FromString | Ulid | 17.2080 ns | 0.0880 ns | - | - |
| FromString | NUlid | 49.9503 ns | 0.2598 ns | 0.0086 | 72 B |
| FromString | Guid | 21.7319 ns | 0.1089 ns | - | - |
| ToByteArray | ByteAetherUlid | 4.2862 ns | 0.1354 ns | 0.0048 | 40 B |
| ToByteArray | AsByteSpan *(6) | 0.6701 ns | 0.0074 ns | - | - |
| ToByteArray | NetUlid | 9.5231 ns | 0.1255 ns | 0.0048 | 40 B |
| ToByteArray | Ulid | 4.4401 ns | 0.1018 ns | 0.0048 | 40 B |
| ToByteArray | NUlid | 8.5761 ns | 0.0941 ns | 0.0048 | 40 B |
| ToGuid | ByteAetherUlid | 0.6665 ns | 0.0121 ns | - | - |
| ToGuid | NetUlid | 8.7326 ns | 0.0263 ns | - | - |
| ToGuid | Ulid | 1.2456 ns | 0.0287 ns | - | - |
| ToGuid | NUlid | 0.7064 ns | 0.0096 ns | - | - |
| ToString | ByteAetherUlid | 14.5372 ns | 0.2029 ns | 0.0095 | 80 B |
| ToString | NetUlid | 22.8099 ns | 0.1848 ns | 0.0095 | 80 B |
| ToString | Ulid | 19.6863 ns | 0.4276 ns | 0.0095 | 80 B |
| ToString | NUlid | 30.7311 ns | 0.2561 ns | 0.0095 | 80 B |
| ToString | Guid | 8.5187 ns | 0.2234 ns | 0.0115 | 96 B |
| GetHashCode | ByteAetherUlid | 0.8390 ns | 0.0066 ns | - | - |
| GetHashCode | NetUlid | 8.7579 ns | 0.0287 ns | - | - |
| GetHashCode | Ulid | 0.8428 ns | 0.0099 ns | - | - |
| GetHashCode | NUlid | 6.6177 ns | 0.0360 ns | - | - |
| GetHashCode | Guid | 0.7400 ns | 0.0150 ns | - | - |
| CompareTo | ByteAetherUlid | 1.3273 ns | 0.0215 ns | - | - |
| CompareTo | NetUlid | 4.0820 ns | 0.0202 ns | - | - |
| CompareTo | Ulid | 6.8477 ns | 0.0239 ns | - | - |
| CompareTo | NUlid | 9.5116 ns | 0.0626 ns | - | - |
| CompareTo | Guid | 4.9821 ns | 0.0270 ns | - | - |
| Equals | ByteAetherUlid | 0.9571 ns | 0.0100 ns | - | - |
| Equals | NetUlid | 1.9630 ns | 0.0140 ns | - | - |
| Equals | Ulid | 0.9663 ns | 0.0121 ns | - | - |
| Equals | NUlid | 0.9564 ns | 0.0077 ns | - | - |
| Equals | Guid | 0.9610 ns | 0.0105 ns | - | - |
Alternative .NET ecosystem solutions exhibit design constraints or spec deviations under heavy production loads:
NetUlid: Monotonicity guarantees are thread-confined and fail across concurrent multi-threaded execution loops.NUlid: Although it provides a monotonic random provider (MonotonicUlidRng), it lacks out-of-the-box global state management. Developers must manually instantiate and persist the generator instance, requiring custom wrappers to maintain thread-safe monotonicity across call sites.Ulid(Cysharp) &GuidV7: Do not implement monotonicity.Ulid(Cysharp): Relies on a cryptographically insecureXOR-Shift64algorithm for sequence generation after seeding.- Native
Guid/GuidV7: Microsoft documentation explicitly warns that the underlying RNG is not guaranteed to be cryptographically secure, rendering them unsuitable for security-sensitive unique keys. AsByteSpan: A zero-allocation performance optimization unique toByteAether.Ulid, exposing a directReadOnlySpan<byte>slice of the underlying structure.
Furthermore, both NetUlid and NUlid, despite offering monotonicity, are susceptible to OverflowException due to random-part overflow.
This implementation demonstrates performance comparable to or exceeding its closest competitors. Crucially, it provides the most complete adherence to the official ULID specification, ensuring superior reliability and robustness for your applications compared to other libraries.
Much of this implementation is either based on or inspired by existing works. This library is standing on the shoulders of giants.
We welcome all contributions! You can:
- Open a Pull Request: Fork the repository, create a branch, make your changes, and submit a pull request to the
mainbranch. - Report Issues: Found a bug or have a suggestion? Open an issue with details.
Thank you for helping improve the project!
This project is licensed under the MIT License. See the LICENSE file for details.






