UUIDv4・UUIDv7・ULIDはどれも128ビットの識別子で、違いは「先頭に時刻が入るか(時刻順に並ぶか)」と「文字表現」です。 UUIDv4は全体がランダム、UUIDv7とULIDは先頭48ビットがミリ秒の時刻なので生成順に並び、B-treeインデックスの末尾に追記されるため主キーに向きます。UUIDv7は2024年5月にRFC 9562として正式に標準化され、Python 3.14の uuid.uuid7() やPostgreSQL 18の uuidv7() など標準ライブラリ・DBへの組み込みも進んでいます。

この記事では、RFC 9562・ULID仕様・各DBの公式ドキュメントで仕様を確認したうえで、PostgreSQL 16とMySQL 8.0に連番・UUIDv4・UUIDv7・ULIDを各100万件投入し、所要時間、テーブルとインデックスのサイズ、B-treeの詰まり具合、ランダム検索の実行計画を実測しました。「文字列で保存するとどれだけ増えるか」「ULIDの単調性の有無で何が変わるか」といった、概念説明だけでは見えない数字も載せています。選び方のフロー、DBごとの型の早見表、Python・Node・Java・PostgreSQL・MySQLの生成コード(すべて実行済み)も用意しました。

UUID・ULID・UUIDv7の早見表

項目 連番(BIGINT) UUIDv4 UUIDv7 ULID Snowflake風
規格 DBの採番機能 RFC 9562 §5.4 RFC 9562 §5.7(2024年5月) ulid/spec(コミュニティ仕様) X(旧Twitter)発の方式。実装ごとに独自
ビット構成 64bit 整数 122bit 乱数 + ver 4bit + var 2bit 時刻 48bit + ver 4bit + rand_a 12bit + var 2bit + rand_b 62bit 時刻 48bit + 乱数 80bit 符号 1bit + 時刻 41bit + マシンID 10bit + 連番 12bit(元の設計)
サイズ 8バイト 16バイト 16バイト 16バイト 8バイト
文字表現 10進 最大19桁 16進 36文字(ハイフン込み) 16進 36文字 Crockford Base32 26文字(大文字) 10進 最大19桁
時刻順ソート できる(採番順) できない できる(ミリ秒。同一ミリ秒内は実装次第) できる(ミリ秒。同一ミリ秒内はmonotonicモードで保証) できる
生成場所 DB アプリ・DBどちらでも アプリ・DBどちらでも 主にアプリ 採番サーバーまたはマシンIDを配ったアプリ
衝突 起きない 乱数122bit(実用上無視できる) 同一ミリ秒内で乱数74bit 同一ミリ秒内で乱数80bit マシンIDが重複しなければ起きない
推測されやすさ 前後のIDが推測できる されない 時刻が読み取れる(乱数部は推測不可) 時刻が読み取れる 時刻・マシンIDが読み取れる
PostgreSQL の型 bigint + GENERATED AS IDENTITY uuid uuid uuid(16バイト)または char(26) bigint
MySQL の型 BIGINT AUTO_INCREMENT BINARY(16) BINARY(16) BINARY(16) または CHAR(26) CHARACTER SET ascii BIGINT UNSIGNED
向く場面 単一DBで完結し、IDを外に見せない 推測不可能なトークン、公開ID 分散生成が必要で、主キーの性能も欲しい 短い文字列でURLやログに載せたい 超大量・64bitに収めたい

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「UUID・ULID・UUIDv7の違い|主キー性能を100万件で実測比較」

RFC 9562で決まったUUIDv7の中身

UUIDv7は「先頭48ビットがUnixエポックからのミリ秒、残りが乱数(またはカウンタ)」という構成で、RFC 9562(2024年5月)がRFC 4122を置き換える形で定義しました。 上位2ページの記事が書かれた時点では「ドラフト」「正式採用前」でしたが、現在は正式な標準です。

フィールド ビット数 内容
unix_ts_ms 48 Unixエポックからのミリ秒(西暦10889年ごろまで表現可能)
ver 4 バージョン。v7 は 0111
rand_a 12 乱数、またはサブミリ秒の精度・カウンタに使ってよい
var 2 バリアント 10
rand_b 62 乱数、またはカウンタ

同一ミリ秒内の順序(単調性)をどう保つかは実装に任されています。 RFC 9562 §6.2は「専用カウンタを置く」「乱数を1ずつ増やす」「rand_aにサブミリ秒を入れる」の3方式を挙げており、たとえばPython 3.14の uuid.uuid7() は42ビットのカウンタで同一ミリ秒内の順序を保証します。後述の実測で、この「同一ミリ秒内の順序があるかどうか」がインデックスの詰まり具合に効くことを確認しています。

ULIDは同じ「時刻48ビット + 乱数80ビット」の16バイトですが、バージョンやバリアントのビットを持たず、Crockford Base32の26文字で表します。 読み間違えやすい I・L・O・U を除いた32文字を使い、仕様上は大文字小文字を区別しません(ただしDBやライブラリは区別することがあります。後述)。16バイトのバイナリはUUID型の列にそのまま入ります。

100万件で実測:PostgreSQL 16

測定方法

Python 3.14で各IDを100万件事前生成し、CSVをCOPYで投入しました。 PostgreSQL 16には uuidv7() がない(PostgreSQL 18で追加)ため、v7は uuid.uuid7()、ULIDは仕様どおり「48ビットのミリ秒 + 80ビットの乱数」を自作関数で生成しています。ULIDは「単調性なし(同一ミリ秒内は毎回乱数)」と「monotonicモード(同一ミリ秒内は直前の乱数+1)」の2種類を用意しました。

# gen_ids.py(抜粋)Python 3.14.7
import os, time, uuid
CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"

def ulid_bytes() -> bytes:                       # 48bit ms + 80bit random
    ms = time.time_ns() // 1_000_000
    return ms.to_bytes(6, "big") + os.urandom(10)

def ulid_encode(b: bytes) -> str:                # 16 bytes -> Crockford Base32 26 chars
    n = int.from_bytes(b, "big"); out = []
    for _ in range(26):
        out.append(CROCKFORD[n & 31]); n >>= 5
    return "".join(reversed(out))

N = 1_000_000
v4 = [str(uuid.uuid4()) for _ in range(N)]
v7 = [str(uuid.uuid7()) for _ in range(N)]
ul = [ulid_encode(ulid_bytes()) for _ in range(N)]
print(v4[0], v7[0], ul[0])
68d545e0-20f5-4c31-9c9d-d7d89b6d94fd 01a0f2d6-09ad-7135-878e-768b1aeab0dd 01M3SDC583T96N0CJ2PYVFTZH8

テーブルは主キーと短い文字列列だけの最小構成です。

-- PostgreSQL 16.13
CREATE TABLE pk_bigint      (id bigint   PRIMARY KEY, note text);
CREATE TABLE pk_uuidv4      (id uuid     PRIMARY KEY, note text);
CREATE TABLE pk_uuidv7      (id uuid     PRIMARY KEY, note text);
CREATE TABLE pk_ulid_uuid   (id uuid     PRIMARY KEY, note text);   -- ULID を 16 バイトのまま
CREATE TABLE pk_ulid_char   (id char(26) PRIMARY KEY, note text);   -- ULID を文字列で
CREATE TABLE pk_uuidv4_char (id char(36) PRIMARY KEY, note text);   -- UUID を文字列で(比較用)

\timing on
COPY pk_bigint FROM '/tmp/t50data/bigint.csv' (FORMAT csv);
COPY pk_uuidv4 FROM '/tmp/t50data/uuidv4.csv' (FORMAT csv);
-- ... 同様に各テーブルへ

COPY 100万件の所要時間とサイズ

時刻順のキー(連番・UUIDv7・monotonicなULID)は、ランダムなUUIDv4より投入が速く、インデックスも2〜3割小さくなりました。 サイズは pg_relation_size / pg_indexes_size、B-treeのリーフページの詰まり具合は pgstattuple 拡張の pgstatindex() で確認しています(所要時間は3回測定の中央値。共有環境のため1〜2割の揺れがあります)。

主キー 型 COPY 100万件 テーブル 主キーindex 合計 リーフ密度
連番 bigint 1.19 秒 50 MB 21 MB 71 MB 90.1%
UUIDv4 uuid 3.38 秒 58 MB 38 MB 96 MB 71.1%
UUIDv7 uuid 1.53 秒 58 MB 30 MB 88 MB 90.0%
ULID(monotonic) uuid 1.78 秒 58 MB 30 MB 88 MB 90.0%
ULID(単調性なし) uuid 2.18 秒 58 MB 39 MB 97 MB 68.8%
ULID(単調性なし) char(26) 2.39 秒 73 MB 61 MB 134 MB 70.0%
UUIDv4 char(36) 4.28 秒 81 MB 74 MB 154 MB 69.1%
     index_name      | leaf_pages | avg_leaf_density_pct
---------------------+------------+----------------------
 pk_bigint_pkey      |       2733 |                 90.1
 pk_uuidv4_pkey      |       4860 |                 71.1
 pk_uuidv7_pkey      |       3832 |                 90.0
 pk_ulid_char_pkey   |       7782 |                 70.0
 pk_ulid_uuid_pkey   |       5022 |                 68.8
 pk_uuidv4_char_pkey |       9324 |                 69.1

読み取れることは3つです。

  • ランダムなキーはリーフページが約70%しか埋まらず、ページ数が1.3倍になります。 B-treeの右端に追記される時刻順キーは90%(PostgreSQLの既定fillfactor)まで詰まり、ランダム挿入は途中のページ分割を繰り返すためです。
  • ULIDでも「同一ミリ秒内の順序」がないと、UUIDv4と同じ詰まり方になります。 今回は100万件を約2秒で生成したので1ミリ秒に数百件が入り、その中の並びが乱数だったためです。1ミリ秒に1件程度の通常のINSERTなら差は出ませんが、まとめて採番する処理があるならmonotonicモード(同一ミリ秒内は+1)のライブラリを選ぶと安心です。
  • 文字列で保存すると、同じ内容でも合計サイズが1.5倍以上になります。 uuid 型16バイトに対し、char(36) は36バイト + 長さヘッダ。インデックスは74 MB対38 MBでほぼ2倍です。

アプリからのINSERTに近い形(1,000件ずつのマルチロー INSERT)

COPYではなく1,000行ずつの INSERT ... VALUES (...),(...) を1,000回(合計100万件、自動コミット)で流しても傾向は同じでした。

主キー 型 100万件の合計 主キーindex
連番 bigint 5.5 秒 21 MB
UUIDv4 uuid 10.0 秒 38 MB
UUIDv7 uuid 6.0 秒 30 MB
ULID(monotonic) uuid 4.8 秒 30 MB
ULID(monotonic) char(26) 5.4 秒 47 MB

ランダム検索の実行計画

主キー1件の検索は、どの型でもインデックス1回の探索で、メモリに載っている規模なら差はごくわずかです。 ランダムに選んだ10,000件を結合で引く(Nested Loopを強制して10,000回の索引検索にする)と次のようになりました。

SET enable_hashjoin = off; SET enable_mergejoin = off;
CREATE TEMP TABLE s_uuidv4 AS SELECT id FROM pk_uuidv4 ORDER BY random() LIMIT 10000;
EXPLAIN (ANALYZE, BUFFERS, SUMMARY OFF, TIMING OFF)
SELECT sum(length(t.note)) FROM s_uuidv4 s JOIN pk_uuidv4 t ON t.id = s.id;
 Aggregate  (cost=47999.00..47999.01 rows=1 width=8) (actual rows=1 loops=1)
   Buffers: shared hit=30782 read=9218 written=5, local hit=64
   ->  Nested Loop  (cost=0.42..47949.00 rows=10000 width=14) (actual rows=10000 loops=1)
         ->  Seq Scan on s_uuidv4 s  (cost=0.00..164.00 rows=10000 width=16) (actual rows=10000 loops=1)
         ->  Index Scan using pk_uuidv4_pkey on pk_uuidv4 t  (cost=0.42..4.78 rows=1 width=30) (actual rows=1 loops=10000)
               Index Cond: (id = s.id)
主キー 10,000件のランダム検索 1件あたり
連番 bigint 50 ms 5 µs
UUIDv4 uuid 57 ms 5.7 µs
UUIDv7 uuid 57 ms 5.7 µs
ULID char(26) 74 ms 7.4 µs

検索で差が出るのは、インデックスがメモリに収まらなくなってからです。 UUIDv4は参照が全ページに散るためキャッシュヒット率が下がりやすく、時刻順キーは「最近のデータ」がまとまるので実際のアクセスパターンと相性がよくなります。

時刻順キーなら、主キーだけで「新しい順」が取れます。 ORDER BY id DESC LIMIT 100 は Index Scan Backward で4ページしか読みません。

EXPLAIN (ANALYZE, BUFFERS) SELECT id, note FROM pk_uuidv7 ORDER BY id DESC LIMIT 100;
 Limit  (cost=0.42..4.20 rows=100 width=30) (actual time=0.014..0.032 rows=100 loops=1)
   Buffers: shared hit=2 read=2
   ->  Index Scan Backward using pk_uuidv7_pkey on pk_uuidv7  (actual time=0.014..0.023 rows=100 loops=1)
 Execution Time: 0.041 ms
-- UUIDv7: 生成順の末尾が返る            -- UUIDv4: 並びに意味がない
 01a0f2d6-1503-7532-... | row-001000000    fffff7bc-3d1b-4da5-... | row-000784285
 01a0f2d6-1503-7532-... | row-000999999    ffffe190-86c4-4f03-... | row-000343097

100万件で実測:MySQL 8.0

測定方法とBINARY(16)

MySQL(InnoDB)は主キー順にデータそのものが並ぶ「クラスタ化インデックス」なので、ランダムな主キーの影響がPostgreSQLより大きく出ます。 UUIDは BINARY(16) に UUID_TO_BIN() で変換して入れるのが基本です。ULIDも16バイトなら BINARY(16)、文字列なら CHAR(26) CHARACTER SET ascii にします。

-- MySQL 8.0.46
CREATE TABLE pk_bigint      (id BIGINT     PRIMARY KEY, note VARCHAR(20));
CREATE TABLE pk_uuidv4      (id BINARY(16) PRIMARY KEY, note VARCHAR(20));
CREATE TABLE pk_uuidv7      (id BINARY(16) PRIMARY KEY, note VARCHAR(20));
CREATE TABLE pk_ulid_bin    (id BINARY(16) PRIMARY KEY, note VARCHAR(20));
CREATE TABLE pk_ulid_char   (id CHAR(26) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY, note VARCHAR(20));
CREATE TABLE pk_uuidv4_char (id CHAR(36)   PRIMARY KEY, note VARCHAR(20));   -- utf8mb4 のまま(比較用)

LOAD DATA INFILE '/tmp/t50data/uuidv7.csv' INTO TABLE pk_uuidv7
  FIELDS TERMINATED BY ',' LINES TERMINATED BY '\n' (@id, note) SET id = UUID_TO_BIN(@id);
LOAD DATA INFILE '/tmp/t50data/ulid_hex.csv' INTO TABLE pk_ulid_bin
  FIELDS TERMINATED BY ',' LINES TERMINATED BY '\n' (@id, note) SET id = UNHEX(@id);

LOAD DATA 100万件の所要時間とサイズ

サイズは information_schema.tables の data_length(クラスタ化インデックス = データ本体)と、.ibd ファイルの実サイズです(3回の中央値)。

主キー 型 LOAD 100万件 data_length .ibd ファイル
連番 BIGINT 3.72 秒 43.6 MB 52 MB
UUIDv4 BINARY(16) 7.13 秒 82.6 MB 92 MB
UUIDv7 BINARY(16) 4.65 秒 51.6 MB 60 MB
ULID(monotonic) BINARY(16) 4.59 秒 51.6 MB 60 MB
ULID(単調性なし) BINARY(16) 6.32 秒 87.7 MB 96 MB
ULID(単調性なし) CHAR(26) ascii 7.05 秒 103.9 MB 112 MB
UUIDv4 CHAR(36) utf8mb4 9.23 秒 120.0 MB 132 MB

UUIDv4は連番の約2倍の時間と1.8倍のファイルサイズになり、UUIDv7は連番に近い値でした。 InnoDBはランダム挿入でページ分割が起きると各ページが半分程度しか埋まらないため、同じ16バイトのキーでもv4は92 MB、v7は60 MBと差がつきます。CHAR(36) のutf8mb4はさらに大きく、1行あたりのキーが16バイトから36バイト(utf8mb4では最大144バイト分の領域を想定)に増えるためです。

1,000行ずつのマルチロー INSERT でも傾向は同じでした(100万件、3回の中央値)。

主キー 型 100万件の合計 .ibd ファイル
連番 BIGINT 5.8 秒 52 MB
UUIDv4 BINARY(16) 10.7 秒 92 MB
UUIDv7 BINARY(16) 7.9 秒 60 MB
ULID(monotonic) BINARY(16) 7.6 秒 60 MB
ULID(monotonic) CHAR(26) ascii 8.9 秒 72 MB

ランダム検索(EXPLAIN ANALYZE)

EXPLAIN ANALYZE
SELECT SUM(LENGTH(t.note)) FROM s_uuidv7 s STRAIGHT_JOIN pk_uuidv7 t ON t.id = s.id\G
-> Aggregate: sum(length(t.note))  (cost=12961 rows=1) (actual time=52..52 rows=1 loops=1)
    -> Nested loop inner join  (actual time=0.225..51 rows=10000 loops=1)
        -> Covering index scan on s using PRIMARY  (actual time=0.153..2.18 rows=10000 loops=1)
        -> Single-row index lookup on t using PRIMARY (id=s.id)  (actual time=0.00466..0.00469 rows=1 loops=10000)
主キー 10,000件のランダム検索
連番 BIGINT 40.5 ms
UUIDv4 BINARY(16) 50.9 ms
UUIDv7 BINARY(16) 52.0 ms
ULID CHAR(26) 63.8 ms

こちらもバッファプールに載っている範囲では大差なく、ファイルサイズと投入時間の差が本質です。

UUID_TO_BIN(v, 1) の swap_flag は「v1専用」

MySQLの UUID() はバージョン1で、時刻の下位ビットが先頭に来る並びです。UUID_TO_BIN(uuid, 1) はこの並びを入れ替えて時刻順に近づける機能で、UUIDv7に使うと逆に順序が壊れます。 公式ドキュメントのとおり、swap_flag=1は第1グループ(time-low)と第3グループ(time-high)を交換します。

SET @u1 = UUID();
SELECT @u1 AS uuid_v1, HEX(UUID_TO_BIN(@u1,0)) AS swap0, HEX(UUID_TO_BIN(@u1,1)) AS swap1;
SET @u7 = '01a0f2d6-09ad-7135-878e-768b1aeab0dd';
SELECT HEX(UUID_TO_BIN(@u7,0)) AS v7_swap0_ok, HEX(UUID_TO_BIN(@u7,1)) AS v7_swap1_breaks_order;
+--------------------------------------+----------------------------------+----------------------------------+
| uuid_v1                              | swap0                            | swap1                            |
+--------------------------------------+----------------------------------+----------------------------------+
| 8230e241-bce1-11f1-8b4e-02fc00000001 | 8230E241BCE111F18B4E02FC00000001 | 11F1BCE18230E2418B4E02FC00000001 |
+--------------------------------------+----------------------------------+----------------------------------+
+----------------------------------+----------------------------------+
| v7_swap0_ok                      | v7_swap1_breaks_order            |
+----------------------------------+----------------------------------+
| 01A0F2D609AD7135878E768B1AEAB0DD | 713509AD01A0F2D6878E768B1AEAB0DD |
+----------------------------------+----------------------------------+
入れるUUID 使う変換 読むときの変換
UUID()(v1) UUID_TO_BIN(v, 1) BIN_TO_UUID(b, 1)
UUIDv4 UUID_TO_BIN(v)(swapなし) BIN_TO_UUID(b)
UUIDv7 UUID_TO_BIN(v)(swapなし。先頭が時刻なのでそのまま時刻順) BIN_TO_UUID(b)
ULID(16バイト) UNHEX(hex) またはアプリでバイナリ化 HEX(b) を Base32 にアプリで戻す

UUID() を主キーに使う場合、swap_flagの効果が見えるのは数分以上の運用です。 time-lowは100ナノ秒単位の下位32ビットで、約7分で一周します。今回のように5秒で100万件を入れる実験ではswap 0/1どちらも60 MBで同じ結果でした(生成時間 5.3秒 / 4.7秒)。

実測からわかる選び方

判断フロー

flowchart TD
    A["IDをどこで採番する?"] -->|"DB 1台で採番できる"| B{"IDを外部に見せる?"}
    B -->|"見せない"| C["連番 BIGINT"]
    B -->|"URLやAPIに出す"| D["連番の主キー + 公開用の UUIDv4 列"]
    A -->|"アプリや複数サービスで採番"| E{"時刻順に並べたい?"}
    E -->|"不要。推測されにくさ優先"| F["UUIDv4"]
    E -->|"必要"| G{"文字列で扱う場面が多い?"}
    G -->|"DBの uuid 型や BINARY(16) で扱う"| H["UUIDv7"]
    G -->|"URLやログで短く読みたい"| I["ULID(monotonic 実装)"]
    A -->|"超大量で 64bit に収めたい"| J["Snowflake 風 64bit ID"]

型の早見表

ID PostgreSQL MySQL 8.0 備考
連番 bigint GENERATED ALWAYS AS IDENTITY BIGINT AUTO_INCREMENT int(21億)は上限に注意
UUIDv4 / v7 uuid(16バイト) BINARY(16) + UUID_TO_BIN / BIN_TO_UUID 文字列型は使わない
ULID uuid(16バイト)か char(26) BINARY(16) か CHAR(26) CHARACTER SET ascii COLLATE ascii_bin 文字列で持つなら大文字に統一
Snowflake風 bigint BIGINT UNSIGNED JSONに出すときは文字列化(JavaScriptの Number は53bitまで)
観点 連番 UUIDv4 UUIDv7 ULID
index サイズ(PG16, 100万件) 21 MB 38 MB 30 MB 30 MB(monotonic)
.ibd サイズ(MySQL 8.0, 100万件) 52 MB 92 MB 60 MB 60 MB(monotonic)
投入時間(PG16 COPY) 1.19 秒 3.38 秒 1.53 秒 1.78 秒
DBを増やしても衝突しない 要調整 はい はい はい
IDだけで新しい順に取れる はい いいえ はい はい
時刻が読み取れる いいえ いいえ はい はい

各言語・DBでの生成コード(実行結果つき)

Python 3.14(uuid7 が標準に)

# Python 3.14.7 / python-ulid 4.0.1(pip install python-ulid)
import datetime as dt
import uuid
from ulid import ULID

v4 = uuid.uuid4()
v7 = uuid.uuid7()                      # 3.14 で追加
print("uuid4():", v4, "version =", v4.version)
print("uuid7():", v7, "version =", v7.version)
print("  time  :", v7.time, "->", dt.datetime.fromtimestamp(v7.time / 1000, dt.UTC))

batch = [uuid.uuid7() for _ in range(3)]          # 同一ミリ秒でも生成順
print("monotonic:", batch == sorted(batch))

u = ULID()
print("ULID()  :", u, "len =", len(str(u)))
print("  as uuid:", u.to_uuid())                    # 16 バイトは UUID と互換
try:
    ULID.from_str(str(u).lower())                  # 小文字は受け付けない実装
except ValueError as e:
    print("  ValueError:", e)
uuid4(): 0660501b-eeb9-4fd8-8378-b2f6d23e7e6e version = 4
uuid7(): 01a0f2e2-56d6-7314-86c8-f962cd6596f0 version = 7
  time  : 1790781314774 -> 2026-09-30 15:15:14.774000+00:00
monotonic: True
ULID()  : 01M3SE4NPPK2W6W43KDPVDHNSW len = 26
  as uuid: 01a0f2e2-56d6-98b8-6e10-736db6d8d73c
  ValueError: Encoded ULID can only consist of letters in 0123456789ABCDEFGHJKMNPQRSTVWXYZ.

Node.js 22(randomUUID は v4。v7 は uuid パッケージか自前)

// gen.mjs  Node 22.22 / uuid 14.0.2(npm i uuid)
import { randomUUID, randomBytes } from "node:crypto";
import { v7 as uuidv7, version as uuidVersion } from "uuid";

console.log("randomUUID (v4):", randomUUID());
const u7 = uuidv7();
console.log("uuid.v7()      :", u7, "version =", uuidVersion(u7));

// 自前の UUIDv7(RFC 9562 §5.7)
export function myUuidv7(ms = Date.now()) {
  const b = randomBytes(16);
  b.writeUIntBE(ms, 0, 6);            // unix_ts_ms 48bit
  b[6] = (b[6] & 0x0f) | 0x70;        // version = 7
  b[8] = (b[8] & 0x3f) | 0x80;        // variant = 10xx
  const h = b.toString("hex");
  return `${h.slice(0,8)}-${h.slice(8,12)}-${h.slice(12,16)}-${h.slice(16,20)}-${h.slice(20)}`;
}
const mine = myUuidv7();
console.log("myUuidv7()     :", mine, "version =", uuidVersion(mine));
const ms = parseInt(mine.replace(/-/g, "").slice(0, 12), 16);   // 先頭 12 桁 = ミリ秒
console.log("timestamp      :", new Date(ms).toISOString());
randomUUID (v4): cd28a3cc-6fbe-4aed-9cde-50ee994d1179
uuid.v7()      : 01a0f2e1-86f9-76c6-81e7-ecf073fb5a94 version = 7
myUuidv7()     : 01a0f2e1-86fb-790f-a7c1-276559dfe9eb version = 7
timestamp      : 2026-09-30T15:14:21.563Z

同一ミリ秒内の順序が必要なら自前実装ではなく、カウンタを持つ uuid パッケージの v7() を使います。

Java 21(randomUUID は v4。v7 は数行で自作)

JDKには2026年9月時点でUUIDv7の生成メソッドはまだありません(UUID.timestampUUID() の提案 JDK-8357251 は未解決)。外部ライブラリが使えない環境でも、次のように作れます。

// Uuid7Demo.java  Java 21(java Uuid7Demo.java で実行)
import java.nio.ByteBuffer;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.UUID;

public class Uuid7Demo {
    private static final SecureRandom RNG = new SecureRandom();

    static UUID uuidv7() {
        long ms = System.currentTimeMillis();
        byte[] rnd = new byte[10];
        RNG.nextBytes(rnd);
        ByteBuffer buf = ByteBuffer.allocate(16);
        buf.putShort((short) (ms >>> 32));   // unix_ts_ms 上位 16bit
        buf.putInt((int) ms);                // unix_ts_ms 下位 32bit
        buf.put(rnd);
        byte[] b = buf.array();
        b[6] = (byte) ((b[6] & 0x0f) | 0x70);   // version = 7
        b[8] = (byte) ((b[8] & 0x3f) | 0x80);   // variant = 10xx
        ByteBuffer r = ByteBuffer.wrap(b);
        return new UUID(r.getLong(), r.getLong());
    }

    static Instant timestampOf(UUID v7) {
        return Instant.ofEpochMilli(v7.getMostSignificantBits() >>> 16);
    }

    public static void main(String[] args) {
        UUID v4 = UUID.randomUUID();
        System.out.println("randomUUID (v4): " + v4 + "  version=" + v4.version());
        UUID v7 = uuidv7();
        System.out.println("uuidv7()       : " + v7 + "  version=" + v7.version() + " variant=" + v7.variant());
        System.out.println("timestamp      : " + timestampOf(v7));
    }
}
randomUUID (v4): 075b60aa-67e1-4d8b-a131-ac68e1488c79  version=4
uuidv7()       : 01a0f2e1-b206-72a2-accb-01181de42a8d  version=7 variant=2
timestamp      : 2026-09-30T15:14:32.582Z

PostgreSQL(16 は SQL 関数で。18 なら uuidv7() が組み込み)

PostgreSQL 18(2025年9月リリース)には uuidv7()・uuidv4()・uuid_extract_timestamp()・uuid_extract_version() が入りました。 16では gen_random_uuid()(v4)に時刻を上書きするSQL関数で同じものが作れます。

-- PostgreSQL 16.13
SELECT gen_random_uuid() AS v4;

CREATE OR REPLACE FUNCTION uuidv7_pg16() RETURNS uuid AS $$
  SELECT encode(
    set_bit(set_bit(
      overlay(uuid_send(gen_random_uuid())
              placing substring(int8send((extract(epoch FROM clock_timestamp()) * 1000)::bigint) FROM 3)
              FROM 1 FOR 6),                   -- 先頭 6 バイトをミリ秒に置換
      52, 1), 53, 1), 'hex')::uuid;            -- version ビットを 0100 -> 0111
$$ LANGUAGE sql VOLATILE;

SELECT uuidv7_pg16() AS v7 FROM generate_series(1, 3);

-- 先頭 48bit を時刻に戻す(PG18 なら uuid_extract_timestamp(id))
SELECT id, to_timestamp(('x' || replace(left(id::text, 13), '-', ''))::bit(48)::bigint / 1000.0) AS created_from_id
FROM (SELECT uuidv7_pg16() AS id) t;
                  v4
--------------------------------------
 e94fa8f6-3659-4e97-b742-01a16c296f8f

                  v7
--------------------------------------
 01a0f2e1-d588-7e46-af4e-b7d82a0706c8
 01a0f2e1-d588-7111-a768-ed3c1a74a913
 01a0f2e1-d588-7d7b-9585-02496d852aeb

                  id                  |      created_from_id
--------------------------------------+----------------------------
 01a0f2e1-d589-760f-ae18-e84fe697c797 | 2026-09-30 15:14:41.673+00
-- PostgreSQL 18 なら(公式ドキュメントの例)
SELECT uuidv7();                              -- 019535d9-3df7-79fb-b466-fa907fa17f9e
SELECT uuid_extract_version('019535d9-3df7-79fb-b466-fa907fa17f9e'::uuid);   -- 7
CREATE TABLE orders (id uuid PRIMARY KEY DEFAULT uuidv7(), ...);

MySQL 8.0(UUID() は v1。v7 は式で組み立てるかアプリで生成)

-- MySQL 8.0.46
SELECT UUID() AS v1_string;
SET @v7 = LOWER(CONCAT(
  LPAD(HEX(FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000)), 12, '0'),        -- 48bit ミリ秒
  '7', SUBSTR(HEX(RANDOM_BYTES(2)), 1, 3),                           -- version 7 + rand_a 12bit
  HEX(0x80 | (ORD(RANDOM_BYTES(1)) & 0x3F)), HEX(RANDOM_BYTES(7))    -- variant 10xx + rand_b
));
SELECT @v7 AS v7_hex32, BIN_TO_UUID(UNHEX(@v7)) AS v7_uuid, IS_UUID(@v7) AS is_uuid;
SELECT FROM_UNIXTIME(CONV(LEFT(@v7, 12), 16, 10) / 1000) AS created_from_id;
v1_string
af99d476-bce1-11f1-8b4e-02fc00000001
v7_hex32                          v7_uuid                               is_uuid
01a0f2e1ffcf76b5ac89055991cbeb68  01a0f2e1-ffcf-76b5-ac89-055991cbeb68  1
created_from_id
2026-10-01 00:14:52.495000        -- セッションの time_zone(JST)で表示

生成した値はPython側でも version 7 と判定されました。本番ではアプリ側で生成し、INSERT ... VALUES (UUID_TO_BIN(?), ...) で渡す形が扱いやすいです。

連番の推測とv7の時刻漏れへの対策

「連番は推測できる」「v7・ULIDは生成時刻がわかる」のは仕様どおりの性質で、IDを秘密として扱わない設計にすれば問題になりません。

気になる点 起きること 対策
連番がURLに出る 前後の番号を書き換えたアクセスが可能。件数や伸び率も推測できる すべてのアクセスで所有者・権限を確認する(IDを知っていても他人のものは見えない)。外部に出す識別子は別列の public_id uuid(v4)にする
v7・ULIDから作成時刻が読める 先頭12桁の16進からミリ秒が復元できる(上のコードのとおり) 作成時刻が秘密の場面だけv4にする。ログや監査に「作成時刻」を出したくない場合も同様
IDを認証トークン代わりに使う v7は乱数74ビット、ULIDは80ビットあるが、時刻部分が既知になる トークンは secrets.token_urlsafe() や crypto.randomBytes() で別に作る
一覧に「新しい順」で出したい v4だと created_at の索引が必要 v7・ULIDなら主キーの逆順でよい(上の実行計画のとおり)

RFC 9562 §6.12は「UUIDをできるだけ不透明(opaque)に扱い、必要のない解析はしない」ことを勧めています。時刻が入っていても、それに依存したロジックを書かないのが安全です。

よくあるつまずき

1. UUIDを文字列型に保存してサイズが増える

char(36) / VARCHAR(36) に入れると、実測ではPostgreSQLで合計サイズ1.6倍(96 MB → 154 MB)、MySQLで1.4倍(92 MB → 132 MB)になりました。uuid 型 / BINARY(16) にすると、比較も16バイトの単純比較になります。

2. ULIDの大文字小文字で一致しない

ULID仕様は「大文字小文字を区別しない」ですが、DBの文字列比較は区別します。小文字のULIDで検索すると0件になります。

-- PostgreSQL 16: char(26) は大文字小文字を区別する
SELECT (SELECT count(*) FROM pk_ulid_char WHERE id = '01M3SDC583T96N0CJ2PYVFTZH8')        AS upper_hit,
       (SELECT count(*) FROM pk_ulid_char WHERE id = lower('01M3SDC583T96N0CJ2PYVFTZH8')) AS lower_hit;
 upper_hit | lower_hit
-----------+-----------
         1 |         0

MySQLも ascii_bin では同様に0件で、ascii_general_ci にすると1件になります。入口で大文字に正規化するか、16バイトのバイナリで保存するのが確実です。Python の python-ulid のように小文字を受け付けないライブラリもあります。

3. MySQLで BIN_TO_UUID(id) AS id と別名を付けて ORDER BY

別名 id が変換後の文字列を指すため、主キーの索引が使われず全件ソートになります。

EXPLAIN SELECT BIN_TO_UUID(id) AS id, note FROM pk_uuidv7 ORDER BY id DESC LIMIT 100\G
-- type: ALL, key: NULL, rows: 996948, Extra: Using filesort  (100万行を読んでソート)
EXPLAIN SELECT BIN_TO_UUID(id) AS id_text, note FROM pk_uuidv7 ORDER BY id DESC LIMIT 100\G
-- type: index, key: PRIMARY, key_len: 16, Extra: Backward index scan  (索引を逆順に 100 件)

4. UUIDv7に UUID_TO_BIN(v, 1) を使って順序が崩れる

swap_flagは UUID()(v1)専用です。v7・v4は UUID_TO_BIN(v) のままにします(上の実行結果参照)。

5. まとめて採番したULIDの並びが乱れる

同一ミリ秒内の乱数が単調でないと、B-treeの詰まりがUUIDv4と同じ約70%になりました(実測表参照)。monotonicモードを持つライブラリ(npm ulid の monotonicFactory()、python-ulid の ULID.from_timestamp の利用や、Python 3.14 uuid7() のようにカウンタを持つ実装)を選びます。

6. JavaScriptで64bitの連番・Snowflake IDが丸まる

Number は2の53乗までしか正確に表せないため、JSONでは文字列として返し、BigInt で扱います。

よくある質問

Q. UUIDv4を主キーにしている既存テーブルは、v7に変えるべきですか? A. 動いているなら急ぐ必要はありません。差が出るのは大量INSERTとインデックスサイズで、実測では100万件で投入時間2〜2.5倍、インデックス1.3〜1.5倍でした。今後作るテーブルや、INSERTが多くインデックスがメモリに収まらなくなってきたテーブルからv7に切り替えると効果があります。型は同じ uuid / BINARY(16) なので、生成側を変えるだけで移行できます。

Q. UUIDv7とULIDはどちらを選べばよいですか? A. DBの uuid 型・BINARY(16) で扱い、標準ライブラリで完結させたいならUUIDv7(RFC 9562で標準化済み。Python 3.14・PostgreSQL 18に組み込み)。URLやログに載せる26文字の読みやすさを優先するならULIDです。中身はどちらも「48ビット時刻 + 乱数」で、性能は同じでした。

Q. 連番はもう使わないほうがよいのでしょうか? A. 単一のDBで採番でき、IDを外部に見せない用途なら連番が最も小さく速い選択です(実測でインデックス21 MB、投入1.19秒)。外部に見せるIDが必要なら、内部の主キーは連番のまま public_id(UUIDv4)を別列に持つ設計もよく使われます。

Q. UUIDが衝突する心配はありませんか? A. UUIDv4は122ビットの乱数で、10億件生成しても衝突確率は10のマイナス19乗以下です。v7・ULIDは同一ミリ秒内で74〜80ビットの乱数があり、通常の採番量では衝突を考える必要はありません。心配な場合も主キー制約があるので、重複すればINSERTがエラーで止まり、静かに壊れることはありません。

まとめ

  • UUIDv7はRFC 9562(2024年5月)で正式標準になり、先頭48ビットの時刻で生成順に並ぶ
  • ULIDも「時刻48bit + 乱数80bit」の16バイトで、Crockford Base32の26文字表現が特徴
  • 実測(100万件)では、時刻順キーはUUIDv4より投入が2倍前後速く、インデックスは2〜3割小さい
  • MySQLはクラスタ化インデックスのため差が大きく、UUIDv4は連番の1.8倍のファイルサイズ
  • 文字列型で保存すると合計サイズが1.4〜1.6倍。uuid / BINARY(16) を使う
  • ULIDやv7の同一ミリ秒内の単調性がないと、B-treeの詰まりはv4と同じ約70%になる
  • 連番の推測はアクセス制御と公開ID列で、v7の時刻はIDを秘密にしない設計で対処する

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「UUID・ULID・UUIDv7の違い|主キー性能を100万件で実測比較」

株式会社RJCでは、識別子の選び方のようなデータ設計の判断を、実測した数字をもとにプロジェクトの中で仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?