日時はタイムスタンプで保存すべき?それとも ISO 文字列?
コードレビューが一つの列で止まる。片方は Unix タイムスタンプの整数で保存し、もう片方は ISO 文字列、外部 API は Z で終わるテキストを返してくる。どれが「正しい」のか?議論はほぼいつも整数か文字列かで詰まる——だがそれは、いちばん重要でない、最後に問うべき問いだ。勝敗を決めるのは、その前にあるものだ。
コードレビューが一つの列で止まる。あるエンジニアは created_at を BIGINT——Unix タイムスタンプの整数——で保存した。別のエンジニアは、ドライバが ISO 文字列へシリアライズする PostgreSQL の timestamptz を譲らない。その一方で、連携する外部 API は "2026-07-15T22:00:00Z" を返してよこし、自分が発行する JWT では exp が十桁の整数だ。どれが「正しい」のか?議論はほぼいつも同じ場所で詰まる——整数か文字列か——そして、そこから先へ進まない。
だが本当はこうだ。整数か文字列かは、一連の問いのなかでいちばん重要でなく、最後に問うべきものだ。その前に一つ問いがあり、それを誤ると、整数か文字列かの選択は無意味になる——どちらの道でも結局は落ちる。その問いはこうだ。これは何の値か。瞬間か、カレンダー日付か、未来のローカルな予定か、それとも時間の長さか。まずそれに答え、それから保存の形を問う。というのも、いったんそれが瞬間だと確定すれば、整数のタイムスタンプとオフセット付きの ISO 文字列は同じ情報の二つの記法であり——どちらが「正しい」わけでもなく、正しさではなく工学的なトレードオフで選ぶことになるからだ。実際にバグを生み出すのは、誰も意図して選ばないのに紛れ込む三つ目の答え、ゾーンのないローカル時刻である。
これは「日付と時刻」クラスタの四本目、そして締めくくりだ。前の三本は、その数字そのもの(Unix タイムスタンプがずれる理由)、それを取り巻く名前の山(UTC・GMT・ISO 8601 の違い)、そしてカレンダー日付という境界(日付が一日ずれる理由)を扱った。この記事は、それらを非常に実際的な問いに着地させる。時刻を本当にデータベースへ書き込む、あるいは API レスポンスに載せるとき、いったい何を書き下すのか。
「形」より先に「種類」を問う
整数か文字列かは形の問題だ。その前にある、より重要な問いは種類のものである。保存層の時刻バグのほとんどは、この段階で種類を取り違え、その後どれだけ整数と文字列を工夫しても取り返せない。日付の形をした値は四種類あり、それぞれ求めるものが違う。
- 瞬間——
created_at、ログの時刻、決済が通った瞬間。タイムライン上の絶対的な一点で、誰にとっても同じ。基準が明確な瞬間として保存する。Unix タイムスタンプ、またはオフセット付きの ISO 文字列だ。この種類についてだけ、その二つは同じ情報の二つの記法になる。 - カレンダー日付——誕生日、請求日、締め切り、「終日」イベント。時刻もゾーンも持たない(1990 年 5 月 1 日は東京でもシカゴでも同じ 5 月 1 日だ)。
DATE型か、単に文字列"1990-05-01"で保存し——瞬間の型に変換して往復させてはならない。自分の手で一日早くずらすことになる。 - 未来のローカルな予定——「来年の 3 日、ベルリンの朝 9 時」、毎週の会議。そのとき実際に効くオフセットは、そのゾーンの夏時間の規則が変われば変わりうる。だから、固定オフセットではなく、ローカル時刻に IANA ゾーン名(
Europe/Berlin)を添えて保存する。繰り返す予定なら、繰り返しルール(毎週・毎月、あるいは RFC 5545 のRRULE)も、ローカル時刻とゾーンと一緒に保存する。 - 時間の長さ/間隔——キャッシュの TTL、タイムアウト、「30 日で期限切れ」の「30 日」。これは量であって、時刻ではない——整数で保存する(秒かミリ秒か、単位を列名やドキュメントに書く)。もしそれが実は期限の時刻なら、その期限の瞬間を保存する。ここで一つ区別しておく——固定の長さ(30 × 24 時間)は秒の数だが、暦の期間(「1 か月」「30 暦日」)は暦の意味を持ち、固定の秒数に潰してはならない。夏時間の境界や短い月をまたぐことがあるからだ。
言い換えれば、「同じ情報の二つの記法」というきれいな等価は、瞬間についてだけ成り立つ。残る三種類にも表現の選択はある——DATE か文字列か、整数か P30D のような ISO 8601 の期間か、繰り返しイベントなら構造化オブジェクトか——が、それらは瞬間のタイムスタンプと ISO 文字列のように、一つの値を入れ替え可能に綴ったものではない。種類を誤ることは、瞬間で記法を誤ることより、はるかに大きく、そして静かに、傷を残す。
瞬間なら、どちらの答えも正しい
では瞬間に絞ろう。ここで、議論の半分をその場で溶かす事実が一つある。整数の Unix タイムスタンプと、オフセット付きの ISO 8601 文字列は、同じ瞬間の二つの記法であり、どちらも UTC に錨を下ろしている。二つの競合する「時刻」ではなく、同じ情報の二つの綴りだ。一方は数字で、一方はテキストで書かれている(まさに四層のうちの「記法」の層だ)。
1700000000 Unix タイムスタンプ(秒)
2023-11-14T22:13:20Z 同じ瞬間を、ISO 8601 で書いたもの
この二行は同じ時刻を描いている。だから「整数か文字列か」は正しさの問いではない——どちらもこの瞬間を曖昧さなく釘付けにする。それは工学的なトレードオフだ。可読性、サイズ、比較と並び替えの速さ、言語をまたぐ移植性。そのトレードオフは下でほどく。だがその前に、瞬間が必要なときに裏目に出る選択肢を締め出しておく。バグの実際の源はそこにあるからだ。
瞬間が必要なときに危険な三つ目の答え:ゾーンのないローカル時刻
裸のローカル壁時計時刻は、それ自体が間違いというわけではない——「毎日朝 9 時に開店」はもともとゾーンのないローカル時刻だし、LocalDateTime は意図して保持している壁時計時刻かもしれない。それがバグになるのは、単一の瞬間を特定する必要のある値として扱った瞬間だ。これが、ほとんど誰も意図して選ばないのに既定値から忍び込む三つ目の選択肢——オフセットを一切持たない裸のローカル壁時計時刻だ。
2026-07-15 17:00:00 ← どのゾーンの 17:00 か? 誰にも分からない。瞬間を特定するには情報が足りない。
この文字列は無害に見えるが、どの瞬間なのかを言うのに必要な情報を捨ててしまっている。同じテキストが、ニューヨークで、上海で、UTC で、それぞれ別の時刻を意味する。いったんデータベースへ落ち、読み戻されると、欠けた半分を何らかの既定ゾーンが補わねばならない——そしてその既定は、たいてい実行環境自身のゾーンだ(これこそが「一日ずれる」「数時間ずれる」バグの温床である)。
この罠は、データベースの型のレベルで特に踏みやすい。
- MySQL の
DATETIMEはゾーンの意味を一切持たない——裸の壁時計値を、入れたまま返す。一方TIMESTAMPはセッションのタイムゾーンで UTC との変換を行い(無条件に正しいわけではない)、範囲に上限があって2038-01-19 03:14:07 UTC で尽きる。 - PostgreSQL の
timestamp without time zoneも裸の壁時計値を保存し、timestamptzだけが本物の瞬間として扱う(それでも UTC に正規化し、入力のオフセットやゾーン名は保持しない)。名前のなかのwithout time zoneはしばしば「既定で UTC」と誤読されるが、実際には「ゾーンなし、意味は未定」を意味する。 - アプリケーション層——Python の「naive」
datetime、オフセットのない Java のLocalDateTime——も、同じ罠が別の顔をしているだけだ。
規則は単純で、このクラスタ全体を貫く。瞬間を保存するなら、常にその錨も保存する——記法のなかの明示的な Z かオフセット、あるいは意味上、タイムゾーンを扱える列型だ。錨のない壁時計時刻は瞬間ではなく、解かれるのを待つ謎である。
工学的トレードオフ:整数 vs オフセット付き ISO 文字列
それが瞬間だと確認し、錨も付けた——残るのが、正当に好みを持ってよい選択だ。二つの記法は、それぞれ違うトレードオフを負う。
| 整数 Unix タイムスタンプ | オフセット付き ISO 8601 文字列 | |
|---|---|---|
| サイズ | 小さい(BIGINT なら通常 8 バイト) | 大きい(テキストで約 20〜30 バイト) |
| 比較 / 範囲クエリ / 索引 | 整数演算そのもの、速い | 信頼するには正規化が要る |
| 人が読めるか | いいえ——変換しないと何時か分からない | はい——一目で読める |
| 自己記述的か | いいえ——秒かミリ秒かは慣習頼み | はい——オフセットと精度が組み込み |
| 並び替え | 数値順で自然に整列 | テキスト順=時刻順になるのは、UTC の Z に正規化し、書式と精度をそろえて初めて |
| 精度 | 単位で固定(秒は秒に丸まる) | 任意——小数秒を足し引きできる |
| 言語をまたぐ / JSON | 単位の慣習が要る | JSON に日付型はなく、RFC 3339 文字列が事実上の選択肢 |
この表は、次の二文にまとめられる。整数はコンパクトで、比較でき、速い。代償は、人が読めないこと、そして単位を規律で保たねばならないことだ——秒をミリ秒を期待するものに渡せば、日付は 1970 年へ飛ぶ。オフセット付きの ISO 文字列は自己記述的で、移植でき、一目で読める。代償は、より多くの場所を取ること、比較の前に正規化が要ること、そして、うっかりオフセットなしの裸の壁時計形式で書いてしまいやすいこと——前節の三つ目の答えだ。
いちばん手のかからない第三の道:列型に「これは瞬間だ」を覚えさせる
そのトレードオフの多くは、データベース自身に委ねられる。多くのリレーショナルデータベースはネイティブの「ゾーン付きの瞬間」型を備える——裸の整数や裸の文字列から自作するのではなく、それを使え。ただし挙動はさまざまだ——UTC に正規化する型もあれば、オフセットを保持する型もあり、そもそもタイムゾーン対応の型を持たないデータベースもある——ので、正確な意味は使うデータベースのドキュメントで確認する。
- PostgreSQL の
timestamptzは瞬間を UTC として保存する——入力のオフセットやゾーン名は保持しない。オフセット付きの値を書けば、それが瞬間へ正規化される。読み出すと、セッションのTimeZoneでその壁時計時刻へ描画される。整数のような正しい瞬間を、読みやすいインターフェースとともに得られる。 - MySQL では、
DATETIMEを「すべての値は UTC」というアプリ全体の取り決めとともに使う(2038 を回避できるが、規律は自分持ち)か、あるいはTIMESTAMPを、セッションのタイムゾーンで変換すること、そして 2038 の天井があることを承知の上で使う。 - API / JSON の層では、RFC 3339——ISO 8601 のより厳格なインターネット部分集合——を使う。JSON にはそもそも日付型がなく、RFC 3339 文字列が、値とオフセットを一緒に運べる、最も直接的で相互運用しやすい形式だからだ。
しばしば矛盾と誤解されるが実は矛盾でないことに気づいてほしい。列は「瞬間」を保存し、JSON へは ISO 文字列としてシリアライズする。これは対立ではなく、「UTC で保存する」と「ISO 文字列をよこせ」が別々の層に属しているということだ。保存層は瞬間を基準時刻に結び付け、転送層は双方が解析できるテキストを扱う。同じ瞬間、別々の層、それぞれの記法。
忘れてはいけないいくつかの細部
大きな方向が定まっても、いくつかの細部が縁で人をつまずかせる。
- 精度には注意が要る。秒を数える整数は、ミリ秒やマイクロ秒を保持できない——秒未満の精度が要るなら、単位を上げる(ミリ、マイクロ)か、小数秒を表現できる文字列か列型を使う。黙って切り捨てておいて、あとで戻ってくると期待してはいけない。
- 並び替えの「超能力」には条件がある。「ISO 文字列はテキストとして並ぶ」は、先に正規化されたときにしか成り立たない——同じ形式、同じ精度、そして理想的にはすべて UTC の
Zに変換されていること。オフセットを混ぜれば、テキスト順は時刻順でなくなる。 - 期限は「時刻」で保存し、「長さ」では保存しない。「作成から 30 日で期限切れ」を表すなら、その期限の瞬間を保存する。「30」という数字に注記を添えるのではない——比較もクエリも素直になる。その期限日がどの一日に落ちるかは、「30 × 86400 秒を足す」で手計算してはいけない(夏時間や月の長短でずれる)——二つの日付の間が何日か、30 日後は何日か、といった計算は日付計算ツールに数えさせる。
- 未来のローカルイベントはオフセットではなくゾーン名で保存する。上でも触れたが、もう一度釘を刺しておく価値がある。
+02:00は規則がある瞬間について計算した結果で、変わる。Europe/Berlinは規則を携えたゾーンそのもので、未来の予定が保存すべきはこちらだ。
デバッグ時、裸の整数を手にして、それが秒かミリ秒か、どの日に落ちるか判じかねるなら、Unix タイムスタンプ変換ツールへ放り込んで両方の単位で読む。保存した瞬間がユーザーそれぞれのゾーンでどう読めるかを見たいなら、タイムゾーン変換ツールが横並びにしてくれる。
一枚の決定表
以上を「これを保存する」表にまとめると、ほぼすべての場合を覆える。
| 保存したいもの | これを保存する |
|---|---|
起きた時刻(created_at、ログ、監査) | 瞬間——ネイティブのゾーン付き列型、または整数タイムスタンプ/Z 付き ISO 文字列 |
| カレンダー日付(誕生日、請求日、締め切り) | DATE 型か文字列 "1990-05-01"——決して瞬間型にはしない |
| 未来のローカルな予定(「来年ベルリンの 9 時」) | ローカル時刻に IANA ゾーン名(Europe/Berlin)——裸のオフセットではない |
| 時間の長さ/間隔(TTL、タイムアウト) | 固定の長さ:単位を明記した整数。暦の期間:暦の意味を保つ(P30D か構造化した期間)。期限:期限の瞬間を保存 |
| 別のシステムへ送る時刻 | RFC 3339 の ISO 文字列(JSON に日付型はない) |
締めのチェックリスト
次に「タイムスタンプか ISO 文字列か」が燃え上がったら、整数か文字列かから争いを始めないこと。この順で問う。
- これは何の値か。瞬間、カレンダー日付、未来のローカルな予定、時間の長さ? 「同じ情報の二つの記法」が成り立つのは瞬間だけ。残る三つは、種類を誤るほうが記法を誤るより悪い。
- 錨を携えているか。瞬間は
Z/オフセットを携えるか、意味上、タイムゾーンを扱える列型に置く。オフセットのない裸の壁時計値は瞬間ではなく、謎だ。 - 整数か文字列か——トレードオフで選ぶ。コンパクト・比較可能・速いが欲しいなら整数(ただし単位を釘付けに)。自己記述的・移植可能・可読が欲しいならオフセット付き ISO(ただし比較前に正規化)。ネイティブのゾーン付き列型が使えるなら、それを使って両取りする。
- 細部は揃っているか。精度は足りるか(秒未満を切り捨てていないか)、並べる前に正規化したか、期限を長さでなく時刻で保存したか、未来のイベントをオフセットでなくゾーン名で保存したか。
この四段の底にあるのは、クラスタ全体を貫く一本の線だ。まずそれが瞬間か別の何かを見極め、瞬間には錨を与え、そのうえで初めて記法を争う。順序を正せば、「タイムスタンプか ISO 文字列か」は勝てない口論から、いつでも説明できる明確なトレードオフを持つ工学的な決定へと変わる。