タイムゾーン設計の答えは「時点は UTC で保存し、ユーザーに見せる境界だけで JST などに変換する」です。 アプリの内部では「2026-10-01T00:00:00Z」のように世界で一つに決まる時点だけを扱い、画面やメールに出す瞬間に「2026/10/01 09:00」へ直します。この一本の原則を守ると、サーバーの場所が変わっても、海外のユーザーが増えても、集計の日付がずれても慌てずに済みます。

この記事では原則を早見表にまとめたうえで、PostgreSQL 16 の timestamptz / timestamp と MySQL 8.0 の DATETIME / TIMESTAMP に対し、セッションの time_zone を変えながら INSERT と SELECT を実際に実行した結果を並べて比較します。さらに Python(zoneinfo)と Node 22(Intl.DateTimeFormat)のコピペ用コードと出力、「JST の 1 日」の範囲検索 SQL と実行計画、月末締め、DATE 列、サマータイム、各層の設定確認コマンド、「9 時間ずれる」の再現と直し方までを扱います。Python の datetime 全般、Java の日付処理、SQL の日付関数は別記事に譲り、設計と DB の挙動に集中します。

タイムゾーン設計の早見表

場面 方針 具体的にどう書くか
DB に保存する「時点」(注文日時、更新日時、ログ) UTC で保存。時点を表す型を選ぶ PostgreSQL は timestamptz、MySQL は DATETIME に UTC 固定(TIMESTAMP は 2038 年まで)
DB に保存する「日付」(締め日、予定日、生年月日) DATE 列にそのまま。変換しない date / DATE。「2026-10-01」という業務上の日付として扱う
「毎週月曜 9:00」のような未来の予定 文字盤の時刻 + IANA タイムゾーン名で持つ scheduled_local = '09:00', time_zone = 'Asia/Tokyo'。実行時に UTC へ変換
API のリクエスト/レスポンス ISO 8601 のオフセット付き 2026-10-01T00:00:00Z または 2026-10-01T09:00:00+09:00。オフセットなしは使わない
ユーザーごとの表示 ユーザーのタイムゾーンを IANA 名で持つ users.time_zone = 'Asia/Tokyo'(+09:00 ではなく地域名)
画面・メールへの表示 境界(表示する瞬間)で変換 Python astimezone(ZoneInfo("Asia/Tokyo"))、Node Intl.DateTimeFormat(..., { timeZone: "Asia/Tokyo" })
「JST の 1 日」「1 か月」の検索・集計 境界を UTC に直してから半開区間で比較 ts >= '2026-09-30T15:00:00Z' AND ts < '2026-10-01T15:00:00Z'。列側に関数をかけない
サーバー・DB・コンテナの設定 全層 UTC にそろえ、セッション依存をなくす OS TZ=UTC、PostgreSQL timezone='UTC'、MySQL default-time-zone='+00:00'、Java -Duser.timezone=UTC
ログ UTC またはオフセット付き 2026-10-01T00:00:00.000Z。複数サーバーを突き合わせても順序が崩れない

型の早見表(セッションの time_zone を変えると何が起きるか)

DB・型 保存されるもの オフセット付き入力 セッションを変えて SELECT 範囲
PostgreSQL timestamptz UTC の時点(タイムゾーン自体は保存しない) 尊重して UTC に換算 表示が変わる(時点は同じ) 紀元前 4713 年〜294276 年
PostgreSQL timestamp 文字盤の数字そのまま 無視される(Z を付けても捨てられる) 変わらない 同上
MySQL TIMESTAMP UTC の時点 8.0.19 以降は尊重 表示が変わる 1970-01-01 00:00:01 〜 2038-01-19 03:14:07 UTC
MySQL DATETIME 文字盤の数字そのまま 8.0.19 以降はセッションの time_zone に換算して保存 変わらない 1000 年〜9999 年

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

(記載例)出典:株式会社RJC「タイムゾーン設計|UTC保存・JST表示をDB別実行結果で解説」

なぜ「UTC で保存、境界で変換」なのか

「時点」と「文字盤」は別物です。「2026-10-01 09:00」という文字盤は、東京なら UTC の 00:00、ニューヨーク(夏時間)なら UTC の 13:00 を指します。文字盤だけを保存すると「どこの 09:00 か」がサーバー設定や暗黙の了解に依存します。UTC の時点として保存すれば、読み出す側がどこにいても同じ瞬間を指し、表示のときに好きなタイムゾーンへ変換できます。

flowchart LR
    U[ユーザー入力<br/>2026-10-01 09:00 JST] -->|境界: JST→UTC| A[アプリ内部<br/>2026-10-01T00:00:00Z]
    A --> D[(DB<br/>timestamptz または UTC固定のDATETIME)]
    D --> A2[アプリ内部<br/>2026-10-01T00:00:00Z]
    A2 -->|境界: UTC→ユーザーのTZ| V[表示<br/>2026/10/01 09:00]

変換するのは入口と出口の 2 か所だけにします。サービス間通信、キュー、ログはすべて UTC(またはオフセット付きの ISO 8601)で受け渡せば、「どこで何時間足したか」を追いかける必要がなくなります。

PostgreSQL:timestamptz と timestamp の違いを実行結果で見る

timestamptz は「UTC の時点」を保存し、timestamp は「文字盤の数字」をそのまま保存します。 名前から「タイムゾーンを一緒に保存する」と思われがちですが、保存されるのは UTC に換算した時点だけで、入力に使ったオフセットは残りません。PostgreSQL 16.13(サーバーの timezone は Etc/UTC)で確認します。

CREATE TABLE tz_demo (id serial PRIMARY KEY, label text,
  ts_tz timestamptz,    -- with time zone(UTCの時点として保存)
  ts_plain timestamp);  -- without time zone(文字盤をそのまま保存)

SET TIME ZONE 'Asia/Tokyo';   -- (1) セッションを JST にして 3 種類の書き方で INSERT
INSERT INTO tz_demo (label, ts_tz, ts_plain) VALUES
  ('JSTセッション/オフセットなし', '2026-10-01 09:00:00',       '2026-10-01 09:00:00'),
  ('JSTセッション/+09:00付き',     '2026-10-01 09:00:00+09:00', '2026-10-01 09:00:00+09:00'),
  ('JSTセッション/Z付き',          '2026-10-01 09:00:00Z',      '2026-10-01 09:00:00Z');
SELECT id, label, ts_tz, ts_plain FROM tz_demo ORDER BY id;
SET TIME ZONE 'UTC';          -- (2) 同じ行をセッション UTC で SELECT
SELECT id, label, ts_tz, ts_plain FROM tz_demo ORDER BY id;

実行結果(PostgreSQL 16.13):

=== (1) セッション time_zone = Asia/Tokyo で INSERT ===
 id |            label             |         ts_tz          |      ts_plain
----+------------------------------+------------------------+---------------------
  1 | JSTセッション/オフセットなし | 2026-10-01 09:00:00+09 | 2026-10-01 09:00:00
  2 | JSTセッション/+09:00付き     | 2026-10-01 09:00:00+09 | 2026-10-01 09:00:00
  3 | JSTセッション/Z付き          | 2026-10-01 18:00:00+09 | 2026-10-01 09:00:00

=== (2) 同じ行を セッション time_zone = UTC で SELECT ===
  1 | JSTセッション/オフセットなし | 2026-10-01 00:00:00+00 | 2026-10-01 09:00:00
  2 | JSTセッション/+09:00付き     | 2026-10-01 00:00:00+00 | 2026-10-01 09:00:00
  3 | JSTセッション/Z付き          | 2026-10-01 09:00:00+00 | 2026-10-01 09:00:00

セッションを America/New_York にすると、行 1 の ts_tz は 2026-09-30 20:00:00-04 と表示され(ts_plain は変わらず)、日付まで変わります。結果から読み取れることを表にまとめます。

観察 timestamptz(ts_tz) timestamp(ts_plain)
オフセットなしの文字列を入れると セッションの time_zone で解釈して UTC に換算(行 1 は JST 09:00 = UTC 00:00) 数字をそのまま保存
+09:00 や Z を付けると 尊重される(行 3 は UTC 09:00 = JST 18:00) 無視される(行 3 も 09:00 のまま。警告も出ない)
SELECT 時のセッションを変えると 表示だけ変わる。時点は同じ 何も変わらない
向いている用途 注文日時・更新日時などの「時点」 「どこの時刻か」が列の外で決まっている用途、UTC 固定で運用する既存テーブル

実務上の選び方は「迷ったら timestamptz」です。 timestamp を使う場合は「この列は UTC の文字盤」という約束をアプリとドキュメントで守り続ける必要があり、Z 付きの入力が静かに捨てられる(行 3)ことも覚えておきます。

セッションに依存しない書き方(AT TIME ZONE)

接続のたびに SET TIME ZONE するより、必要な箇所で AT TIME ZONE を明示した方が、接続プールやツールの設定に左右されません。

SET TIME ZONE 'UTC';
SELECT ts_tz,
       ts_tz    AT TIME ZONE 'Asia/Tokyo' AS jst_wall_clock,     -- timestamptz -> timestamp(JSTの文字盤)
       ts_plain AT TIME ZONE 'Asia/Tokyo' AS plain_as_jst_to_tz  -- timestamp   -> timestamptz(文字盤をJSTと解釈)
FROM tz_demo WHERE id = 1;
         ts_tz          |   jst_wall_clock    |   plain_as_jst_to_tz
------------------------+---------------------+------------------------
 2026-10-01 00:00:00+00 | 2026-10-01 09:00:00 | 2026-10-01 00:00:00+00

AT TIME ZONE は型を行き来させる演算子です。timestamptz AT TIME ZONE 'Asia/Tokyo' は「時点を JST の文字盤にする(結果は timestamp)」、timestamp AT TIME ZONE 'Asia/Tokyo' は「文字盤を JST として読んで時点にする(結果は timestamptz)」で、方向を逆にすると 9 時間ずれます。

MySQL:DATETIME と TIMESTAMP の違いを実行結果で見る

MySQL では TIMESTAMP が「UTC の時点」、DATETIME が「文字盤の数字」です。 PostgreSQL の timestamptz / timestamp と同じ関係ですが、TIMESTAMP には 2038 年の上限があります。MySQL 8.0.46 で同じ 3 種類の INSERT を試します(オフセット付き文字列の挿入は 8.0.19 以降で使えます)。

CREATE TABLE tz_demo (id INT AUTO_INCREMENT PRIMARY KEY, label VARCHAR(40),
  ts_col TIMESTAMP NULL,   -- 保存時にUTCへ変換、取り出し時にセッションのtime_zoneへ変換
  dt_col DATETIME NULL);   -- 文字盤をそのまま保存(変換なし)

SET time_zone = '+09:00';
INSERT INTO tz_demo (label, ts_col, dt_col) VALUES
  ('JSTセッション/オフセットなし', '2026-10-01 09:00:00',       '2026-10-01 09:00:00'),
  ('JSTセッション/+09:00付き',     '2026-10-01 09:00:00+09:00', '2026-10-01 09:00:00+09:00'),
  ('JSTセッション/Z付き(+00:00)', '2026-10-01 09:00:00+00:00', '2026-10-01 09:00:00+00:00');
SELECT id, label, ts_col, dt_col FROM tz_demo ORDER BY id;
SET time_zone = '+00:00';
SELECT id, label, ts_col, dt_col FROM tz_demo ORDER BY id;

実行結果(MySQL 8.0.46):

=== (1) セッション time_zone = +09:00 で INSERT ===
| id | label                          | ts_col              | dt_col              |
|  1 | JSTセッション/オフセットなし    | 2026-10-01 09:00:00 | 2026-10-01 09:00:00 |
|  2 | JSTセッション/+09:00付き        | 2026-10-01 09:00:00 | 2026-10-01 09:00:00 |
|  3 | JSTセッション/Z付き(+00:00)     | 2026-10-01 18:00:00 | 2026-10-01 18:00:00 |

=== (2) 同じ行を セッション time_zone = +00:00 で SELECT ===
|  1 | JSTセッション/オフセットなし    | 2026-10-01 00:00:00 | 2026-10-01 09:00:00 |
|  2 | JSTセッション/+09:00付き        | 2026-10-01 00:00:00 | 2026-10-01 09:00:00 |
|  3 | JSTセッション/Z付き(+00:00)     | 2026-10-01 09:00:00 | 2026-10-01 18:00:00 |
観察 TIMESTAMP(ts_col) DATETIME(dt_col)
オフセットなしの文字列を入れると セッションの time_zone で解釈し UTC に換算 数字をそのまま保存
+00:00 を付けると(8.0.19 以降) 尊重される(行 3) セッションの time_zone に換算した文字盤を保存(行 3 は 18:00。PostgreSQL の timestamp が無視するのとは違う)
SELECT 時のセッションを変えると 表示が変わる(時点は同じ) 変わらない
表示に出ないもの オフセットが表示されないので、値だけでは UTC か JST か判別できない 同じ

MySQL は SELECT 結果にオフセットが付きません。 「DATETIME に UTC で入れた」のか「JST で入れた」のかは値から判別できず、設計の約束で担保することになります。時点が本当に同じかは UNIX_TIMESTAMP() で確かめられ、セッション +00:00 で行 1 は UNIX_TIMESTAMP(ts_col) が 1790812800、UNIX_TIMESTAMP(dt_col) が 1790845200 と、32400 秒(9 時間)の差が出ました。これが「DATETIME に JST の文字盤を入れて UTC セッションで読む」とずれる仕組みです。

TIMESTAMP の 2038 年上限と DATETIME の範囲

SET time_zone = '+00:00';
INSERT INTO tz_demo (label, ts_col) VALUES ('上限ぎりぎり', '2038-01-19 03:14:07');          -- OK
INSERT INTO tz_demo (label, ts_col) VALUES ('上限を超える', '2038-01-19 03:14:08');          -- エラー
INSERT INTO tz_demo (label, dt_col) VALUES ('DATETIMEは9999年まで', '2099-12-31 23:59:59'); -- OK
ERROR 1292 (22007): Incorrect datetime value: '2038-01-19 03:14:08' for column 'ts_col' at row 1

TIMESTAMP の範囲は 1970-01-01 00:00:01 〜 2038-01-19 03:14:07 UTC で、MySQL 8.0 でも拡張されていません。 契約終了日や長期の予定など 2038 年を超えうる列には DATETIME(1000 年〜9999 年)を使います。「UTC の時点として扱いたいが 2038 年を超える」なら、DATETIME に UTC 固定で保存し、アプリ側で必ず UTC として読む設計が一般的です。

名前付きタイムゾーンにはタイムゾーン表のロードが必要

+09:00 のようなオフセットは常に使えますが、Asia/Tokyo のような IANA 名は mysql.time_zone_name などにデータが入っていないと使えません。初期化直後の状態では次のようになります。

mysql> SET time_zone = 'Asia/Tokyo';
ERROR 1298 (HY000): Unknown or incorrect time zone: 'Asia/Tokyo'
mysql> SELECT CONVERT_TZ('2026-10-01 00:00:00', '+00:00', '+09:00') AS by_offset,
    ->        CONVERT_TZ('2026-10-01 00:00:00', 'UTC', 'Asia/Tokyo') AS by_name;
| by_offset           | by_name |
| 2026-10-01 09:00:00 | NULL    |

名前付きゾーンの CONVERT_TZ はエラーではなく NULL を返すので、集計から行が静かに消えます。mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root mysql で OS の zoneinfo をロードすると(検証環境では 499 件)、名前付きゾーンが使えるようになりました。

Python(zoneinfo)で UTC と JST を変換する

Python では「aware な datetime(tzinfo あり)だけを扱う」が原則です。 標準ライブラリの zoneinfo(3.9 以降)で IANA 名を使い、変換は astimezone() に任せます。Python 3.11.15、TZ=UTC で実行しました。

from datetime import datetime, date, time, timedelta, timezone
from zoneinfo import ZoneInfo

JST = ZoneInfo("Asia/Tokyo")
UTC = timezone.utc

# 1. UTC(DBから取り出した値)→ JST で表示
stored_utc = datetime(2026, 10, 1, 0, 0, 0, tzinfo=UTC)
print("1 UTC→JST表示:", stored_utc.astimezone(JST).strftime("%Y/%m/%d %H:%M"))

# 2. JST の入力(フォーム)→ UTC に直して保存
wall = datetime.strptime("2026-10-01 09:00", "%Y-%m-%d %H:%M")   # naive(tzinfoなし)
aware_jst = wall.replace(tzinfo=JST)                             # 「この naive は JST の文字盤」と宣言
to_save = aware_jst.astimezone(UTC)
print("2 JST入力→UTC保存:", to_save.isoformat())

# 3. API へ返す ISO 8601(オフセット付き)/ 4. API から受け取る(3.11 以降は末尾 Z も可)
print("3 APIへ返す:", aware_jst.isoformat(), "/", to_save.isoformat().replace("+00:00", "Z"))
from_api = datetime.fromisoformat("2026-10-01T00:00:00Z")
print("4 API受信:", from_api, "->", from_api.astimezone(JST))

# 5. 「JST の 1 日」を UTC の半開区間に / 6. 2026 年 9 月分(JST)の範囲
target = date(2026, 10, 1)
start_utc = datetime.combine(target, time.min, tzinfo=JST).astimezone(UTC)
end_utc = (datetime.combine(target, time.min, tzinfo=JST) + timedelta(days=1)).astimezone(UTC)
print("5 JSTの1日(UTC):", start_utc.isoformat(), "<= ts <", end_utc.isoformat())
first = datetime(2026, 9, 1, tzinfo=JST)
next_first = datetime(first.year + (first.month // 12), first.month % 12 + 1, 1, tzinfo=JST)
print("6 9月分(UTC):", first.astimezone(UTC).isoformat(), "<= ts <", next_first.astimezone(UTC).isoformat())

# 7. サマータイムのある地域:同じ文字盤 09:00 でもオフセットが変わる
NY = ZoneInfo("America/New_York")
for d in (datetime(2026, 3, 7, 9, 0), datetime(2026, 3, 9, 9, 0)):
    aware = d.replace(tzinfo=NY)
    print("7 NY", aware.isoformat(), "-> UTC", aware.astimezone(UTC).strftime("%H:%M"))
1 UTC→JST表示: 2026/10/01 09:00
2 JST入力→UTC保存: 2026-10-01T00:00:00+00:00
3 APIへ返す: 2026-10-01T09:00:00+09:00 / 2026-10-01T00:00:00Z
4 API受信: 2026-10-01 00:00:00+00:00 -> 2026-10-01 09:00:00+09:00
5 JSTの1日(UTC): 2026-09-30T15:00:00+00:00 <= ts < 2026-10-01T15:00:00+00:00
6 9月分(UTC): 2026-08-31T15:00:00+00:00 <= ts < 2026-09-30T15:00:00+00:00
7 NY 2026-03-07T09:00:00-05:00 -> UTC 14:00
7 NY 2026-03-09T09:00:00-04:00 -> UTC 13:00

ポイントは 2 つです。naive な値には replace(tzinfo=JST) で「どこの文字盤か」を宣言してから astimezone() で変換すること(naive に直接 astimezone を呼ぶと実行環境のローカル時刻として扱われます)。そして、夏時間がある地域は同じ 09:00 でもオフセットが -05:00 と -04:00 に変わるので、固定オフセットではなく IANA 名で持つことです。春の切り替えで存在しない 2026-03-08 02:30(ニューヨーク)は zoneinfo でも PostgreSQL でも標準時 -05:00 として解釈され UTC 07:30 になり、秋の 2 回ある時刻は fold=0/1 で区別できます。

Node(Intl.DateTimeFormat と Date)で UTC と JST を変換する

JavaScript の Date は「UTC の時点(エポックミリ秒)」だけを持ち、タイムゾーンは持ちません。 表示は Intl.DateTimeFormat に timeZone を渡し、入力はオフセットを明示した文字列から作ります。Node 22.22.2、TZ=UTC で実行しました。

// Node 22、追加ライブラリなし
// 1. UTC(DB/APIの値)→ JST で表示
const stored = new Date("2026-10-01T00:00:00Z");                 // Z付き = UTC
const fmtJst = new Intl.DateTimeFormat("ja-JP", {
  timeZone: "Asia/Tokyo", year: "numeric", month: "2-digit", day: "2-digit",
  hour: "2-digit", minute: "2-digit", hour12: false,
});
console.log("1 UTC→JST表示:", fmtJst.format(stored));

// 2. JST の入力(<input type="datetime-local"> の値)→ UTC で保存
const input = "2026-10-01T09:00";
const asUtc = new Date(`${input}:00+09:00`);                      // 「JSTの文字盤」とオフセットを明示
console.log("2 JST入力→UTC保存:", asUtc.toISOString());           // toISOString は常に UTC(Z)

// 3. 任意のタイムゾーンの文字盤 → UTC(サマータイム地域でも使える汎用関数)
function zonedToUtc(y, mo, d, h = 0, mi = 0, s = 0, timeZone = "Asia/Tokyo") {
  const guess = Date.UTC(y, mo - 1, d, h, mi, s);                 // まず UTC の文字盤として作る
  const parts = Object.fromEntries(
    new Intl.DateTimeFormat("en-US", { timeZone, hourCycle: "h23", year: "numeric", month: "2-digit",
      day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" })
      .formatToParts(new Date(guess)).map(p => [p.type, p.value]));
  const seen = Date.UTC(+parts.year, +parts.month - 1, +parts.day, +parts.hour, +parts.minute, +parts.second);
  return new Date(guess - (seen - guess));                        // ずれ(= オフセット)を引く
}
console.log("3 JST 09:00 →", zonedToUtc(2026, 10, 1, 9, 0, 0, "Asia/Tokyo").toISOString());
console.log("3 NY  09:00(3/7, 標準時) →", zonedToUtc(2026, 3, 7, 9, 0, 0, "America/New_York").toISOString());
console.log("3 NY  09:00(3/9, 夏時間) →", zonedToUtc(2026, 3, 9, 9, 0, 0, "America/New_York").toISOString());
// 4. 「JST の 1 日」を UTC の半開区間に
console.log("4 JSTの1日(UTC):", zonedToUtc(2026, 10, 1).toISOString(), "<= ts <", zonedToUtc(2026, 10, 2).toISOString());
// 6. Temporal(Node 22 では未実装。対応環境なら 1 行で済む)
console.log("6 typeof Temporal:", typeof Temporal);
1 UTC→JST表示: 2026/10/01 09:00
2 JST入力→UTC保存: 2026-10-01T00:00:00.000Z
3 JST 09:00 → 2026-10-01T00:00:00.000Z
3 NY  09:00(3/7, 標準時) → 2026-03-07T14:00:00.000Z
3 NY  09:00(3/9, 夏時間) → 2026-03-09T13:00:00.000Z
4 JSTの1日(UTC): 2026-09-30T15:00:00.000Z <= ts < 2026-10-01T15:00:00.000Z
6 typeof Temporal: undefined

toISOString() は常に UTC(末尾 Z)を返すので、API へ送る値やログはこれで統一できます。zonedToUtc は「UTC の文字盤として作った Date を目的のタイムゾーンで表示し直し、そのずれ分を引く」だけの関数で、夏時間の切り替えにも追従します(NY が 14:00Z と 13:00Z)。なお new Date("2026-10-01T09:00:00") のようなオフセットなしの文字列はローカル時刻、new Date("2026-10-01") のような日付だけの文字列は UTC として解釈されるため(検証環境で TZ を変えると前者だけ結果が変わりました)、入力は必ず Z かオフセットを付けた形にそろえます。Temporal が使える環境なら Temporal.ZonedDateTime.from("2026-10-01T09:00[Asia/Tokyo]").toInstant() の 1 行で同じことができます(詳細は別記事「JavaScript Temporal 使い方」)。

「JST の 1 日」「月末締め」「DATE 列」「サマータイム」の扱い

JST の 1 日を範囲検索する SQL(インデックスが効く形)

「JST の 2026-10-01」は UTC では 2026-09-30 15:00:00 以上 2026-10-01 15:00:00 未満です。境界をオフセット付きリテラルで書き、列には関数をかけません。PostgreSQL に 5 件(9/30 23:59 JST 〜 10/2 00:00 JST)を入れて確認します。

SELECT label, ts_tz FROM tz_demo
WHERE ts_tz >= '2026-10-01 00:00:00+09:00'
  AND ts_tz <  '2026-10-02 00:00:00+09:00'
ORDER BY ts_tz;
     label      |         ts_tz
----------------+------------------------
 10/1 00:00 JST | 2026-09-30 15:00:00+00
 10/1 12:00 JST | 2026-10-01 03:00:00+00
 10/1 23:59 JST | 2026-10-01 14:59:59+00

9/30 23:59 JST と 10/2 00:00 JST が除かれ、半開区間(以上・未満)なので 23:59:59.999 のような端数を気にせずに済みます。 10 万行のテーブル(ordered_at timestamptz にインデックスあり)で実行計画を比べると違いがはっきりします。

-- A. 列をそのまま比較
EXPLAIN (COSTS OFF) SELECT count(*) FROM orders
WHERE ordered_at >= timestamptz '2026-10-01 00:00:00+09:00' AND ordered_at < timestamptz '2026-10-02 00:00:00+09:00';
-- B. 列に関数をかける
EXPLAIN (COSTS OFF) SELECT count(*) FROM orders
WHERE (ordered_at AT TIME ZONE 'Asia/Tokyo')::date = date '2026-10-01';
=== A ===
 Aggregate
   ->  Index Only Scan using idx_orders_ordered_at on orders
         Index Cond: ((ordered_at >= '2026-09-30 15:00:00+00'::timestamp with time zone) AND (ordered_at < '2026-10-01 15:00:00+00'::timestamp with time zone))
=== B ===
 Aggregate
   ->  Seq Scan on orders
         Filter: (((ordered_at AT TIME ZONE 'Asia/Tokyo'::text))::date = '2026-10-01'::date)

件数はどちらも 206 件で同じですが、A は Index Only Scan、B は Seq Scanです。MySQL(UTC 固定の DATETIME 列)でも考え方は同じで、境界だけを CONVERT_TZ で UTC に直します。

SELECT id, ordered_at_utc, CONVERT_TZ(ordered_at_utc, '+00:00', '+09:00') AS jst
FROM orders
WHERE ordered_at_utc >= CONVERT_TZ('2026-10-01 00:00:00', '+09:00', '+00:00')
  AND ordered_at_utc <  CONVERT_TZ('2026-10-02 00:00:00', '+09:00', '+00:00');
| id | ordered_at_utc      | jst                 |
|  2 | 2026-09-30 15:00:00 | 2026-10-01 00:00:00 |
|  3 | 2026-10-01 03:00:00 | 2026-10-01 12:00:00 |
|  4 | 2026-10-01 14:59:59 | 2026-10-01 23:59:59 |

MySQL の EXPLAIN でも、この形は type: range(インデックスの範囲検索)、DATE(CONVERT_TZ(ordered_at_utc, ...)) = '2026-10-01' のように列側に関数をかけると type: index(インデックス全走査)になりました。

日別集計は「どこの日付か」を明示する

セッションが UTC のまま ::date でキャストすると、JST の深夜 0 時台の行が前日に数えられます。

SELECT label, ts_tz::date AS utc_date, (ts_tz AT TIME ZONE 'Asia/Tokyo')::date AS jst_date
FROM tz_demo ORDER BY ts_tz;
     label      |  utc_date  |  jst_date
----------------+------------+------------
 9/30 23:59 JST | 2026-09-30 | 2026-09-30
 10/1 00:00 JST | 2026-09-30 | 2026-10-01
 10/1 12:00 JST | 2026-10-01 | 2026-10-01
 10/1 23:59 JST | 2026-10-01 | 2026-10-01
 10/2 00:00 JST | 2026-10-01 | 2026-10-02

utc_date では 10/1 00:00 JST の行が 9/30 に入り、jst_date では 10/1 に入っています。 日別集計の「件数が合わない」は、ほとんどがこのキャストの位置の違いです。GROUP BY には date_trunc('day', ts_tz, 'Asia/Tokyo')(第 3 引数でタイムゾーン指定)が使えます。

月末締め(JST の 1 か月)

月の範囲も「JST の月初 0 時以上、翌月初 0 時未満」を UTC に直して比べます。2026 年 9 月分なら 2026-08-31T15:00:00Z 以上 2026-09-30T15:00:00Z 未満です(Python の出力 6 と同じ値)。

-- 固定の月
SELECT count(*) AS september_jst FROM tz_demo
WHERE ts_tz >= timestamptz '2026-09-01 00:00:00+09:00' AND ts_tz < timestamptz '2026-10-01 00:00:00+09:00';
-- 「今月(JST)」の月初を求める:JSTの文字盤で切り捨ててから時点に戻す
SELECT date_trunc('month', now() AT TIME ZONE 'Asia/Tokyo') AT TIME ZONE 'Asia/Tokyo' AS this_month_start_utc;
 september_jst
---------------
             1
  this_month_start_utc
------------------------
 2026-09-30 15:00:00+00

5 件のうち 9 月に入るのは「9/30 23:59 JST」の 1 件だけ、という期待どおりの結果です。締め処理は「締めるタイムゾーン」をコードに明示し、サーバーの設定に頼らないようにすると、サーバーを移しても結果が変わりません。

DATE 列はタイムゾーンを持たない

date / DATE 型は「2026-10-01」という文字盤だけを保存し、セッションの time_zone を変えても変換されません(PostgreSQL・MySQL とも検証環境で確認)。

「日付」の種類 持ち方 理由
締め日・予定日・生年月日・公開日(業務上の日付) DATE 列 「その日」はタイムゾーンに関係なく決まる。timestamptz に 00:00 を入れると表示側の変換で前日になりうる
「JST で何日の出来事か」を時点から求める timestamptz を AT TIME ZONE 'Asia/Tokyo' してから ::date 時点から日付を出すには「どこの日付か」の指定が必要
「ユーザーの今日」 ユーザーのタイムゾーンで now() を変換してから日付にする 日本とニューヨークでは「今日」が違う時間帯がある

サマータイムのある地域の変換例

日本には夏時間がありませんが、海外拠点やユーザーがいるシステムでは避けて通れません。同じ「09:00」でも、切り替えの前後でオフセットが変わります。

SELECT timestamptz '2026-03-07 09:00:00 America/New_York' AS before_dst,
       timestamptz '2026-03-09 09:00:00 America/New_York' AS after_dst;
       before_dst       |       after_dst
------------------------+------------------------
 2026-03-07 14:00:00+00 | 2026-03-09 13:00:00+00

3 月 7 日の 09:00 は UTC 14:00(-05:00)、3 月 9 日の 09:00 は UTC 13:00(-04:00)です。夏時間のある地域の予定は、UTC の時点ではなく「文字盤 + IANA 名」で保存して実行時に変換すると、切り替え後も「現地の 09:00」を保てます。

DB サーバー・OS・アプリの timezone 設定を確認する

推奨は「サーバー側は全層 UTC、表示はアプリの境界でユーザーのタイムゾーンへ」です。 各層の確認コマンドと推奨値をまとめます。

層 確認コマンド 推奨値・補足
Linux(OS) cat /etc/timezone、ls -l /etc/localtime、date +%Z、timedatectl Etc/UTC(/etc/localtime -> /usr/share/zoneinfo/Etc/UTC)
環境変数 TZ(プロセス・コンテナ) echo $TZ TZ=UTC を明示。未設定だと OS 設定が使われ、コンテナと母艦で食い違いやすい。Dockerfile では ENV TZ=UTC
PostgreSQL SHOW timezone;、SELECT name, setting, source FROM pg_settings WHERE name IN ('TimeZone','log_timezone'); postgresql.conf で timezone = 'UTC'、log_timezone = 'UTC'。source が session なら接続側で SET されている
MySQL SELECT @@global.time_zone, @@session.time_zone, @@system_time_zone;、SELECT COUNT(*) FROM mysql.time_zone_name; my.cnf で default-time-zone = '+00:00'。既定の SYSTEM は OS 依存(検証環境では TZ=Asia/Tokyo の影響で system_time_zone が JST)
Python python3 -c "from datetime import datetime; print(datetime.now().astimezone().tzinfo)" naive を作らず aware で統一
Node node -e "console.log(Intl.DateTimeFormat().resolvedOptions().timeZone)" TZ=UTC。表示は Intl.DateTimeFormat の timeZone 指定、getHours() などローカル依存の API を避ける
Java java -XshowSettings:properties -version で user.timezone、または ZoneId.systemDefault() -Duser.timezone=UTC(検証環境では TZ=Asia/Tokyo だと ZoneId.systemDefault() が Asia/Tokyo、オプションを付けると UTC)
PHP php -r 'echo date_default_timezone_get();' php.ini の date.timezone = UTC(ini の設定は TZ より優先)
ブラウザ(表示側) Intl.DateTimeFormat().resolvedOptions().timeZone ユーザーのタイムゾーンとして取得し、プロフィールに保存して表示に使う

接続ごとの SET TIME ZONE に頼らないのがコツです。ORM、接続プール、BI ツールと接続元が増えるほど「どこかの接続だけ JST」が起きやすいため、サーバーを UTC にそろえ、SQL では AT TIME ZONE / CONVERT_TZ を明示します。

よくあるつまずきと直し方

1. 9 時間ずれる

原因のほとんどは「naive な値(タイムゾーン情報なし)が、実行環境のローカル時刻として解釈される」ことです。 同じコードを TZ だけ変えて実行すると結果が変わります。

from datetime import datetime, timezone
from zoneinfo import ZoneInfo
JST = ZoneInfo("Asia/Tokyo")

naive_utc = datetime(2026, 10, 1, 0, 0, 0)           # 「UTCのつもり」の naive
print("naive.astimezone(JST):", naive_utc.astimezone(JST).isoformat())   # ローカル時刻とみなされる
aware_utc = naive_utc.replace(tzinfo=timezone.utc)   # UTCだと宣言してから変換
print("aware.astimezone(JST):", aware_utc.astimezone(JST).isoformat())
try:
    print(aware_utc < naive_utc)                     # aware と naive は比較できない
except TypeError as e:
    print("TypeError:", e)
### TZ=Asia/Tokyo
naive.astimezone(JST): 2026-10-01T00:00:00+09:00   ← 9時間ずれる
aware.astimezone(JST): 2026-10-01T09:00:00+09:00
TypeError: can't compare offset-naive and offset-aware datetimes
### TZ=UTC
naive.astimezone(JST): 2026-10-01T09:00:00+09:00   ← たまたま合う
aware.astimezone(JST): 2026-10-01T09:00:00+09:00
直す前 直した後
datetime.utcnow()(naive)を保存・変換する datetime.now(timezone.utc)(aware)を使う
naive.astimezone(JST) naive.replace(tzinfo=timezone.utc).astimezone(JST) と、まず「どこの文字盤か」を宣言する
PostgreSQL の timestamp 列に Z 付きで入れる(無視される) 列を timestamptz にする、または UTC の文字盤に直してから入れる
MySQL の DATETIME に JST の文字盤を入れて UTC セッションで読む UTC 固定で入れ、読む側も UTC として解釈する
JS の getHours() で時刻を組み立てる Intl.DateTimeFormat(..., { timeZone }) で表示する。内部は toISOString()

2. 日付をまたぐ(件数が合わない、「今日」が違う)

JST の 0 時〜9 時は UTC では前日です。集計の日付境界を UTC のままにすると、JST の深夜 0 時台の行が前日に入ります。先ほどの utc_date と jst_date の違いがそのまま答えで、「どこの日付か」を AT TIME ZONE / date_trunc('day', ts, 'Asia/Tokyo') / CONVERT_TZ で明示します。「今日の注文」のような画面でも、ユーザーのタイムゾーンで「今日」の境界を計算してから UTC に直して検索します。

3. 文字列のまま比較・保存する

ISO 8601 の文字列は表記が違うと大小が逆転します。

const a = "2026-10-01T09:00:00+09:00";   // = 2026-10-01T00:00:00Z
const b = "2026-10-01T01:00:00Z";        // a より 1 時間あと
console.log("文字列比較 a < b:", a < b, "/ 時点比較:", new Date(a) < new Date(b));
文字列比較 a < b: false / 時点比較: true

PostgreSQL でも '2026-10-01 09:00:00+09:00' < '2026-10-01 01:00:00+00:00' を文字列として比べると f、timestamptz にキャストして比べると t でした。保存は日時型、比較も日時型で行い、文字列にするのは出口(API・画面)だけにします。

よくある質問

Q. timestamptz は「タイムゾーンを保存する型」ではないのですか? A. 違います。保存されるのは UTC に換算した時点だけで、入力に使った +09:00 などは残りません。SELECT のときにセッションの time_zone で表示し直しているだけです。「どのタイムゾーンで入力されたか」が必要なら、別の列に IANA 名を保存します。

Q. 日本国内向けのサービスなら、全部 JST で保存しても良いですか? A. 動きます。ただし「この列は JST の文字盤」という約束をサーバー設定や暗黙の前提で守り続けることになり、クラウド移行やコンテナ化でサーバーの既定が UTC になった瞬間に 9 時間のずれが出やすくなります。UTC で保存して境界で変換する形なら、設定に左右されずに同じ結果が得られます。

Q. MySQL の TIMESTAMP は使わない方が良いですか? A. 2038-01-19 03:14:07 UTC までの値しか入らないので、将来の日時が入りうる列には DATETIME(UTC 固定)が向きます。更新日時のように「今」しか入らない列なら TIMESTAMP で問題ありません。どちらでも SELECT 結果にオフセットは付かないので、アプリ側で「UTC として読む」ことを統一します。

Q. ユーザーのタイムゾーンはどう決めれば良いですか? A. ブラウザの Intl.DateTimeFormat().resolvedOptions().timeZone で IANA 名を取得し、プロフィール設定として保存して、表示のたびにその値で変換します。オフセットではなく IANA 名で持つと、夏時間のある地域でも季節ごとに正しいオフセットが使われます。

まとめ

  • 時点は UTC で保存し、表示する境界だけでユーザーのタイムゾーンへ変換する。 変換箇所は入口と出口の 2 か所に限定する
  • PostgreSQL は timestamptz(UTC の時点)、timestamp は文字盤そのまま。timestamp は Z 付きの入力を無視する
  • MySQL は TIMESTAMP が UTC の時点、DATETIME が文字盤。TIMESTAMP は 2038-01-19 03:14:07 UTC が上限なので、新規の時点列は DATETIME に UTC 固定が扱いやすい
  • API とログは ISO 8601 のオフセット付き(Z または +09:00)。オフセットなしの文字列と日付だけの文字列は解釈が環境で変わる
  • 「JST の 1 日」は境界を UTC に直した半開区間で検索する。列側に関数をかけると Seq Scan になる
  • 業務上の日付は DATE 列、繰り返し予定は「文字盤 + IANA 名」で持ち、サマータイム地域はオフセットではなく地域名で扱う
  • サーバー・DB・コンテナは全層 UTC にそろえ、確認コマンドで source や @@session.time_zone まで見る

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

(記載例)出典:株式会社RJC「タイムゾーン設計|UTC保存・JST表示をDB別実行結果で解説」

株式会社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で一緒に開発しながら、 成長を楽しみませんか?