第8章:Go × MySQL のタイムゾーン設計(JST / UTC)

Go
Golang
Backend
MySQL
Timezone

はじめに

タイムゾーン周りはハマりやすい。DBはUTC、アプリはJSTで統一して、変換はドライバとプラグインに任せるのが楽。意識的にUTCに変換するコードを書かないのがポイント。


基本方針

レイヤータイムゾーン備考
MySQL(DB)UTCMySQL はタイムゾーン設定で UTC を指定
Go アプリケーションJSTコード上では常に JST で扱う
フロントへの返却RFC33392024-11-19T10:30:00+09:00 形式

データフロー

[フロント]
  ↓ RFC3339 形式 (例: "2024-11-19T10:30:00+09:00")
[API - CustomTime 型でパース]
  ↓ JST として扱う (2024-11-19 10:30:00 +0900 JST)
[DB 保存]
  ↓ MySQL ドライバが自動的に UTC に変換 (2024-11-19 01:30:00 UTC)
[MySQL (UTC 格納)]
  ↓ GORM クエリ実行後 (2024-11-19 01:30:00 +0000 UTC)
[JSTPlugin - gorm:after_query]
  ↓ time.Time フィールドを自動的に JST に変換 (2024-11-19 10:30:00 +0900 JST)
[API レスポンス]
  ↓ RFC3339 形式で返却 (例: "2024-11-19T10:30:00+09:00")
[フロント]

実装ルール

1. 時刻の取得には time.Now() を使う

// ❌ NG: MySQL の NOW() 関数は使用しない
query := "SELECT * FROM users WHERE created_at < NOW()"

// ✅ OK: Go コード側で time.Now() を使う
now := time.Now()
db.Where("created_at < ?", now).Find(&users)

2. UTC 変換しない

// ❌ NG: .UTC() で変換しない
now := time.Now().UTC()

// ✅ OK: JST のまま使う(MySQL ドライバが自動変換)
now := time.Now()

CustomTime 型

time.Time の代わりに types.CustomTime を使うと、フロントとの時刻のやり取りが安全になる。

type Response struct {
    CreatedAt types.CustomTime `json:"created_at"`
    UpdatedAt types.CustomTime `json:"updated_at"`
}

パース挙動(重要)

入力形式結果
RFC33392024-11-19T10:30:00+09:00タイムゾーン情報を無視し、値はそのまま JST に設定
RFC3339 (UTC)2024-11-19T10:30:00Z同上 (10:30 が JST として扱われる)
DateTime2024-11-19 10:30:00JST として扱う
DateOnly2024-11-192024-11-19 00:00:00 JST

ポイント: 入力タイムゾーンに関わらず、時刻の値(10:30)をそのまま JST として扱います
日本国内向けサービスでは、ユーザーが入力した時刻の値をそのまま使うのが自然なため。

CustomTimeってなんぞや?↓

  • CustomTimetime.Timeをラップした独自の型で、フロントから来るRFC3339形式の文字列を安全にパースできる。通常のtime.Timeと違い、タイムゾーン情報を無視して「時刻の値」をそのままJSTとして扱うのが最大の特徴。
  • 日本向けサービスでは「10:30と書いてあれば日本時間の10:30」として扱うのが自然なので、この実装になっている。
// 入力: "2024-11-19T10:30:00+05:00" (UTC+5)
// ↓
// 出力: 2024-11-19 10:30:00 +0900 JST  ← 10:30 という値はそのまま JST

レスポンス時の出力

常に RFC3339 形式で出力されます:

2024-11-19T10:30:00+09:00

JSTPlugin(GORM プラグイン)

MySQL から取得した time.Time 値は UTC だが、JSTPlugin が gorm:after_query コールバックで自動的に JST に変換する。

// DB 初期化時に登録
db.Use(scope.NewJSTPlugin())
// 取得例
var user User
db.WithContext(ctx).First(&user)
// user.CreatedAt → 自動で JST に変換済み (2024-11-19 10:30:00 +0900 JST)

DB 接続文字列の設定

dsn := "user:password@tcp(host:3306)/dbname?parseTime=true&loc=Asia%2FTokyo"
パラメータ役割
parseTime=trueMySQL の DATETIME/TIMESTAMP を time.Time に変換
loc=Asia%2FTokyoGo での時刻解釈を JST に設定

parseTime=trueとloc=Asia/Tokyoってなんぞや?↓

  • parseTime=trueを付けないと、MySQLのDATETIMEカラムが[]byteで返ってきてtime.Time型に自動変換されない。
  • loc=Asia/Tokyoは、GoMySQLdriversがDBから取得した時刻をどのタイムゾーンで解釈するかの設定。JSTを指定することでGo側でJST時刻として扱える。

トラブルシューティング

時刻のズレが起きたときに確認する順番。たいてい接続文字列とDBのタイムゾーン設定のどちらかが抜けている。

1. DB タイムゾーン確認

SELECT @@global.time_zone, @@session.time_zone;
-- 結果が 'UTC' であることを確認

2. Go のタイムゾーン確認

loc, _ := time.LoadLocation("Asia/Tokyo")
fmt.Println(time.Now().In(loc))

3. 接続文字列確認

  • parseTime=true が含まれているか
  • loc=Asia%2FTokyo が含まれているか

よくある間違い

間違い症状解決策
UTC / JST 混在9 時間のズレこの方針に統一
文字列での時刻比較タイムゾーン情報が失われるtime.Time 型を使う
JSON 変換時の TZ 喪失フロントで時刻がずれるtypes.CustomTime を使う
入力 TZ をそのまま変換意図しない時刻になるCustomTime のパース挙動を理解する

まとめ

MySQL = UTC 格納
  ↕ MySQL ドライバが自動変換
Go アプリ = JST で処理
  ↕ CustomTime がパース / フォーマット
フロント = RFC3339 (JST) で通信
  • time.Now() のみ使用、.UTC() は使わない
  • フロントとのやり取りは types.CustomTime
  • DB ↔ アプリ間の変換は MySQL ドライバ + JSTPlugin が自動処理
  • 明示的なタイムゾーン変換コードは不要
← Return to blog
↑ Back to top