Go 1.27の標準uuidパッケージ—google/uuidを外せる条件

Go 1.27の標準uuidパッケージ—google/uuidを外せる条件 | mohablog

go.mod から github.com/google/uuid の行が消せます。Go 1.27 で標準ライブラリに uuid パッケージが入り、v4 と v7 の生成もパースも標準だけで完結する。ただし API は google/uuid のサブセットなので、import 行を書き換えるだけではビルドが通りません。

計測環境は go1.27rc2(darwin/arm64、Apple M5 Pro)、比較対象は google/uuid v1.6.0

目次

標準 uuid が公開しているのは7つの関数と5つのメソッド

go doc uuid の出力がパッケージのほぼ全体です。

$ go1.27rc2 doc uuid
package uuid // import "uuid"

type UUID [16]byte
    func Max() UUID
    func MustParse(s string) UUID
    func New() UUID
    func NewV4() UUID
    func NewV7() UUID
    func Nil() UUID
    func Parse(s string) (UUID, error)

これに String / Compare / MarshalText / AppendText / UnmarshalText の5メソッドが付きます。型は type UUID [16]byte の配列。== で直接比較でき、map のキーにもそのまま置けます。

New は NewV4 の別名

パッケージドキュメントには “The New function returns a new UUID generated using an algorithm suitable for most purposes” とあり、実装は NewV4() を呼ぶだけ。将来 New の中身が v7 に差し替わる余地を残した書き方です。バージョンを固定したいなら NewV4()NewV7() を明示的に呼びます。

Nil と Max は変数ではなく関数

google/uuid の uuid.Nil はパッケージ変数でしたが、標準では uuid.Nil() という関数。書き換え漏れがそのままコンパイルエラーになるので、移行の項で扱います。Max は RFC 9562 Section 5.10 で定義された ffffffff-ffff-ffff-ffff-ffffffffffff を返します。

Parse が受け付ける4つの表記

ハイフンあり、波括弧つき、URN 形式、ハイフンなしの4つ。16進の英字は大文字小文字どちらでも通ります。

for _, in := range []string{
	"f81d4fae-7dec-11d0-a765-00a0c91e6bf6",
	"{f81d4fae-7dec-11d0-a765-00a0c91e6bf6}",
	"urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6",
	"F81D4FAE7DEC11D0A76500A0C91E6BF6",
	"f81d4fae-7dec-11d0-a765-00a0c91e6bf", // 1文字足りない
} {
	u, err := uuid.Parse(in)
	fmt.Printf("%v / %v\n", u, err)
}
f81d4fae-7dec-11d0-a765-00a0c91e6bf6 / <nil>   (上4件はすべて同じ値)
...
00000000-0000-0000-0000-000000000000 / invalid uuid

エラーは errInvalid = errors.New("invalid uuid") の1種類だけ。どの文字が不正だったかは返らないので、入力値のログ出しは呼び出し側の仕事です。

NewV7 は google/uuid の2.9倍速く、アロケーションが0

生成とパースを b.Loop で回した結果。-benchtime 3s で計測しています。

処理標準 uuid (1.27rc2)google/uuid v1.6.0
v4 生成164.6 ns/op
0 B/op, 0 allocs
172.7 ns/op
16 B/op, 1 alloc
-4.7%
v7 生成71.12 ns/op
0 B/op, 0 allocs
207.2 ns/op
16 B/op, 1 alloc
-65.7%
Parse14.84 ns/op
0 allocs
11.06 ns/op
0 allocs
+34.2%

v7 が v4 より速いのは乱数を半分しか使わないから

標準実装のソースを並べると理由が一行で分かります。

// src/uuid/uuid.go
func NewV4() UUID {
	var u UUID
	rand.Read(u[:])      // 16バイト全部を乱数で埋める
	u.setVersion(4)
	u.setVariant(0b10)
	return u
}

func NewV7() UUID {
	// ...タイムスタンプ計算は省略
	var u UUID
	binary.BigEndian.PutUint64(u[0:8], hibits)
	rand.Read(u[8:])     // 後半8バイトだけ乱数
	u.setVersion(7)
	u.setVariant(0b10)
	return u
}

v4 は crypto/rand から16バイト、v7 は8バイト。164.6 ns71.12 ns の差はほぼこの読み取り量で説明がつきます。v7 は sync.Mutex のロックを取るのに、それでも v4 の半分以下に収まっている。

Parse だけは google/uuid が速い

標準の ParseUnmarshalText に委譲する実装で、4つの表記を判定する分岐が入ります。ハイフンあり形式に最適化された google/uuid に 3.78 ns 負ける。どちらも0アロケーションなので、1リクエストあたり数回のパースなら誤差です。

10万件を連続生成して順序が逆転した回数

v7 の売りは生成順にソートされること。10万件を生成して直前の値と Compare し、逆転を数えました。

v7 生成 100000 件 / 順序が逆転した件数: 0
v4 生成 100000 件 / 順序が逆転した件数: 49955
v7 は生成順のままソート済みか: true

v4 は約半分が逆転。乱数なので当然の結果です。

単調性の刻みは1/4096ミリ秒

標準の NewV7 は RFC 9562 が任意で許している12ビットのサブミリ秒フラクションを rand_a 領域に入れています。1ミリ秒を4096分割した粒度。それを超える速度で採番したときは、直前の値に +1 して単調性を維持します。

} else if timestamp <= v7lastTimestamp {
	// This timestamp is the same as a previously-generated UUID.
	// To preserve the property that we generate UUIDs in order,
	// use a timestamp 1/4096 millisecond later than the most recently
	// generated UUID.
	timestamp = v7lastTimestamp + 1
}

この補正が入ると、埋め込みタイムスタンプが実時間より先に進みます。5万件をループで生成して測った結果。

50000 件の生成にかかった実時間: 10.326ms
先頭UUIDの埋め込み時刻    : 20:10:02.210
末尾UUIDの埋め込み時刻    : 20:10:02.223
埋め込み時刻の進み幅      : 13ms
実時間との差(先送り量)    : 2.674ms

13ミリ秒ぶんの時刻が刻まれ、実時間より 2.67ミリ秒 未来にずれました。v7 のタイムスタンプを監査ログの時刻として扱うなら、この誤差が乗ります。

database/sql は uuid.UUID を内部でハードコードして扱う

google/uuid は driver.Valuersql.Scanner を実装していました。標準の UUID 型はどうか。インターフェース充足をコンパイル時にチェックします。

var (
	_ driver.Valuer            = uuid.UUID{}
	_ encoding.BinaryMarshaler = uuid.UUID{}
)
./nilcheck.go:15:31: cannot use uuid.UUID{} (value of array type uuid.UUID)
  as driver.Valuer value: uuid.UUID does not implement driver.Valuer (missing method Value)
./nilcheck.go:16:31: cannot use uuid.UUID{} (value of array type uuid.UUID)
  as encoding.BinaryMarshaler value: uuid.UUID does not implement encoding.BinaryMarshaler
  (missing method MarshalBinary)

どちらも未実装。ここでラッパー型に Value()Scan() を生やしたくなります。自分は先にラッパーを書いてしまい、後から不要と分かって消しました。

Valuer 未実装でも INSERT が通る

SQLite(modernc.org/sqlite v1.55.0)にそのまま渡した結果。

db.Exec(`CREATE TABLE orders (id TEXT PRIMARY KEY, amount INTEGER)`)

want := uuid.NewV7()
_, err := db.Exec(`INSERT INTO orders VALUES (?, ?)`, want, 1200)

var got uuid.UUID
err = db.QueryRow(`SELECT id FROM orders`).Scan(&got)
INSERT (uuid.UUID 直接): err=<nil>
SELECT (*uuid.UUID 直接): err=<nil>
  got=019fc74e-aef2-7083-ab63-8dcbb4e07348 / 一致=true

往復とも通ります。database/sql/driverdefaultConverter.ConvertValueuuid.UUID の分岐があるためです。

// src/database/sql/driver/types.go
func (defaultConverter) ConvertValue(v any) (Value, error) {
	if IsValue(v) {
		return v, nil
	}
	switch vr := v.(type) {
	case Valuer:
		// ...
	case decimalDecompose:
		return vr, nil
	case uuid.UUID:
		return vr.String(), nil
	}
	// ...

case uuid.UUID という型スイッチが1本入っている。標準ライブラリが自前の型を名指しで分岐しています。素の [16]byte はこの分岐に落ちません。

DefaultParameterConverter: value="019fc74e-540c-7808-99dc-8847cfcd0249" type=string err=<nil>
素の [16]byte            : value=<nil> type=<nil> err=unsupported type main.plain, a array

Scan 側は TEXT と16バイトの両方を受ける

読み取りは database/sql/convert.go 側に *uuid.UUID の分岐があります。driver から返る値が string なら uuid.Parse[]byte なら長さで判定する。

// src/database/sql/convert.go
case *uuid.UUID:
	if len(s) == len(*d) {
		copy((*d)[:], s)   // 16バイトならそのままコピー
		return nil
	}
	var u uuid.UUID
	err := u.UnmarshalText(s)  // それ以外はテキストとして解釈

PostgreSQL の uuid 型や MySQL の BINARY(16) のようにバイナリで返るカラムと、VARCHAR(36) のようにテキストで返るカラム。どちらも追加のコードなしで *uuid.UUID に入ります。SQLite の BLOB カラムに3パターン流し込んで確かめました。

  • 16バイトのバイナリ: 成功。copy でそのまま入る
  • 36バイトのテキスト: 成功。UnmarshalText 経由でパースされる
  • 不正な長さ"broken" の6バイト): エラー
sql: Scan error on column index 0, name "id":
  converting driver.Value type []byte ("broken") to a UUID: invalid uuid

16バイト経路の判定は len(s) == len(*d) という長さ比較だけで、中身は検証されません。ちょうど16バイトの非UUID文字列を BLOB カラムに入れて *uuid.UUID で受けると、こうなります。

入れた値の長さ: 16
Scan err: <nil>
入った UUID: 61626364-6566-6768-696a-6b6c6d6e6f70
バージョン部: 6

"abcdefghijklmnop" がそのまま16バイトのUUIDとして入り、バージョン部が6という RFC 9562 に存在しない値になった。エラーは返りません。UUID 以外の値が混ざりうるカラムなら、いったん string で受けて uuid.Parse のエラーを見ます。

この挙動はリリースノートに書かれていない

Go 1.27 Release Notes“New uuid package” セクションは “The new uuid package generates and parses UUIDs.” の一文だけ。“Minor changes to the library” の database/sql 項目にあるのは ConvertAssignRowsColumnScanner の追加で、uuid への言及はありません。pgx のようにネイティブインターフェースを持つドライバはこの変換経路を通らないため、Rows.ScanConvertValue を経由する構成に限った話になります。

google/uuid から移すとビルドが止まる6箇所

import パスの書き換えだけでは済みません。google/uuid でよく使う API を並べてビルドすると、この6つで止まります。

_ = uuid.NewString()
_, _ = uuid.NewRandom()
_ = uuid.New().Version()
_ = uuid.New().Variant()
_, _ = uuid.New().MarshalBinary()
_ = uuid.NewSHA1(uuid.Nil, []byte("x"))
./missing.go:6:11:  undefined: uuid.NewString
./missing.go:8:14:  undefined: uuid.NewRandom
./missing.go:9:17:  uuid.New().Version undefined (type uuid.UUID has no field or method Version)
./missing.go:10:17: uuid.New().Variant undefined (type uuid.UUID has no field or method Variant)
./missing.go:11:20: uuid.New().MarshalBinary undefined (type uuid.UUID has no field or method MarshalBinary)
./missing.go:12:11: undefined: uuid.NewSHA1

Nil の比較は型エラーになる

google/uuid では uuid.Nil がパッケージ変数だったため、if u == uuid.Nil がイディオムでした。標準では関数なので、書き換えないと比較そのものが成立しません。

invalid operation: u == uuid.Nil (mismatched types uuid.UUID and func() uuid.UUID)

正しくは u == uuid.Nil()、あるいは u == (uuid.UUID{})。配列型なのでゼロ値との比較で足ります。

v1・v3・v5 は入っていない

標準にあるのは v4 と v7 だけ。名前空間ベースの v5(NewSHA1)や MAC アドレスを使う v1 が要るなら、google/uuid を残す判断になります。Version()Variant() のアクセサも意図的に省かれました。バージョン判定が要るなら7バイト目の上位4ビットを自分で読みます。

version := u[6] >> 4   // 4 または 7

型変換は無条件に通る

両者とも実体は [16]byte。段階的に移行するなら型変換で橋渡しできます。

g := googleuuid.New()
s := uuid.UUID(g)      // 変換だけで済む
fmt.Println(g.String() == s.String())
true

v7 は生成時刻を外部に見せる

先頭48ビットは Unix ミリ秒そのもの。UUID を受け取った側がレコードの作成時刻を復元できます。

func v7TimeOf(u uuid.UUID) time.Time {
	var b [8]byte
	copy(b[2:], u[:6])
	return time.UnixMilli(int64(binary.BigEndian.Uint64(b[:])))
}
019fc74d-fe17-74c4-9a77-256589b4e342 から復元した生成時刻: 2026-08-03T20:06:45.143+09:00

注文番号を v7 で採番して URL に載せると、その注文が入った時刻がミリ秒精度で第三者に読めます。

NewV4NewV7
ランダム性122ビット62ビット以上
生成時刻復元不可ミリ秒精度で復元可
ソート順ランダム(10万件中49,955件が逆転)生成順(10万件で逆転0)
生成コスト164.6 ns71.12 ns
向く用途セッションID、公開URLのID主キー、イベントID

まとめ

  • 標準 uuid の API は7関数と5メソッド。v4 と v7 のみで、v1・v3・v5 と Version() / Variant() は無い
  • NewV7 は 71.12 ns/op・0アロケーション。google/uuid v1.6.0 の 207.2 ns/op から65.7%削減。乱数の読み取りが8バイトで済むため v4 よりも速い
  • Parse は google/uuid のほうが 3.78 ns 速い。標準は4表記を判定する分岐を持つ
  • driver.Valuersql.Scanner も未実装。ただし database/sql/driverdatabase/sql の両方に uuid.UUID の型スイッチがあるため、ラッパー型なしで往復できる。バイナリ16バイトのカラムも対応済み
  • 16バイト経路は長さしか見ないので、UUID でない16バイト列がエラーなしで通る。混在しうるカラムは string で受けて uuid.Parse にかける
  • 移行でビルドが止まるのは NewString / NewRandom / NewSHA1 / Version / Variant / MarshalBinary の6つと、変数から関数に変わった Nil
  • v7 のタイムスタンプは高速採番時に実時間より先へずれる。5万件を10.3ミリ秒で生成したとき2.67ミリ秒の先送りが発生した

Go 1.27 は2026年8月にリリース予定で、現時点の最新安定版は Go 1.26.5。この記事の検証は go1.27rc2 で行いました。go install golang.org/dl/go1.27rc2@latestgo1.27rc2 download で既存の Go と並列に入ります。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次