ULID と UUIDv7 の違い
128bit の時系列ソート可能 ID として ULID と UUIDv7 の二つがある。どちらも「単調増加するランダム ID」という同じ問題を解いているが、設計思想・標準化のレベル・エコシステムが違う。本記事では両者の違いを整理し、Python / TypeScript / Rust それぞれの最新ライブラリ事情、そして Django で主キーに採用するときの具体的な書き方までまとめる。
TL;DR
- 128bit、先頭48bit に Unix time (ms) という根幹は両者共通。文字列が時系列順にソート可能。
- UUIDv7 は IETF 標準 (RFC 9562, 2024年5月公開)。ULID は GitHub 上の単独仕様 (2016年〜)。標準化のレベルで明確に差がついた。
- Python 3.14 (2025年10月リリース) で
uuid.uuid7()が標準ライブラリ入り。PostgreSQL 18 (2025年9月25日リリース) でuuidv7()関数がネイティブ提供。Django 5.2 + Python 3.14 + Postgres 18 という組み合わせで、外部ライブラリなしに UUIDv7 主キーが書けるようになった。 - 私の結論: ユーザーに紐づく永続データの主キーは UUIDv7。文字列を見たときに「ID とわかる」「v7 とわかる」可読性が ULID の26文字 Base32 文字列に勝る。ULID は内部識別子・URL に出すスラッグ用途のほうが向いている。
1. UUIDv4 と ULID / UUIDv7 の大きな違いは「ソート可能性」
まず、ULID と UUIDv7 の違いの前に、 UUIDv4 と ULID / UUIDv7 の違いについて。
UUIDv4 は 128bit のうち version/variant の 6bit を除いた 122bit を完全ランダムで埋める。
- 分散環境で調整なしに一意な ID を生成できる → これは v7/ULID も同じ。
- 時系列情報を一切含まない → 生成順とソート順が無関係。
- セキュアなトークン用途には別物 (
secrets.token_urlsafe()など) を使うべきで、UUIDv4 は本来「衝突しない ID 」のためのもの。
「分散一意性は欲しいが、生成順でソートも効かせたい」というニーズに応えるのが ULID と UUIDv7 の共通動機。逆に「ソート不要、ランダム性こそ欲しい」(例: 推測されたくない公開トークンの代用、ランダム配置のシャーディングキー) なら UUIDv4 のほうが適切な場面もある。
両者とも:
| 共通仕様 | 内容 |
|---|---|
| 全体長 | 128bit (16 bytes) |
| 先頭フィールド | 48bit Unix timestamp (ミリ秒精度) |
| 残り 80bit | ランダム + (実装による) 単調性保証 |
| バイナリ表現 | 同じ 16 バイト、相互変換可能 |
つまり ストレージ的には完全に等価 で、違いはエンコード形式・標準化・周辺エコシステムにある。
2. 歴史的背景
ULID (2016年)
- 作者: Alizain Feerasta。
- リポジトリ:
ulid/spec - 動機: UUIDv4 の「文字列が長い・ソート不可」という不満への対処。
- 仕様: 128bit を Crockford Base32 で 26 文字にエンコード。
- 標準化: IETF 標準ではなく、GitHub 上の単独仕様。後から「ULID Spec v1」として整理されているが、RFC ではない。
- 各種言語で40以上の実装がある (Go の oklog/ulid が事実上のリファレンス実装)。
UUIDv6, v7, v8 (2024年5月)
- RFC 9562 として IETF が標準化。RFC 4122 の改訂版。
- v6: 既存の v1 (MAC + 時刻) を時系列ソート可能に並べ替えたもの。後方互換のための救済策。
- v7: ULID 的なアプローチを UUID フォーマットに取り込んだもの。これが事実上のメインプレイヤー。
- v8: 完全カスタム実装用の枠 (アプリ固有のビット配置を許容)。
UUIDv7 は ULID の問題提起を IETF が標準として吸収した形になっている。歴史的には ULID が先行し、その実績を踏まえて UUIDv7 が後発で標準化された。
3. ビット配置の差分
ULID
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 32_bit_uint_time_high |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 16_bit_uint_time_low | 16_bit_uint_random |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 32_bit_uint_random |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 32_bit_uint_random |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
- 先頭 48bit: Unix time (ms)
- 残り 80bit: 全部ランダム
- 単調性: 同じ ms 内で複数生成されたら ランダム部の最下位ビットを +1 して保証する (monotonic mode)。これは仕様の MUST ではなく実装の責務。
UUIDv7 (RFC 9562)
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| unix_ts_ms |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| unix_ts_ms | ver | rand_a |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|var| rand_b |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| rand_b |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
- 先頭 48bit: Unix time (ms)
- 4bit: version (固定で
0111= 7) - 12bit:
rand_a(RFC 推奨では sub-ms カウンタやランダムに使用) - 2bit: variant (
10) - 62bit:
rand_b
重要な差
UUIDv7 は version/variant ビットを必ず固定で持つ。ランダムに使えるのは合計 74bit。ULID は完全に 80bit 全部ランダムに使える。衝突確率は ULID のほうがわずかに低いが、74bit でも十分に実用的なので実害はない。
サブミリ秒の単調性 (RFC 9562 の Method 1, Method 2, Method 3)
RFC 9562 はサブミリ秒の単調性保証について複数の方式を許容している:
- Method 1 (Fixed-Length Dedicated Counter Bits):
rand_aの一部 (またはrand_bの上位ビット) を固定長のカウンタとして専有する。同じ ms 内ではカウンタを +1 し、新しい ms に入ったらリセット。 - Method 2 (Monotonic Random): 同じ ms 内では前回生成値のランダム部を「ランダムにシードされたカウンタ」とみなして +1 する。固定カウンタ領域を作らず、ランダム領域全体を単調増加に使う。
- Method 3 (Replace Leftmost Random Bits with Increased Clock Precision):
rand_aの 12bit にミリ秒をさらに分割した sub-ms 精度の小数 (1/4096ms 単位) を入れる。PostgreSQL 18 はこの Method 3 採用。
→ 実装ライブラリによってどの Method を使うかが違うので、「同じセッション内で生成順が UUIDv7 文字列のソート順と一致するか」は実装依存。
4. 文字列表現の差
ULID
01ARZ3NDEKTSV4RRFFQ69G5FAV
- Crockford Base32 (5bit / 文字 × 26 文字 = 130bit、先頭文字の上位 2bit が常に 0 になる制約で実質 128bit。先頭文字は
0–7のみ) - 大文字のみ。
ILOUを除外 (誤読・卑語回避) - ハイフンなし、26文字
- ぱっと見は 「ランダムなトークン」っぽい
UUIDv7
0190b3a1-7c4b-7abc-89de-f0123456789a
^
先頭が 7 → version 7
- 16進、ハイフン区切り、36 文字 (
8-4-4-4-12) - 3 グループ目の先頭 (ハイフン込み 15 文字目 / hex 13 文字目) が
7で固定 → 目視で「これ UUIDv7 だ」とわかる - 4 グループ目の先頭 (ハイフン込み 20 文字目 / hex 17 文字目) が
8/9/a/bのいずれか → variant10
私の主観的な使い勝手評価
UUIDv7 のほうが「人間が見たときに ID だとわかる」。0190b3a1-7c4b-7abc-... を見ればハイフンの区切り方で誰しも UUID と認識できるし、3 グループ目先頭の 7 で v7 と即座にわかる。これが API レスポンスや管理画面の URL に出ていても違和感がない。
ULID の 01ARZ3NDEKTSV4RRFFQ69G5FAV は、JWT やランダムトークンと 目視で区別がつかない。サーバーレスポンスにこれが入っていると「これ機密トークン入っちゃってない?」と一瞬警戒する。慣れの問題ではあるが、コードレビューで他人のコードを読むときの認知負荷が違う。
→ ユーザーに紐づくデータベースの主キーには UUIDv7 を採用する、というのが私の方針。
5. 各言語での使い方
Python
UUIDv7
Python 3.14+ (2025年10月リリース)
標準ライブラリ uuid モジュールに uuid6, uuid7, uuid8 が追加された。
import uuid
new_id = uuid.uuid7()
print(new_id)
# UUID('0190b3a1-7c4b-7abc-89de-f0123456789a')
# 上位 48bit を Unix 時刻 (ms) として取り出す
ts_ms = (new_id.int >> 80) & 0xFFFFFFFFFFFF
Python 3.13 以前
標準ライブラリにないので uuid6 パッケージを使う。
pip install uuid6
from uuid6 import uuid7
new_id = uuid7()
ULID
標準ライブラリにはない。python-ulid が事実上のスタンダード。
pip install python-ulid
from ulid import ULID
new_id = ULID()
print(str(new_id)) # '01ARZ3NDEKTSV4RRFFQ69G5FAV'
print(new_id.timestamp) # datetime
print(new_id.to_uuid()) # 同じ 128bit を UUID として表現
python-ulid は ULID と UUID の相互変換 API を持っている。バイト列としては等価なので変換コスト 0。
TypeScript / JavaScript
UUIDv7
npm install uuid # v11+ で TypeScript ネイティブ対応・v7 サポート
import { v7 as uuidv7 } from 'uuid';
const id = uuidv7();
// '0190b3a1-7c4b-7abc-89de-f0123456789a'
uuid パッケージ v11 以降は完全 TypeScript 化されており、v7 がエクスポートされる。
高機能版が必要なら uuidv7 パッケージ (LiosK 製) もある。これは Method 2 (Monotonic Random) の参照実装で、生成順の単調性が厳密。
npm install uuidv7
import { uuidv7 } from 'uuidv7';
const id = uuidv7();
ULID
ulid パッケージ。
npm install ulid
import { ulid, monotonicFactory } from 'ulid';
ulid(); // '01ARZ3NDEKTSV4RRFFQ69G5FAV'
// 単調性を保証したい場合
const monotonic = monotonicFactory();
monotonic();
monotonic(); // 必ず前回より大きい
ブラウザ / Node.js / Deno / Bun 全部で動く。
Rust
UUIDv7
uuid クレートが本家対応済み。v7 feature を有効化する。
# Cargo.toml
[dependencies]
uuid = { version = "1", features = ["v7"] }
use uuid::Uuid;
let id = Uuid::now_v7();
println!("{}", id);
// 0190b3a1-7c4b-7abc-89de-f0123456789a
Uuid::new_v7(timestamp) で任意のタイムスタンプを指定することもできる。
ULID
ulid クレート。
[dependencies]
ulid = "1"
use ulid::Ulid;
let id = Ulid::new();
println!("{}", id);
// 01ARZ3NDEKTSV4RRFFQ69G5FAV
// UUID 互換変換
let as_uuid: uuid::Uuid = id.into();
ulid クレートは内部表現が u128 なのでコピー・比較が高速。Into<Uuid> も実装されている。
6. Django でプライマリキーに採用する方法
6.1 既存パターン (uuid6 ライブラリ + UUIDField)
私が運用している複数の Django プロジェクトでは、Python 3.14 リリース前に立ち上がった経緯から、PyPI の uuid6 パッケージを使っている。
# pyproject.toml
dependencies = [
"uuid6>=2025.0.1",
]
# models.py
from django.db import models
from uuid6 import uuid7
class User(AbstractUser):
id = models.UUIDField(primary_key=True, default=uuid7, editable=False)
email = models.EmailField(unique=True)
class LoginCode(models.Model):
id = models.UUIDField(primary_key=True, default=uuid7, editable=False)
email = models.EmailField()
code = models.CharField(max_length=8)
ポイント
default=uuid7のように 関数自体を渡す (関数呼び出しuuid7()ではない)。Django が INSERT 時に毎回呼んで新しい UUID を生成する。editable=Falseで Django Admin の編集フォームから外す。models.UUIDFieldは Postgres ではuuid型、SQLite ではchar(32)にマッピングされる。
6.2 新しいパターン (Python 3.14 標準ライブラリ)
Python 3.14 以降のプロジェクトなら、依存ライブラリすら不要。
import uuid
from django.db import models
class Item(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid7, editable=False)
uuid6 パッケージへの依存を削れる。新規プロジェクトはこれが推奨。
6.3 PostgreSQL 18 + Django 5.2 ネイティブパターン (最も新しい)
PostgreSQL 18 (2025-09-25 リリース) は uuidv7() 関数を組み込みで持つ。Django 5.2 の db_default を使うと、アプリケーションではなく DB 側で UUID を生成 できる。
from django.db import models
class UUIDv7(models.Func):
function = "uuidv7"
output_field = models.UUIDField()
class UUIDExtractTimestamp(models.Func):
function = "uuid_extract_timestamp"
output_field = models.DateTimeField()
class Record(models.Model):
uuid = models.UUIDField(db_default=UUIDv7(), primary_key=True)
creation_time = models.GeneratedField(
expression=UUIDExtractTimestamp("uuid"),
output_field=models.DateTimeField(),
db_persist=True,
)
生成される DDL
CREATE TABLE "items_record" (
"uuid" uuid DEFAULT (uuidv7()) NOT NULL PRIMARY KEY,
"creation_time" timestamp GENERATED ALWAYS AS
(uuid_extract_timestamp("uuid")) STORED
);
メリット
- 複数アプリサーバーが同時に書いても DB が一元的に発番 する → クロックドリフトの影響を受けない。
creation_timeは UUID から自動抽出するので、別途タイムスタンプカラムを INSERT に含める必要がない。- PostgreSQL 18 の Method 3 (sub-ms fraction) で同一セッション内の単調性が強く保証される。
6.4 どれを選ぶか
| 状況 | 推奨パターン |
|---|---|
| 既存プロジェクト (Python ≤ 3.13) | uuid6 パッケージ + default=uuid7 |
| 新規プロジェクト (Python 3.14+) | 標準ライブラリ uuid.uuid7 + default |
| Postgres 18 + Django 5.2 確定 | db_default=UUIDv7() でネイティブ生成 |
| SQLite/MySQL 主体 | アプリ側生成 (default=uuid.uuid7) |
私の現状は 6.1 のパターンが主力。新規プロジェクトからは 6.3 を採用していく予定。
7. ULID と UUIDv7 の弱点比較
ULID の弱点
- IETF 標準ではない。実装ごとに「ms 越え時の clock 戻り処理」「monotonic mode の挙動」が微妙に違う。Python の
python-ulidと JavaScript のulidで挙動が一致するとは限らない。 - PostgreSQL 18 のようなネイティブ DB サポートがない。
UUID型カラムに変換して入れるか、BINARY(16)/BYTEAで持つかの選択。後者はWHERE id = '01ARZ...'と書けず、bytea_to_ulid()的な変換関数を作る羽目になる。 - 文字列が一見ただのトークンに見える。コードレビューで「これ機密じゃない?」と毎回確認される。
UUIDv7 の弱点
- Hex 文字列にすると長い (36文字)。URL に直接出すと不格好。slug用途なら ULID か Base62 短縮の方が良い。
- UUIDv4 と目視で区別しづらい。version ビットを見れば確実だが、慣れていないと「ハイフン込み 36 文字」=「v4」と誤解されがち。
- RFC 9562 の Method 選択がライブラリ依存。アプリ A が Method 1、アプリ B が Method 2 のときに、同一 ms 内のソート順が一致しない可能性がある。「UUIDv7 だから単調」ではなく「実装が単調保証している UUIDv7 だから単調」。
- 74bit ランダム (ULID は 80bit) なので衝突確率は理論上わずかに高い。実用上問題にはならないが。
8. 参考リンク
- RFC 9562: Universally Unique IDentifiers (UUIDs)
- ULID Specification (ulid/spec)
- Python 3.14 uuid module documentation
- PostgreSQL 18 Release Notes
- How to use UUIDv7 in Python, Django and PostgreSQL — Paolo Melchiorre
- UUIDv7 Comes to PostgreSQL 18 — Nile
- npm: uuid / npm: uuidv7 / npm: ulid
- crates.io: uuid / crates.io: ulid
- PyPI: uuid6 / PyPI: python-ulid
開発相談をお待ちしています。