PostgreSQL における UUID と bigint:スケールする ID の選び方
PostgreSQL における UUID と bigint を比較:インデックスサイズ、ソート順、シャーディング対応、および ID が API、Web、モバイルアプリを通るときの扱い方を解説します。

ID の選択が思ったより重要な理由
PostgreSQL のテーブルの各行は、再び見つけるための安定した識別子が必要です。それが ID の役割です:レコードを一意に識別し、通常は主キーとなり、リレーションの接着剤になります。他のテーブルは外部キーとしてそれを保持し、クエリはそれで結合し、アプリは「その顧客」「その請求書」「そのサポートチケット」を示すハンドルとして渡します。
ID はあちこちに現れるので、選択は単なるデータベースの詳細ではありません。後でインデックスサイズ、書き込みパターン、クエリ速度、キャッシュヒット率、さらには分析、インポート、デバッグといったプロダクト作業に現れます。URL や API に何を公開するか、モバイルアプリがデータを安全に保存・同期するのがどれだけ簡単かにも影響します。
多くのチームは PostgreSQL で UUID と bigint を比較することになります。平たく言えば、選んでいるのは次のどちらかです:
- bigint:64 ビットの数値。通常はシーケンスで生成されます(1, 2, 3…)。
- UUID:128 ビットの識別子。見た目はランダムだったり、時間順に生成されたりします。
どちらが常に勝つということはありません。bigint はインデックスやソートに対してコンパクトで有利な傾向があります。UUID は複数のシステムでグローバルにユニークな ID が必要な場合、公開 ID を安全にしたい場合、あるいは多くの場所でデータが作られることが予想される(複数サービス、オフラインのモバイル、将来のシャーディング)場合に適しています。
実用的な指針:今日の保存方法だけでなく、データがどのように作られ、共有されるかに基づいて決めてください。
bigint と UUID の基本を平易に
人々が PostgreSQL で UUID と bigint を比較するとき、選んでいるのは行の名前付け方法の違いです:小さなカウンターのような数値か、より長いグローバルにユニークな値か。
bigint は 64 ビット整数です。PostgreSQL では通常 identity column(または古い serial パターン)で生成します。DB は内部でシーケンスを持ち、行を挿入するたびに次の番号を渡します。つまり ID は 1, 2, 3, 4… のように増えていきます。シンプルで読みやすく、ツールやレポートでも扱いやすいです。
UUID(Universally Unique Identifier)は 128 ビットです。ハイフン付き 36 文字のように見えることが多く、例: 550e8400-e29b-41d4-a716-446655440000。主な種類は:
- v4:ランダム UUID。どこでも簡単に生成できるが、作成順にソートされない。
- v7:時間順に近い UUID。ユニーク性を保ちながら、おおむね時間とともに増えるよう設計されている。
ストレージは最初の実務的な差分のひとつです:bigint は 8 バイト、UUID は 16 バイト。このサイズ差はインデックスに現れ、キャッシュヒット率に影響します(DB はメモリにより少ないインデックスエントリしか収められません)。
また、ID が DB の外でどこに現れるかも考えてください。bigint は URL で短く、ログやサポートチケットで読みやすいです。UUID は長くてタイプしにくいですが、推測しにくく、クライアント側で安全に生成できます。
インデックスサイズとテーブル膨張:何が変わるか
bigint と UUID の最も実務的な違いはサイズです。bigint は 8 バイト、UUID は 16 バイト。これは小さく聞こえますが、インデックスが ID を何度も繰り返し保存することを思い出してください。
主キーインデックスはメモリでホットに保つ必要があります。小さいインデックスの方が shared buffers や CPU キャッシュに多く収まり、ルックアップや結合でディスク読み込みが減ります。UUID を主キーにすると、同じ行数でインデックスが明らかに大きくなることが多いです。
二次インデックスはさらに影響します。PostgreSQL の B-tree インデックスでは、各二次インデックスエントリも主キー値を保存します(データ行を見つけるため)。つまり幅の広い ID は主キーインデックスだけでなく、追加したすべてのインデックスを膨らませます。二次インデックスが 3 つあれば、UUID の余分な 8 バイトは実質的に 4 箇所に現れます。
外部キーや結合テーブルも同様に影響を受けます。ID を参照するテーブルは自分の行やインデックスにその値を保存します。多対多のジョインテーブルは主に 2 つの外部キーと少しのオーバーヘッドなので、キー幅が倍になるとフットプリントが大きく変わります。
実務的には:
- UUID は通常、主キーと二次インデックスを大きくし、その差分はインデックス数が増えるごとに累積する。
- 大きなインデックスはメモリ圧力を高め、負荷時にページ読み込みを増やす。
- ID を参照するテーブルが多いほど、サイズ差は重要になる。
ユーザー ID が users, orders, order_items, audit_log に現れるなら、その同じ値はすべてのテーブルで保存・インデックス化されます。幅の広い ID を選ぶことは、ID の選択だけでなくストレージの選択でもあります。
ソート順と書き込みパターン:連続かランダムか
ほとんどの PostgreSQL の主キーは B-tree インデックス上にあります。B-tree は新しい行がインデックスの末尾付近に入ると最も効率的です。データベースは追記するだけで済み、再配置が最小限で済むからです。
連続した ID:予測可能でストレージに優しい
bigint の identity やシーケンスでは新しい ID は時間とともに増えます。挿入はたいていインデックスの右端に当たり、ページは詰まりやすく、キャッシュは温かく保たれ、PostgreSQL は余計な作業を減らせます。
これは ORDER BY id を実行しない場合でも重要です。書き込みパスは各新しいキーをソート済みの順序に置かなければなりません。
ランダムな UUID:散らばりとチャーンの増加
ランダムな UUID(一般的な UUIDv4)は挿入をインデックス全体に散らします。これによりページスプリットが増える可能性が高まり、PostgreSQL は新しいインデックスページを割り当て、エントリを移動させる必要があります。結果として書き込みの増幅が起きます:より多くのインデックスバイトが書かれ、より多くの WAL が生成され、後で autovacuum や bloat 管理の作業が増えます。
時間順に近い UUID は話を変えます。UUID が時間的にほぼ増加するよう設計されていれば(例: UUIDv7 スタイル)、局所性の多くが回復します。それでも 16 バイトで見た目は UUID のままです。
これらの違いは、挿入率が高い、大きなテーブルがメモリに収まらない、二次インデックスが複数あるといった状況で最も感じられます。ページスプリットによる書き込みレイテンシのスパイクが気になるなら、ホットな書き込みテーブルで完全にランダムな ID は避けてください。
例:モバイルアプリのログを一日中受け取る忙しい events テーブルは、完全にランダムな UUID よりも連続キーか時間順 UUID の方が通常は安定して動作します。
実感できるパフォーマンスの影響
現実の遅延は多くの場合「UUID が遅い」「bigint が速い」という単純な話ではありません。データベースがクエリに答えるために触らなければならない量が問題です。
クエリプランが重視するのは、フィルタでインデックススキャンを使えるか、キーで高速に結合できるか、テーブルが物理的に整列されているか(またはそれに近いか)で、レンジ読み込みが安く済むかどうかです。bigint 主キーでは新しい行がほぼ増加順に入るため、主キーインデックスはコンパクトで局所性に優れる傾向があります。ランダムな UUID では挿入がインデックスに散らばり、ページスプリットやオンディスクの混乱を生みます。
読み取りは多くのチームが最初に気づく点です。キーが大きいとインデックスも大きくなり、より少ないページしか RAM に収まらないため、キャッシュヒット率が下がり IO が増えます。特に「顧客情報付きの注文一覧」のような結合が多い画面で顕著です。
書き込みも変わる可能性があります。ランダム UUID の挿入はインデックスのチャーンを増やし、autovacuum に負荷をかけ、繁忙時にレイテンシのスパイクとして現れることがあります。
UUID と bigint をベンチマークするなら、公平に行ってください:同じスキーマ、同じインデックス、同じ fillfactor、そして RAM を超える十分な行数(10k ではない)で測定します。p95 レイテンシと IO を測り、ウォームキャッシュとコールドキャッシュの両方でテストしてください。
AppMaster 上で PostgreSQL を使ってアプリを構築している場合、一覧ページの遅さやデータベース負荷の増加として先に現れることが多いです。
公開システムでのセキュリティと使いやすさ
ID がデータベースを出て URL、API レスポンス、サポートチケット、モバイル画面に現れるなら、選択は安全性と日常の使いやすさの両方に影響します。
bigint は人間に優しいです。短くて電話越しに伝えやすく、サポートは「失敗している注文は 9,200,000 あたりが多い」といったパターンをすぐ見つけられます。ログや顧客のスクリーンショットからデバッグする際に役立ちます。
UUID は公開識別子に有用です。UUID は推測しにくいため、/users/1, /users/2 のような簡単なスクレイピングが効きません。外部から総レコード数を推測されにくくもなります。
ただし「推測できない = セキュア」と考えるのは罠です。認可チェックが弱ければ、予測可能な bigint ID は悪用されやすいですが、UUID も共有リンク、流出したログ、キャッシュされた API レスポンスから盗まれる可能性があります。セキュリティは ID を隠すことではなく、適切な権限チェックから来ます。
実用的なアプローチ:
- すべての読み書きで所有権またはロールチェックを強制する。
- 公開 API で ID を公開するなら、UUID や別の公開トークンを使う。
- 人間が扱いやすい参照が欲しいなら、内部用に bigint を残す。
- ID 自体に機密情報(ユーザー種別など)を埋め込まない。
例:顧客ポータルが請求書 ID を表示する場合。請求書が bigint で、API が「請求書が存在するか」だけをチェックしていると、誰かが番号を順に試して他人の請求書をダウンロードできてしまいます。まずチェックを修正し、そのうえで公開請求書 ID を UUID にするかどうか判断してください。
AppMaster のように ID が生成された API やモバイルアプリを通って流れるプラットフォームでは、安全なデフォルトは一貫した認可とクライアントが確実に扱える ID 形式です。
API とモバイルアプリを通る ID の流れ
データベースで選んだ型はデータベース内に留まりません。URL、JSON ペイロード、クライアントの保存、ログ、分析などあらゆる境界に漏れ出します。
後で ID の型を変えるとき、壊れるのは決して「ただのマイグレーション」ではありません。外部キーはメインのテーブルだけでなくすべての場所を変えなければなりません。ORM やコードジェネレータがモデルを再生成しても、統合は古い形式を期待しているかもしれません。GET /users/123 のようなエンドポイントは、ID が 36 文字の UUID になったら混乱の元になります。キャッシュやメッセージキュー、ID を数値として保存していた場所も全て更新が必要です。
API では表現とバリデーションの選択が重要です。bigint は数値として送られますが、ある言語やシステムでは浮動小数点として解釈されて精度問題が生じることがあります。UUID は文字列として送るのが普通で、パースは安全ですが「ほぼ UUID」のようなゴミがログや DB に入らないよう厳格なバリデーションが必要です。
モバイルでは ID が常にシリアライズされ保存されます:JSON レスポンス、ローカルの SQLite、ネットワークが戻るまで保存するオフラインキューなど。数値 ID は小さいですが、文字列の UUID は不透明なトークンとして扱いやすいことが多いです。問題を起こすのは一貫性の欠如です:ある層は整数として保存し、別の層はテキストとして保存していると、比較や結合が壊れます。
守るべきルール:
- API の正準表現を決め(多くの場合文字列)、それを徹底する。
- エッジで ID を検証し、明確な 400 エラーを返す。
- ローカルキャッシュやオフラインキューでも同じ表現を保存する。
- サービス間でログのフィールド名と形式を一貫させる。
AppMaster のような生成スタックで Web とモバイルクライアントを作る場合、安定した ID 契約はさらに重要です。なぜならそれが生成されるすべてのモデルとリクエストの一部になるからです。
シャーディング準備と分散システム
「シャーディング対応」と言うときは主に、複数の場所で ID を生成してもユニーク性を保てること、後でノード間でデータを移動しても外部キーを全部書き換えずに済むことを意味します。
UUID は複数リージョンやマルチライタ構成で人気があります。どのノードでもユニークな ID を生成でき、中央のシーケンスに問い合わせる必要がないため、調停が減り、複数地域で書き込みを受け入れて後でマージするのが容易になります。
bigint でも対応は可能ですが計画が必要です。よくある選択肢はシャードごとに数値範囲を割り当てる(シャード1 は 1-1B、シャード2 は 1B-2B)、シャード接頭辞を持つ別シーケンス、あるいは Snowflake 風の ID(時間ビット+マシン/シャードビット)を使うことです。これらは UUID よりインデックスを小さく保ちつつ順序性を保てますが、運用ルールを守る必要があります。
日常で重要になるトレードオフ:
- 調整:UUID はほとんど不要。bigint は範囲計画や生成サービスが必要なことが多い。
- 衝突:UUID の衝突確率は極めて低い。bigint は割り当てルールが重複しないことが前提。
- 順序:多くの bigint スキームはおおむね時間順になる。UUID は時間順バリアントを使わない限りランダムに近い。
- 複雑さ:シャード化した bigint を維持するにはチームの規律が必要。
多くのチームにとって「シャーディング対応」とは実際には「将来の移行に備える」ことを意味します。現在は単一 DB なら、現在の作業を楽にする ID を選んでください。すでに複数ライター(例:AppMaster で生成された API とオフラインモバイル)を構築しているなら、サービス間で ID をどう作成し検証するかを早めに決めてください。
ステップバイステップ:適切な ID 戦略の選び方
まずアプリの実際の形を定義します。単一の PostgreSQL データベースが一地域にあるのと、マルチテナント、将来的にリージョン別に分割する可能性があるもの、オフラインでレコードを作って後で同期するモバイル主体のものではニーズが違います。
次に ID がどこに現れるか正直に評価してください。識別子がバックエンド内(ジョブ、内部ツール、管理パネル)にとどまるならシンプルさが勝つことが多いです。ID が URL、顧客と共有するログ、サポートチケットやモバイルのディープリンクに現れるなら、予測可能性やプライバシーがより重要です。
順序を決定要因にしてください。最新順フィード、安定したページネーション、スキャンしやすい監査ログに依存するなら、連続した ID(または時間順の ID)が驚きを減らします。プライマリキーの選択と順序要件が切り離せるなら、別のタイムスタンプでソートする選択肢もあります。
実用的な意思決定フロー:
- アーキテクチャを分類する(単一 DB、マルチテナント、マルチリージョン、オフラインファースト)と、将来複数ソースのデータをマージする可能性を評価する。
- ID が公開識別子か内部専用かを決める。
- 順序とページネーションのニーズを確認する。挿入順が重要なら純粋にランダムな ID は避ける。
- UUID を選ぶならバージョンを意図的に選ぶ:予測不能性が欲しければランダム(v4)、インデックス局所性を優先するなら時間順に近いものを選ぶ。
- 早めに慣例を固める:API での正準テキスト形式、大文字小文字ルール、バリデーション、各 API が ID をどう返し受け取るか。
例:モバイルがオフラインで「下書き注文」を作れる場合、デバイス側で安全に ID を生成できる UUID は便利です。AppMaster のようなツールでは、同じ ID 形式が DB から API、Web/ネイティブアプリへ特別扱いなしに流れるので便利です。
よくある間違いと落とし穴
多くの ID 議論が失敗するのは、ある理由で型を選び、後で副作用に驚くからです。
一般的なミスの一つは、フルランダムな UUID をホットな書き込みテーブルに使い、挿入がスパイクする理由に首をかしげることです。ランダム値はインデックス全体に散らばり、ページスプリットや DB の追加作業が増えます。書き込みの多いテーブルでは挿入の局所性を考えてください。
別のよくある問題は、サービスやクライアント間で ID 型を混在させることです。例えばあるサービスは bigint、別のサービスは UUID を使い、API に数値と文字列が混在すると、JSON パーサが大きな数値で精度を失ったり、モバイルが画面によって ID を数値として扱ったり文字列として扱ったりしてバグを生みます。
三つ目の罠は「推測不能な ID をセキュリティ代わりに使う」ことです。UUID を使っても適切な認可がなければ意味がありません。
最後に、計画なしに後で型を変えるチームがいます。最も大変なのは主キーそのものではなく、それに付随するすべてです:外部キー、ジョインテーブル、URL、分析イベント、モバイルのディープリンク、保存されたクライアント状態など。
トラブルを避けるために:
- 公開 API には一つの ID 型を選び、それを守る。
- クライアント側では ID を不透明な文字列として扱うようにして数値の端数問題を避ける。
- ID のランダム性をアクセス制御代わりにしない。
- どうしても移行が必要なら API をバージョン化し、長期にわたるクライアントを想定した計画を立てる。
AppMaster のようなコード生成プラットフォームで開発するなら、ID の一貫性はさらに重要です。なぜなら同じ ID 型が DB スキーマから生成バックエンド、Web/モバイルアプリに流れるからです。
決める前のクイックチェックリスト
理論からではなく、製品が 1 年後にどう見えるか、そしてその ID がどれだけ多くの場所を移動するかから始めてください。
次の問いを投げてください:
- 最大テーブルは 12~24 か月でどれくらい大きくなるか、何年分の履歴を保持する予定か?
- 作成時刻順に大まかにソートされる ID がページングやデバッグに必要か?
- 複数のシステムが同時にレコードを作る(オフラインのモバイルやバックグラウンドジョブを含む)可能性はあるか?
- ID は URL、サポートチケット、エクスポート、顧客と共有するスクリーンショットに現れるか?
- すべてのクライアント(Web、iOS、Android、スクリプト)が同じ方法で ID を扱えるか(バリデーションと保存を含む)?
これらに答えたら配管(エコシステム)を確認してください。bigint を使うなら、すべての環境(ローカル開発やインポートを含む)での ID 生成計画を明確にしてください。UUID を使うなら API 契約とクライアントモデルが文字列 ID を一貫して扱えること、チームがそれを読み比較するのに慣れていることを確認してください。
現実的なテスト:モバイルがオフラインで注文を作って同期する必要があるなら、UUID は調整作業を減らすことが多いです。主にオンラインでシンプルで小さいインデックスが欲しいなら bigint が扱いやすいです。
AppMaster で構築するなら、早めに決めておくと PostgreSQL モデル、API エンドポイント、モバイルクライアントが一貫して再生成され成長しても安定します。
現実的な例
小さな会社が内部運用ツール、顧客ポータル、フィールドスタッフ向けモバイルアプリを持ち、3 つとも同じ PostgreSQL データベースに対して 1 つの API 経由でアクセスしているとします。チケット、写真、ステータス更新、請求書が一日中作成されます。
bigint の場合、API ペイロードはコンパクトで読みやすいです:
{ "ticket_id": 4821931, "customer_id": 91244 }
ページネーションは自然に感じられます: ?after_id=4821931&limit=50。id でソートすると通常は作成時刻と一致するため、「最新のチケット」を取得するのが速く予測可能です。デバッグも簡単で、サポートは「ticket 4821931」と言えばほとんどの人がタイプできます。
UUID の場合、ペイロードは長くなります:
{ "ticket_id": "3f9b3c0a-7b9c-4bf0-9f9b-2a1b3c5d1d2e" }
ランダムな v4 を使うと挿入がインデックス全体に散らばり、インデックスチャーンと日常的なデバッグの手間が増えることがあります(コピー&ペーストが普通になる)。ページングは after id ではなくカーソル形式に移ることが多いです。
時間順 UUID を使えば、「最新順」の挙動の多くを保ちながら公開 URL で推測されにくくする利点を得られます。
実務でチームがよく気づく 4 点:
- ID を人がタイプする頻度とコピーする頻度
idによるソートがcreatedによるソートと一致するか- カーソルページングの滑らかさ
- ログ、API 呼び出し、モバイル画面で 1 レコードを追跡する容易さ
次のステップ:デフォルトを決め、テストし、標準化する
多くのチームは完璧な答えを求めて動けなくなります。完璧は不要で、今のプロダクトに合うデフォルトと、それが後で問題を起こさないことを素早く検証する方法が必要です。
標準化できるルール:
- 小さなインデックス、予測可能な順序、簡単なデバッグを重視するなら bigint を使う。
- URL で推測されにくくし、オフライン生成(モバイル)やシステム間の衝突を避けたいなら UUID を使う。
- 将来テナントやリージョンで分割する可能性があるなら、ノード間で使える ID 計画(UUID、または調整された bigint スキーム)を優先する。
- 例外は稀にし、ひとつをデフォルトにする。複数のクライアントがいるときは一貫性が小さな最適化より重要になることが多い。
ロックイン前に小さなスパイクを走らせてください。現実的な行サイズのテーブルを作り、100 万〜500 万行を挿入して、(1) インデックスサイズ、(2) 挿入時間、(3) 主要クエリ(主キーといくつかの二次インデックス)を比較します。実機と実際のデータ形で行ってください。
後で変更する可能性があるなら、移行を平凡にする計画を立ててください:
- 新しい ID 列とユニークインデックスを追加する。
- デュアルライト:新しい行は両方の ID を埋める。
- 既存行をバッチでバックフィルする。
- API とクライアントを新しい ID を受け取るよう更新する(移行期間中は古い ID も維持)。
- 読み取りを切り替え、ログとメトリクスが安定したら古いキーを削除する。
AppMaster(appmaster.io)で構築しているなら、早めに決める価値があります。ID の慣例は PostgreSQL モデル、生成 API、Web とネイティブのクライアントへと流れるからです。型そのものが重要ですが、一貫性の方が実運用でより重要になることが多いです。
よくある質問
単一の PostgreSQL データベースで、ほとんどの書き込みがサーバー側で起き、コンパクトなインデックスと予測可能な挿入挙動を重視するならデフォルトは bigint にしてください。ID を多数の場所で生成する必要がある(複数サービス、オフラインのモバイル、将来のシャーディングなど)か、公開 ID を推測されにくくしたい場合は UUID を選びます。
ID は多くの場所に複製されます: 主キーインデックス、すべてのセカンダリインデックス(行ポインタとして主キー値を持つため)、他テーブルの外部キー列、ジョインテーブルなど。UUID は 16 バイト、bigint は 8 バイトなので、スキーマ全体で差分が何倍にもなり、キャッシュヒット率を下げます。
ホットな挿入が多いテーブルでは影響があります。ランダムな UUID(例: v4)は B-tree 全体に挿入を散らすため、ページスプリットやインデックスのチャーンが増えて書き込みが重くなります。UUID を使いつつ書き込みを滑らかにしたいなら、時間順に近い UUID(time-ordered)を用いると良いです。
多くの場合、CPU の遅さではなく IO の増加として現れます。キーが大きいとインデックスが大きくなり、メモリに収まるページ数が減るため、結合やルックアップで読み込みが増えます。特に大きなテーブル、結合が多いクエリ、ワーキングセットが RAM に収まらない場合に顕著です。
UUID は /users/1 のような単純な推測を防ぐのに役立ちますが、認可の代わりにはなりません。認可チェックが甘ければ、UUID であっても共有リンクやログ流出で再利用され得ます。UUID は公開識別子として便利なだけで、本当のセキュリティはアクセス制御に依存します。
API の表現はひとつに統一して守ることが重要です。実務的なデフォルトは、データベースが bigint であっても API では ID を文字列として扱うことです。これによりクライアント側の数値の精度問題を避けられます。どれを選ぶにせよ、Web、モバイル、ログ、キャッシュで一貫させてください。
大きな数値が浮動小数点として扱われるクライアントでは精度を失う可能性があります。UUID は文字列なのでその点は回避できますが、長く扱いづらいことと厳格なバリデーションの必要性は残ります。最も安全なのは一貫性を保つことです。
UUID はローカルで独立して生成できるため、複数リージョンやマルチライター環境で扱いやすいです。bigint でもシャーディングは可能ですが、シャードごとの範囲割り当てや Snowflake 構成のような仕組みが必要になります。分散環境で最も簡単なのは時間順を採用した UUID です。
主キーの型変更は単一カラム以上の影響があります。外部キー、ジョインテーブル、API 契約、クライアント保存、キャッシュ、分析イベントなど、ID を保存しているすべての場所を扱う必要があります。変更が必要なら、デュアルライトや段階的バックフィルを含む移行計画を立ててください。
内部ではコンパクトな bigint を主キーにして、外部公開用に別途 UUID(またはトークン)を持つという選択は有効です。こうすればインデックスは小さく保ちつつ公開 API での列挙を避けられます。重要なのはどちらを“公開 ID”にするかを早めに決め、混在させないことです。


