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 are universally unique, lexicographically sortable identifiers, ideal for distributed systems and time-ordered data due to their sortability and human-readability—advantages GUIDs lack. This library offers a robust, fully compliant .NET implementation, addressing limitations found in other ULID solutions.
This implementation addresses a potential OverflowException that can occur when generating multiple ULIDs within the same millisecond due to the "random" part overflowing. To ensure dependable, unique ULID generation, our solution increments the timestamp component upon random part overflow, eliminating such exceptions. This behavior aligns with discussions in ULID specification issue #39.
This library uniquely addresses the predictability of monotonic ULIDs generated within the same millisecond by allowing random increments to the random component. This mitigates enumeration attack vulnerabilities, as discussed in ULID specification issue #105. You can configure the random increment with a random value ranging from 1-byte (1–256) to 4-bytes (1–4,294,967,296), enhancing randomness while preserving lexicographical sortability.
In the evolution of distributed identifiers, ULIDs represent the definitive successor to both legacy GUIDs and auto-incrementing integers. While modern standards like UUIDv7 attempt to address sortability, the RFC 9562 makes monotonicity optional, allowing implementations (such as the native .NET provider) to sacrifice strict ordering during sub-millisecond bursts. This "lazy" approach reintroduces the very index fragmentation and out-of-order writes that sortable IDs were meant to solve.
ULID addresses this by design, mandating strict lexicographical sortability and monotonic increments. By enforcing these requirements at the specification level rather than leaving them to the implementor's discretion, ULID ensures consistent, high-performance behavior across all environments. This library provides a robust, compliant implementation that guarantees this order, enabling your application to scale without the performance trade-offs of non-deterministic identifiers.
- 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 Compatible: Fully compatible with AoT compilation for improved startup performance and smaller binary sizes.
- Error-Free Generation: Prevents
OverflowExceptionby incrementing the timestamp component when the random part overflows, ensuring continuous unique ULID generation.
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}");Since ULIDs are lexicographically sortable and contain a timestamp, you can use Ulid.MinAt() and Ulid.MaxAt() to generate boundary ULIDs for a specific time range. This allows EF Core to translate these into efficient range comparisons (e.g., WHERE Id >= @min AND Id <= @max) in your database.
public async Task<List<Entity>> GetEntitiesFromYesterday(MyDbContext context)
{
var startOfYesterday = DateTimeOffset.UtcNow.AddDays(-1).Date;
var endOfYesterday = startOfYesterday.AddDays(1).AddTicks(-1);
// Create boundary ULIDs for the time range
var minUlid = Ulid.MinAt(startOfYesterday);
var maxUlid = Ulid.MaxAt(endOfYesterday);
// This query uses the primary key index for high performance
return await context.Entities
.Where(e => e.Id >= minUlid && e.Id <= maxUlid)
.ToListAsync();
}You can customize ULID generation by providing GenerationOptions. This allows you to control monotonicity and the source of randomness.
To generate ULIDs that are monotonically increasing with a random increment, you can specify the Monotonicity option.
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)
Create from 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 if the given string is a valid ULID.Ulid.IsValid(ReadOnlySpan<char> ulidString)
Validates if the given span of characters is a valid ULID.Ulid.IsValid(ReadOnlySpan<byte> ulidBytes)
Validates if the given byte array represents a valid ULID.
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.MinValue
Represents an empty ULID, equivalent todefault(Ulid)andUlid.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
Default configuration 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 time 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>,IIEqualityComparer<Ulid>,IComparable,IComparable<Ulid>,IComparisonOperators<Ulid, Ulid, bool>,IFormattable,IParsable<Ulid>,ISpanFormattable,ISpanParsable<Ulid>,IUtf8SpanFormattable,IUtf8SpanParsable<Ulid>.
The GenerationOptions class provides detailed configuration for ULID generation, with the following key properties:
-
Monotonicity
Controls the behavior of ULID generation when multiple identifiers are created within the same millisecond. It determines whether ULIDs are strictly increasing or allow for random ordering within that millisecond. Available options include:NonMonotonic,MonotonicIncrement(default),MonotonicRandom1Byte,MonotonicRandom2Byte,MonotonicRandom3Byte,MonotonicRandom4Byte. -
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.
Supports seamless integration as a route or query parameter with built-in TypeConverter.
Includes a JsonConverter for easy serialization and deserialization.
To use ULIDs as primary keys or properties in Entity Framework Core, you can create a custom ValueConverter to handle the conversion between Ulid and byte[]. Here's how to do it:
public class UlidToBytesConverter : ValueConverter<Ulid, byte[]>
{
private static readonly ConverterMappingHints _defaultHints = new(size: 16);
public UlidToBytesConverter() : this(_defaultHints) { }
public UlidToBytesConverter(ConverterMappingHints? mappingHints = null) : base(
convertToProviderExpression: x => x.ToByteArray(),
convertFromProviderExpression: x => Ulid.New(x),
mappingHints: _defaultHints.With(mappingHints)
)
{ }
}Once the converter is created, you need to register it in your DbContext's ConfigureConventions virtual method to apply it to Ulid properties:
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
// ...
configurationBuilder
.Properties<Ulid>()
.HaveConversion<UlidToBytesConverter>();
// ...
}To use ULIDs with Dapper, you can create a custom TypeHandler to convert between Ulid and byte[]. Here's how to set it up:
using Dapper;
using System.Data;
public class UlidTypeHandler : SqlMapper.TypeHandler<Ulid>
{
public override void SetValue(IDbDataParameter parameter, Ulid value)
{
parameter.Value = value.ToByteArray();
}
public override Ulid Parse(object value)
{
return Ulid.New((byte[])value);
}
}After creating the UlidTypeHandler, you need to register it with Dapper. You can do this during application startup (e.g., in the Main method or ConfigureServices for ASP.NET Core).
Dapper.SqlMapper.AddTypeHandler(new UlidTypeHandler());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
));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 for Ulid to serialize and deserialize it as a string:
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);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.
Benchmark scenarios also include comparisons against Guid, where functionality overlaps, such as creation, parsing, and byte conversions.
Note:
ByteAetherUlidR1Bc&ByteAetherUlidR4Bcare configured to use a cryptographically secure random increment of 1 byte and 4 bytes, respectively, during monotonic ULID generation.ByteAetherUlidR1Bp&ByteAetherUlidR4Bpare configured to use a pseudo-random increment of 1 byte and 4 bytes, respectively, during monotonic ULID generation.ByteAetherUlidPis configured to use a pseudo-random source for the random component during non-monotonic ULID generation.
The following benchmarks were performed:
BenchmarkDotNet v0.15.8, Windows 10 (10.0.19044.7417/21H2/November2021Update)
AMD Ryzen 7 3700X 3.60GHz, 1 CPU, 12 logical and 6 physical cores
.NET SDK 10.0.301
[Host] : .NET 10.0.9 (10.0.9, 10.0.926.27113), X64 RyuJIT x86-64-v3
DefaultJob : .NET 10.0.9 (10.0.9, 10.0.926.27113), X64 RyuJIT x86-64-v3
Job=DefaultJob
| Type | Method | Mean | Error | Gen0 | Allocated |
|---------------- |------------------- |------------:|----------:|-------:|----------:|
| Generate | ByteAetherUlid | 41.0279 ns | 0.0968 ns | - | - |
| Generate | ByteAetherUlidR1Bp | 47.2891 ns | 0.1171 ns | - | - |
| Generate | ByteAetherUlidR4Bp | 51.9968 ns | 0.0910 ns | - | - |
| Generate | ByteAetherUlidR1Bc | 90.0880 ns | 0.1864 ns | - | - |
| Generate | ByteAetherUlidR4Bc | 96.0395 ns | 0.2255 ns | - | - |
| Generate | NetUlid *(1) | 158.6095 ns | 0.9265 ns | 0.0095 | 80 B |
| Generate | NUlid *(2) | 48.7143 ns | 0.0948 ns | - | - |
| GenerateNonMono | ByteAetherUlid | 90.1055 ns | 0.3176 ns | - | - |
| GenerateNonMono | ByteAetherUlidP | 41.1849 ns | 0.0736 ns | - | - |
| GenerateNonMono | Ulid *(3,4) | 39.2001 ns | 0.1008 ns | - | - |
| GenerateNonMono | NUlid | 93.0571 ns | 0.1699 ns | - | - |
| GenerateNonMono | Guid *(5) | 47.1776 ns | 0.0932 ns | - | - |
| GenerateNonMono | GuidV7 *(3,5) | 77.4890 ns | 0.2267 ns | - | - |
| FromByteArray | ByteAetherUlid | 0.8044 ns | 0.0070 ns | - | - |
| FromByteArray | NetUlid | 1.4115 ns | 0.0111 ns | - | - |
| FromByteArray | Ulid | 1.1808 ns | 0.0080 ns | - | - |
| FromByteArray | NUlid | 1.1515 ns | 0.0063 ns | - | - |
| FromByteArray | Guid | 1.0440 ns | 0.0059 ns | - | - |
| FromGuid | ByteAetherUlid | 0.8255 ns | 0.0057 ns | - | - |
| FromGuid | NetUlid | 2.0028 ns | 0.0373 ns | - | - |
| FromGuid | Ulid | 1.9495 ns | 0.0116 ns | - | - |
| FromGuid | NUlid | 0.9240 ns | 0.0183 ns | - | - |
| FromString | ByteAetherUlid | 14.4357 ns | 0.0392 ns | - | - |
| FromString | NetUlid | 27.2159 ns | 0.0690 ns | - | - |
| FromString | Ulid | 16.9972 ns | 0.0311 ns | - | - |
| FromString | NUlid | 53.9897 ns | 0.1649 ns | 0.0086 | 72 B |
| FromString | Guid | 21.8639 ns | 0.1228 ns | - | - |
| ToByteArray | ByteAetherUlid | 4.7470 ns | 0.1274 ns | 0.0048 | 40 B |
| ToByteArray | AsByteSpan *(6) | 0.7736 ns | 0.0051 ns | - | - |
| ToByteArray | NetUlid | 9.4871 ns | 0.1054 ns | 0.0048 | 40 B |
| ToByteArray | Ulid | 4.7189 ns | 0.1066 ns | 0.0048 | 40 B |
| ToByteArray | NUlid | 8.7597 ns | 0.1460 ns | 0.0048 | 40 B |
| ToGuid | ByteAetherUlid | 0.8170 ns | 0.0070 ns | - | - |
| ToGuid | NetUlid | 10.3155 ns | 0.0230 ns | - | - |
| ToGuid | Ulid | 0.9731 ns | 0.0074 ns | - | - |
| ToGuid | NUlid | 0.7636 ns | 0.0061 ns | - | - |
| ToString | ByteAetherUlid | 21.5166 ns | 0.3377 ns | 0.0095 | 80 B |
| ToString | NetUlid | 27.3102 ns | 0.3004 ns | 0.0095 | 80 B |
| ToString | Ulid | 23.4614 ns | 0.2211 ns | 0.0095 | 80 B |
| ToString | NUlid | 29.4123 ns | 0.2632 ns | 0.0095 | 80 B |
| ToString | Guid | 10.2546 ns | 0.2493 ns | 0.0115 | 96 B |
| CompareTo | ByteAetherUlid | 1.4082 ns | 0.0071 ns | - | - |
| CompareTo | NetUlid | 4.4499 ns | 0.0303 ns | - | - |
| CompareTo | Ulid | 6.6206 ns | 0.0352 ns | - | - |
| CompareTo | NUlid | 9.2860 ns | 0.0495 ns | - | - |
| CompareTo | Guid | 4.8326 ns | 0.0210 ns | - | - |
| Equals | ByteAetherUlid | 1.0720 ns | 0.0135 ns | - | - |
| Equals | NetUlid | 1.9610 ns | 0.0155 ns | - | - |
| Equals | Ulid | 1.0432 ns | 0.0052 ns | - | - |
| Equals | NUlid | 1.0552 ns | 0.0103 ns | - | - |
| Equals | Guid | 1.1030 ns | 0.0107 ns | - | - |
| GetHashCode | ByteAetherUlid | 0.9185 ns | 0.0053 ns | - | - |
| GetHashCode | NetUlid | 8.8601 ns | 0.0250 ns | - | - |
| GetHashCode | Ulid | 0.9362 ns | 0.0059 ns | - | - |
| GetHashCode | NUlid | 6.9396 ns | 0.0387 ns | - | - |
| GetHashCode | Guid | 0.9082 ns | 0.0049 ns | - | - |
Existing competitive libraries exhibit various deviations from the official ULID specification or present drawbacks:
NetUlid: Only supports monotonicity within a single thread.NUlid: Requires custom wrappers and state management for monotonic generation.Ulid&GuidV7: Do not implement monotonicity.Ulid: Utilizes a cryptographically non-secureXOR-Shiftfor random value generation, with only the initial seed being cryptographically secure.Guid&GuidV7: The Guid documentation explicitly states that its random component may not be generated using a cryptographically secure random number generator (RNG), and thatGuidvalues should not be used for cryptographic purposes.AsByteSpan: ByteAether.Ulid provides aAsByteSpan()method to read the underlying byte array as aReadOnlySpan<byte>.
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.

