If you are looking for a monotonously increasing, sortable ID, you might then want to consider ULID[0] which predates UUID v7/v8.
It is probably not the right choice if you require a cryptographically strong and unique ID.
[0] ulid/spec
If you really need monotonic, secure/unguessable IDs, there are options like CUID2 (github.com/paralleldrive/cuid2).
Older designs like ULIDs (ulid/spec) and Snowflake IDs (en.wikipedia.org) have similar properties to UUIDv7, and potentially some advantages w.r.t. length/encoding/runtime cost/etc.
But UUIDv7 has one key advantage none of those do: you can store them in a UUID-typed column or struct field without any lossy conversion. All of the UUID versions share a common size and serialization format, so it doesn't actually matter if you generate v1/4/7/etc.
Many language standard libraries can generate them interchangeably, and (for example) a Postgres table can store any of the versions in the same column type.
So yes, to the author's point(s): they are not magic, monotonic, un-guessable IDs and you shouldn't treat them as either "secure" or strictly ordered. That doesn't mean they aren't useful.
Native ASP.NET Core & CLI Support in ByteAether.Ulid v1.4.2
A production-grade identifier library requires tight integration across data persistence layers, API boundary definitions, and operational tooling. ByteAether.Ulid implements a high-performance, specification-compliant .NET type for the ULID standard, engineering strict monotonicity, throughput optimization, and deterministic ordering under high-concurrency workloads.
The ByteAether.Ulid v1.4.2 release extends the ecosystem beyond the core primitive. Alongside existing ORM integrations, the repository now provides two specialized packages targeting modern .NET architectures:
- ByteAether.Ulid.AspNetCore: Native route constraints, System.Text.Json converters, and OpenAPI schema generators.
- ByteAether.Ulid.Cli: A cross-platform global tool for generating, parsing, and inspecting ULIDs within shell scripts and operational pipelines.
Source-First ASP.NET Core Integration
ByteAether.Ulid.AspNetCore introduces native framework bindings for ASP.NET Core pipelines:
- Route Constraints (:ulid): Evaluates incoming 26-character Base32 route parameters at the routing layer before triggering controller instantiation or delegate execution.
- System.Text.Json Serialization: Direct JsonConverter<Ulid> and JsonConverter<Ulid?> implementations handling canonical string representations without intermediate allocations.
- OpenAPI Integration: Schema transformers injecting the string format with a ulid designator into both Microsoft.AspNetCore.OpenApi and Swashbuckle metadata models.
- Native AOT & Trim Compatibility: Fully compatible with source-generated JSON serializer contexts (JsonSerializerContext) required for Native AOT deployments.
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(new UserResponse(id))
);
app.Run();
public record UserResponse(Ulid Id);
By handling parsing and model binding at the routing boundary, the framework prevents malformed identifier requests from executing pipeline middleware or reaching application handlers.
Operational Tooling: CLI Generation and Inspection
ByteAether.Ulid.Cli provides shell-level operations for inspecting and generating ULIDs. Install the utility via the .NET SDK tool registry:
dotnet tool install -g ByteAether.Ulid.Cli
Scripting Capabilities
- Format Interoperability: Converts canonical Base32 strings into raw Hexadecimal or System.Guid byte layouts.
- Deterministic Timestamp Overrides: Accepts custom unix epoch timestamps to reproduce state or generate test fixtures.
- Structured JSON Output: Emits machine-readable breakdown payloads for ingestion by tools like jq or PowerShell automation.
# Generate sequential identifiers in batches
ulid --count 5
# Parse and inspect internal structure
ulid 01AN4Z07BY79KA1307SR9X4MV3
# Process standard input streams for log analysis
cat ulids.txt | ulid --json
---
*Read the complete technical deep dive and view benchmarks in the original article on our blog: byteaether.github.io
Expanding .NET ULID handling with native ASP.NET Core constraints, AOT-friendly JSON converters, and CLI tooling
Universally Unique Lexicographically Sortable Identifiers (ULIDs) are 128-bit compatible alternative identifiers to standard UUIDs/GUIDs. They combine a 48-bit Unix timestamp with 80 bits of randomness, formatted as a 26-character Base32 string. This structure enables lexicographical ordering, index-friendly database insertion, and human-readable timestamp inspection without database calls.
While core libraries handle primitive string/byte conversions, integrated application boundaries often require manual boilerplate (custom route attributes, JsonConverter implementations, or ad-hoc parsing scripts).
The ByteAether.Ulid library updated to v1.4.2, introducing two dedicated packages to handle these ecosystem concerns:
1. ByteAether.Ulid.AspNetCore:
- Adds an explicit :ulid route constraint for early pipeline validation.
- Direct System.Text.Json converters with Native AOT/trimming support.
- OpenAPI/Swashbuckle schema transformers for typed schema generation.
2. ByteAether.Ulid.Cli:
- Installed via dotnet tool install -g ByteAether.Ulid.Cli.
- CLI tool for parsing timestamps/randomness, converting byte layouts (System.Guid, Hex), handling pipeline stdin streams, and generating batch identifiers.
Usage example for Minimal API validation:
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(new UserResponse(id)));
app.Run();
public record UserResponse(Ulid Id);
For the complete technical breakdown and repository benchmarks, check out the full write-up: byteaether.github.io
(The code for this project is entirely hand-written. AI was only used to brainstorm/draft text and generate raw media assets, which I then manually polished.)
2 points
同じミリ秒に作った ULID 1000件は、並べ替えると生成順にならない
背景
自分のツールサイトに ULID をまとめて生成するページを置いています。
hashitosystem.com
ボタンを押すと、最大1000件を一気に作って1行ずつ並べる、というだけのツールです。
「ULID は並べ替えると時刻順になる」とよく聞くので、なんとなく「1000件も作った順に並ぶんだろう」と思い込んでいました。
実際に並べ替えてみたら、ぜんぜん違いました…。
仕様を読み直したら、ちゃんと書いてあったので整理しておきます。
ULID をざっくり
ULID は、時刻順に並べられるように設計された128ビットの識別子だ。
仕様は GitHub で公開されています。
ulid/spec
前半48ビットが UNIX 時刻(ミリ秒)、後半80ビットが乱数で、これを Crockford の Base32(I・L・O・U を除いた32文字)で26文字にしたものです。
時刻部が10文字、乱数部が16文字になる。
よく比べられる UUID(ランダムに作る128ビットの識別子で、v4 は全体がほぼ乱数)と違って、先頭が時刻なので文字列のまま辞書順に並べると時刻順になります。
データベースの主キーにしたとき、新しい行が末尾に寄るのが嬉しい、というのが ULID がよく使われる理由だと思います。
試したこと
ツールの生成処理を Node.js にそのまま移して、同じ Date.now() で1000件作り、sort() したら作った順と一致するかを数えました。
encodeTime が時刻部、encodeRandom が乱数部を作る関数で、ツール本体と同じ書き方です。
const crypto = require('node:crypto');
const ENC = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; // Crockford Base32
function encodeTime(t, len) {
let s = '';
for (let i = len - 1; i >= 0; i--) { const mod = t % 32; s = ENC[mod] + s; t = (t - mod) / 32; }
return s;
}
function encodeRandom(len) {
const bytes = crypto.randomBytes(len);
let s = '';
for (let i = 0; i < len; i++) s += ENC[bytes[i] % 32];
return s;
}
const ulid = (now) => encodeTime(now, 10) + encodeRandom(16);
const now = Date.now();
const arr = [];
for (let i = 0; i < 1000; i++) arr.push(ulid(now));
const sorted = [...arr].sort();
let same = 0;
for (let i = 0; i < arr.length; i++) if (arr[i] === sorted[i]) same++;
console.log('生成順のまま並んでいる位置:', same, '/ 1000');
console.log(arr.slice(0, 3));
Node.js v24 で実行した結果です。
生成順のまま並んでいる位置: 0 / 1000
[
'01M4HV3A1D4WZRFKBBQ62T7DND',
'01M4HV3A1DT3X119G0BFTENGED',
'01M4HV3A1DDM6E0P7B59A2FCSD'
]
時刻部の 01M4HV3A1D は全部同じで、その後ろの乱数部だけで並び順が決まっている。
乱数なので、作った順とはまったく関係のない順番になります(0件なのはたまたまで、毎回少しは変わるはずです)。
仕様にも、同じミリ秒の中では並び順は保証されない、とはっきり書かれていました。
「時刻順に並ぶ」はミリ秒の粒度まで、ということですね。
同じミリ秒でも順番を保つ(monotonic)
仕様には、これを解決するための monotonic(単調増加)という生成方法も書かれています。
同じミリ秒にもう一度作るときは、乱数を引き直さずに前回の乱数部に1を足す、というものだ。
乱数部が全部 Z で桁あふれするときは、生成を失敗させる(エラーにする)と決められています。
下記は、上のコードの encodeTime と encodeRandom をそのまま使った monotonic 版です。
前回の時刻と乱数部をクロージャに持っておき、時刻が同じなら乱数部の末尾から繰り上げながら1を足します。
function makeMonotonic() {
let lastTime = -1, lastRand = null;
return function (now) {
if (now === lastTime) {
const r = lastRand.split('');
let i = r.length - 1;
while (i >= 0) {
const v = ENC.indexOf(r[i]);
if (v < 31) { r[i] = ENC[v + 1]; break; }
r[i] = '0'; i--; // Z なら 0 にして上の桁へ繰り上げ
}
if (i < 0) throw new Error('random overflow');
lastRand = r.join('');
} else {
lastTime = now;
lastRand = encodeRandom(16);
}
return encodeTime(now, 10) + lastRand;
};
}
const mono = makeMonotonic();
const arr2 = [];
for (let i = 0; i < 1000; i++) arr2.push(mono(now));
const s2 = [...arr2].sort();
let same2 = 0;
for (let i = 0; i < 1000; i++) if (arr2[i] === s2[i]) same2++;
console.log('単調増加版:', same2, '/ 1000');
console.log(arr2.slice(0, 3));
結果は下記です。
単調増加版: 1000 / 1000
[
'01M4HV3A1DMTV4XHQGY5FHEMMZ',
'01M4HV3A1DMTV4XHQGY5FHEMN0',
'01M4HV3A1DMTV4XHQGY5FHEMN1'
]
末尾が MZ → N0 → N1 と1ずつ増えていて、Z の次で繰り上がっているのがわかると思います。
1000件すべてが作った順のまま並びました。
どちらを使うか
monotonic にすると、同じミリ秒の ULID は前の1件から推測できるようになります(乱数部が1違うだけなので)。
ID から次の ID を当てられたくない用途では、これは弱点です。
逆に、ログのように「同じミリ秒でも発生順に並んでほしい」用途では、monotonic でないと意味がありません。
私のツールはテストデータ用にまとめて作るのが主な使い道なので、いまは乱数を毎回引き直す方式のままにしています。
ちなみに乱数部は bytes[i] % 32 で文字を選んでいますが、256 は 32 で割り切れるので、この割り算で特定の文字が出やすくなる偏りは生じません。
結論
ULID を辞書順に並べて時刻順になるのはミリ秒単位までで、同じミリ秒に作った分は乱数部の大小で並ぶので、生成順は保たれません。
同じミリ秒の中でも順番を保ちたいなら、仕様の monotonic 方式どおり前回の乱数部に1を足し、桁あふれしたら生成を失敗させます。
推測されにくさが要るなら毎回引き直す、発生順が要るなら monotonic、と用途で選ぶのが正解です。
---
> 本記事はAI補助で執筆した、個人開発の紹介記事です。
Mentions
If you are looking for a monotonously increasing, sortable ID, you might then want to consider ULID[0] which predates UUID v7/v8. It is probably not the right choice if you require a cryptographically strong and unique ID. [0] ulid/spec
If you really need monotonic, secure/unguessable IDs, there are options like CUID2 (github.com/paralleldrive/cuid2). Older designs like ULIDs (ulid/spec) and Snowflake IDs (en.wikipedia.org) have similar properties to UUIDv7, and potentially some advantages w.r.t. length/encoding/runtime cost/etc. But UUIDv7 has one key advantage none of those do: you can store them in a UUID-typed column or struct field without any lossy conversion. All of the UUID versions share a common size and serialization format, so it doesn't actually matter if you generate v1/4/7/etc. Many language standard libraries can generate them interchangeably, and (for example) a Postgres table can store any of the versions in the same column type. So yes, to the author's point(s): they are not magic, monotonic, un-guessable IDs and you shouldn't treat them as either "secure" or strictly ordered. That doesn't mean they aren't useful.
Native ASP.NET Core & CLI Support in ByteAether.Ulid v1.4.2 A production-grade identifier library requires tight integration across data persistence layers, API boundary definitions, and operational tooling. ByteAether.Ulid implements a high-performance, specification-compliant .NET type for the ULID standard, engineering strict monotonicity, throughput optimization, and deterministic ordering under high-concurrency workloads. The ByteAether.Ulid v1.4.2 release extends the ecosystem beyond the core primitive. Alongside existing ORM integrations, the repository now provides two specialized packages targeting modern .NET architectures: - ByteAether.Ulid.AspNetCore: Native route constraints, System.Text.Json converters, and OpenAPI schema generators. - ByteAether.Ulid.Cli: A cross-platform global tool for generating, parsing, and inspecting ULIDs within shell scripts and operational pipelines. Source-First ASP.NET Core Integration ByteAether.Ulid.AspNetCore introduces native framework bindings for ASP.NET Core pipelines: - Route Constraints (:ulid): Evaluates incoming 26-character Base32 route parameters at the routing layer before triggering controller instantiation or delegate execution. - System.Text.Json Serialization: Direct JsonConverter<Ulid> and JsonConverter<Ulid?> implementations handling canonical string representations without intermediate allocations. - OpenAPI Integration: Schema transformers injecting the string format with a ulid designator into both Microsoft.AspNetCore.OpenApi and Swashbuckle metadata models. - Native AOT & Trim Compatibility: Fully compatible with source-generated JSON serializer contexts (JsonSerializerContext) required for Native AOT deployments. 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(new UserResponse(id)) ); app.Run(); public record UserResponse(Ulid Id); By handling parsing and model binding at the routing boundary, the framework prevents malformed identifier requests from executing pipeline middleware or reaching application handlers. Operational Tooling: CLI Generation and Inspection ByteAether.Ulid.Cli provides shell-level operations for inspecting and generating ULIDs. Install the utility via the .NET SDK tool registry: dotnet tool install -g ByteAether.Ulid.Cli Scripting Capabilities - Format Interoperability: Converts canonical Base32 strings into raw Hexadecimal or System.Guid byte layouts. - Deterministic Timestamp Overrides: Accepts custom unix epoch timestamps to reproduce state or generate test fixtures. - Structured JSON Output: Emits machine-readable breakdown payloads for ingestion by tools like jq or PowerShell automation. # Generate sequential identifiers in batches ulid --count 5 # Parse and inspect internal structure ulid 01AN4Z07BY79KA1307SR9X4MV3 # Process standard input streams for log analysis cat ulids.txt | ulid --json --- *Read the complete technical deep dive and view benchmarks in the original article on our blog: byteaether.github.io
Expanding .NET ULID handling with native ASP.NET Core constraints, AOT-friendly JSON converters, and CLI tooling Universally Unique Lexicographically Sortable Identifiers (ULIDs) are 128-bit compatible alternative identifiers to standard UUIDs/GUIDs. They combine a 48-bit Unix timestamp with 80 bits of randomness, formatted as a 26-character Base32 string. This structure enables lexicographical ordering, index-friendly database insertion, and human-readable timestamp inspection without database calls. While core libraries handle primitive string/byte conversions, integrated application boundaries often require manual boilerplate (custom route attributes, JsonConverter implementations, or ad-hoc parsing scripts). The ByteAether.Ulid library updated to v1.4.2, introducing two dedicated packages to handle these ecosystem concerns: 1. ByteAether.Ulid.AspNetCore: - Adds an explicit :ulid route constraint for early pipeline validation. - Direct System.Text.Json converters with Native AOT/trimming support. - OpenAPI/Swashbuckle schema transformers for typed schema generation. 2. ByteAether.Ulid.Cli: - Installed via dotnet tool install -g ByteAether.Ulid.Cli. - CLI tool for parsing timestamps/randomness, converting byte layouts (System.Guid, Hex), handling pipeline stdin streams, and generating batch identifiers. Usage example for Minimal API validation: 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(new UserResponse(id))); app.Run(); public record UserResponse(Ulid Id); For the complete technical breakdown and repository benchmarks, check out the full write-up: byteaether.github.io (The code for this project is entirely hand-written. AI was only used to brainstorm/draft text and generate raw media assets, which I then manually polished.)
同じミリ秒に作った ULID 1000件は、並べ替えると生成順にならない 背景 自分のツールサイトに ULID をまとめて生成するページを置いています。 hashitosystem.com ボタンを押すと、最大1000件を一気に作って1行ずつ並べる、というだけのツールです。 「ULID は並べ替えると時刻順になる」とよく聞くので、なんとなく「1000件も作った順に並ぶんだろう」と思い込んでいました。 実際に並べ替えてみたら、ぜんぜん違いました…。 仕様を読み直したら、ちゃんと書いてあったので整理しておきます。 ULID をざっくり ULID は、時刻順に並べられるように設計された128ビットの識別子だ。 仕様は GitHub で公開されています。 ulid/spec 前半48ビットが UNIX 時刻(ミリ秒)、後半80ビットが乱数で、これを Crockford の Base32(I・L・O・U を除いた32文字)で26文字にしたものです。 時刻部が10文字、乱数部が16文字になる。 よく比べられる UUID(ランダムに作る128ビットの識別子で、v4 は全体がほぼ乱数)と違って、先頭が時刻なので文字列のまま辞書順に並べると時刻順になります。 データベースの主キーにしたとき、新しい行が末尾に寄るのが嬉しい、というのが ULID がよく使われる理由だと思います。 試したこと ツールの生成処理を Node.js にそのまま移して、同じ Date.now() で1000件作り、sort() したら作った順と一致するかを数えました。 encodeTime が時刻部、encodeRandom が乱数部を作る関数で、ツール本体と同じ書き方です。 const crypto = require('node:crypto'); const ENC = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; // Crockford Base32 function encodeTime(t, len) { let s = ''; for (let i = len - 1; i >= 0; i--) { const mod = t % 32; s = ENC[mod] + s; t = (t - mod) / 32; } return s; } function encodeRandom(len) { const bytes = crypto.randomBytes(len); let s = ''; for (let i = 0; i < len; i++) s += ENC[bytes[i] % 32]; return s; } const ulid = (now) => encodeTime(now, 10) + encodeRandom(16); const now = Date.now(); const arr = []; for (let i = 0; i < 1000; i++) arr.push(ulid(now)); const sorted = [...arr].sort(); let same = 0; for (let i = 0; i < arr.length; i++) if (arr[i] === sorted[i]) same++; console.log('生成順のまま並んでいる位置:', same, '/ 1000'); console.log(arr.slice(0, 3)); Node.js v24 で実行した結果です。 生成順のまま並んでいる位置: 0 / 1000 [ '01M4HV3A1D4WZRFKBBQ62T7DND', '01M4HV3A1DT3X119G0BFTENGED', '01M4HV3A1DDM6E0P7B59A2FCSD' ] 時刻部の 01M4HV3A1D は全部同じで、その後ろの乱数部だけで並び順が決まっている。 乱数なので、作った順とはまったく関係のない順番になります(0件なのはたまたまで、毎回少しは変わるはずです)。 仕様にも、同じミリ秒の中では並び順は保証されない、とはっきり書かれていました。 「時刻順に並ぶ」はミリ秒の粒度まで、ということですね。 同じミリ秒でも順番を保つ(monotonic) 仕様には、これを解決するための monotonic(単調増加)という生成方法も書かれています。 同じミリ秒にもう一度作るときは、乱数を引き直さずに前回の乱数部に1を足す、というものだ。 乱数部が全部 Z で桁あふれするときは、生成を失敗させる(エラーにする)と決められています。 下記は、上のコードの encodeTime と encodeRandom をそのまま使った monotonic 版です。 前回の時刻と乱数部をクロージャに持っておき、時刻が同じなら乱数部の末尾から繰り上げながら1を足します。 function makeMonotonic() { let lastTime = -1, lastRand = null; return function (now) { if (now === lastTime) { const r = lastRand.split(''); let i = r.length - 1; while (i >= 0) { const v = ENC.indexOf(r[i]); if (v < 31) { r[i] = ENC[v + 1]; break; } r[i] = '0'; i--; // Z なら 0 にして上の桁へ繰り上げ } if (i < 0) throw new Error('random overflow'); lastRand = r.join(''); } else { lastTime = now; lastRand = encodeRandom(16); } return encodeTime(now, 10) + lastRand; }; } const mono = makeMonotonic(); const arr2 = []; for (let i = 0; i < 1000; i++) arr2.push(mono(now)); const s2 = [...arr2].sort(); let same2 = 0; for (let i = 0; i < 1000; i++) if (arr2[i] === s2[i]) same2++; console.log('単調増加版:', same2, '/ 1000'); console.log(arr2.slice(0, 3)); 結果は下記です。 単調増加版: 1000 / 1000 [ '01M4HV3A1DMTV4XHQGY5FHEMMZ', '01M4HV3A1DMTV4XHQGY5FHEMN0', '01M4HV3A1DMTV4XHQGY5FHEMN1' ] 末尾が MZ → N0 → N1 と1ずつ増えていて、Z の次で繰り上がっているのがわかると思います。 1000件すべてが作った順のまま並びました。 どちらを使うか monotonic にすると、同じミリ秒の ULID は前の1件から推測できるようになります(乱数部が1違うだけなので)。 ID から次の ID を当てられたくない用途では、これは弱点です。 逆に、ログのように「同じミリ秒でも発生順に並んでほしい」用途では、monotonic でないと意味がありません。 私のツールはテストデータ用にまとめて作るのが主な使い道なので、いまは乱数を毎回引き直す方式のままにしています。 ちなみに乱数部は bytes[i] % 32 で文字を選んでいますが、256 は 32 で割り切れるので、この割り算で特定の文字が出やすくなる偏りは生じません。 結論 ULID を辞書順に並べて時刻順になるのはミリ秒単位までで、同じミリ秒に作った分は乱数部の大小で並ぶので、生成順は保たれません。 同じミリ秒の中でも順番を保ちたいなら、仕様の monotonic 方式どおり前回の乱数部に1を足し、桁あふれしたら生成を失敗させます。 推測されにくさが要るなら毎回引き直す、発生順が要るなら monotonic、と用途で選ぶのが正解です。 --- > 本記事はAI補助で執筆した、個人開発の紹介記事です。