PrismaとMySQLのタイムゾーン不整合問題とその対応

PrismaとMySQLのタイムゾーン不整合問題の原因と対応策を解説。DB自動生成・アプリ生成・SQL直書きの各パターンでJST統一する方法を紹介します。

はじめに

本記事では、PrismaとMySQLを組み合わせた際に発生しやすいタイムゾーン(特にJST/UTC)不整合の問題と、その対応策について解説します。

背景・前提

多くの日本の開発現場では、MySQLサーバーのタイムゾーン設定がJST(日本標準時)になっていることが一般的です。一方、Prisma(Node.js ORM)はDateTime型の値を「タイムゾーン情報なしのISO8601文字列」として扱い、DBサーバ側のタイムゾーン設定や値のタイムゾーンを自動で解釈・変換しません

特に、以下のような前提や制約がある場合に時刻の不整合が発生しやすくなります:

  • DBがクラウドのマネージドサービスで、DBサーバのタイムゾーン設定をJSTに変更できない、またはUTC固定運用が求められる場合
  • Prismaやアプリケーション側はUTCで時刻を扱うが、SQLを直接発行する運用や、DBのデフォルト値・トリガー等でJST時刻が生成される場合
  • チームやプロジェクトでSQLを直打ちする文化があり、ORMを介さずにデータを操作するケースが混在する場合

このような状況下で、アプリケーション・ORM・DB間で時刻のズレや不整合が発生しやすくなります。

実際に、時刻の生成や保存方法が複数存在することで、アプリケーション・ORM・DB間で時刻のズレや不整合が発生しやすくなります。主なパターンは次の3つです。

DB時刻生成・保存方法の主なパターン

1. DB自動生成

  • createdAtupdatedAtは、Prisma schemaの@default(now())によりUTCで自動生成される。
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @map("updated_at")

2. アプリ生成(new Date())

  • アプリケーション側でnew Date()(UTC)などを使って時刻を生成し、DBに保存するケース。
const now = new Date();
await prisma.someModel.create({
  data: {
    // ...他のフィールド
    deletedAt: now,
  },
});

3. DB/SQL直書き

  • SQLを直接発行し、DB側のON UPDATEDEFAULT CURRENT_TIMESTAMPなどの仕組みで、JST時刻が自動生成されるケース。

Prismaの3つの書き込み経路(Prisma schemaのnow()、アプリのnew Date()、DB自動生成/SQL直書き)が、それぞれNode.jsのUTCクロックとMySQLセッションのtime_zoneのどちらで時刻を生成し、DATETIME(3)カラムにタイムゾーン変換なしで格納されるかを示す図。対比として@db.Timestamp(3)カラムでは変換が働くことも示す

上図の通り、3つの経路は「時刻をどこで計算するか」で二分されます。①@default(now())と②アプリのnew Date()はNode.jsプロセスのクロック(UTC基準のエポック値)で計算されるのに対し、①bのdbgenerated("CURRENT_TIMESTAMP(3)")と③のDB/SQL直書きはMySQLサーバー自身がCURRENT_TIMESTAMP()関数で計算します。後者はMySQLのセッション変数time_zoneの設定に従って評価されるため、サーバーのtime_zoneがJSTであれば生成される文字列表現はJSTの壁時計値になります。

なぜズレるのか:now()とMySQLのCURRENT_TIMESTAMPは評価される場所が違う

ここが本記事の核心です。Prisma Clientが実際に発行するSQLをログで確認すると、この違いがそのまま見えます(検証環境・実測値は後述の「実機検証」章を参照)。

prisma:query INSERT INTO `some_model_prisma`
  (`created_at_now`,`updated_at_dbg`,`app_written`,`event_date`,`created_at_ts`)
  VALUES (?,?,?,?,?)

このINSERT文のカラムリストには、@default(now())を指定したcreated_at_now明示的に含まれています。つまりPrisma Clientは、now()をMySQL側のDEFAULT CURRENT_TIMESTAMP(3)句に委譲するのではなく、クエリエンジン内でnew Date()相当の値を計算し、バインドパラメータとして送信しています。一方、dbgenerated("CURRENT_TIMESTAMP(3)")を指定したcreated_at_dbgはこのカラムリストに含まれません。この値はMySQL側のDEFAULT句CURRENT_TIMESTAMP(3)だけに委ねられ、Prismaは一切関与しません。

まとめると:

経路値の計算場所基準タイムゾーンPrismaが送信する値
@default(now())Node.js(Prisma Client / クエリエンジン)常にUTC明示的なバインドパラメータ
@default(dbgenerated("CURRENT_TIMESTAMP(3)"))MySQLサーバーセッションのtime_zone送信されない(DB DEFAULTに委譲)
アプリのnew Date()Node.js常にUTC明示的なバインドパラメータ
DB/SQL直書き・トリガーMySQLサーバーセッションのtime_zonePrisma不関与

「DB自動生成の見直し」節で紹介するdbgeneratedへの変更は、まさにこの「値の計算場所をNode.jsからMySQLへ移す」変更であり、これによって初めてMySQLサーバーのtime_zone設定(JST)が時刻生成に反映されるようになります。裏を返すと、@default(now())のままではMySQLサーバーのタイムゾーン設定を変えても何の効果もありません(値は常にNode.jsのUTCクロックで決まるため)。

TIMESTAMPとDATETIMEの違い:どちらが変換されるのか

MySQLには時刻を格納するカラム型が2つあり、タイムゾーンの扱いが根本的に異なります( MySQL 8.0 Reference Manual: Date and Time Types )。

  • DATETIME:文字列としての壁時計値をそのまま格納します。書き込み時・読み出し時とも、セッションのtime_zoneによるタイムゾーン変換は一切行われません。PrismaのDateTimeスカラーは、MySQLでは@db.Timestamp(n)を明示しない限り常にDATETIME(n)にマッピングされます(後述の実機検証でDESCRIBEにより確認)。
  • TIMESTAMP:書き込み時にセッションのtime_zoneを使って値をUTCに変換し、内部的にはUTCエポックとして格納します。読み出し時には、その時点のセッションのtime_zoneを使ってUTCから逆変換した値を返します。つまり、同じ行を異なるtime_zone設定のセッションから読むと、表示される値が変わります

これが本記事の不整合の正体です。①bやSQL直書きで生成される値はMySQLのCURRENT_TIMESTAMP()関数がセッションのtime_zone(JST)に従って計算したJSTの壁時計値ですが、格納先がDATETIMEであるため、この値は「JSTである」という情報を一切保持せず、ただの文字列として保存されます。Prisma Clientはこれを読み出す際、MySQLのDATETIME値を無条件にUTCとして解釈し、末尾にZを付与したDateオブジェクトを構築します( Prisma公式ドキュメント: Type mappings between MySQL to Prisma schema 、および実機検証で確認)。その結果、実際にはJSTの壁時計値であるにもかかわらず、アプリケーション側ではそれが「UTCの瞬間」であるかのように扱われ、実時刻に対して9時間のズレが発生します。


主な対応策

どの経路(アプリ・ORM・DB)から時刻が生成・保存されても、最終的にJSTで一貫した時刻管理ができるように設計・運用します。

DB自動生成の見直し

Prisma schemaの@default値にdbgenerated("CURRENT_TIMESTAMP(3)")を採用し、JSTで時刻生成を行うように変更。

createdAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP(3)")) @map("created_at")
updatedAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)")) @map("updated_at")

補足:

  • dbgenerated("NOW(3)")も同様の動作となりますが、標準性や移植性の観点からはCURRENT_TIMESTAMPの利用を推奨します。
  • なお、PrismaのMySQL対応には既知のバグがあり、DATETIME(3)型のカラムに@default(dbgenerated("CURRENT_TIMESTAMP(3)"))(特にON UPDATE CURRENT_TIMESTAMP(3)を伴う場合)を設定すると、スキーマ変更がなくてもprisma migrate dev実行時に同内容のmigrationファイルが繰り返し生成される場合があります。 この場合、migrationファイルの内容を確認し、実質的な差分がない場合は手動で削除するなどの運用をする必要があります。この挙動は後述の「実機検証」章でPrisma 7.8.0でも実際に再現しています。
    • https://github.com/prisma/prisma/issues/11318 (MySQLのON UPDATE CURRENT_TIMESTAMP(3)dbgeneratedの組み合わせで再現。クローズ済みだが未解決のまま#12574に一本化)
    • https://github.com/prisma/prisma/issues/12574dbgeneratedによるDEFAULT値が終わりなくmigrationを生成し続ける問題の追跡issue。MySQL/PostgreSQL双方の報告あり。2025年1月にduplicateとして#10795に統合済み)
    • https://github.com/prisma/prisma/issues/10795 (現在の追跡issue。スキーマ・DBとも変更がないのにALTER TABLEを含むmigrationが繰り返し生成される問題。本記事執筆時点でOPENのまま)
    • 補足:本記事は以前#20586を典拠として挙げていましたが、これはPostgreSQLのcurrent_setting()をdbgeneratedで使うケース(行レベルセキュリティ向けの設定値取得)で発生する別の問題であり、本記事が扱うMySQLのCURRENT_TIMESTAMP起因の事象とは無関係でした。誤りを訂正します。

アプリ生成の補正

Prisma Clientの拡張($extends)で、書き込み時にUTC→JST、取得時にJST→UTCの補正を自動化。

const setOffsetTime = (object: any, offsetTime: number) => {
  if (object === null || typeof object !== "object") return;
  for (const key of Object.keys(object)) {
    const value = object[key];
    if (value instanceof Date) {
      object[key] = new Date(value.getTime() + offsetTime);
    } else if (value !== null && typeof value === "object") {
      setOffsetTime(value, offsetTime);
    }
  }
};

return new PrismaClient().$extends({
  query: {
    $allModels: {
      async $allOperations({ args, query }) {
        const offsetTime = 9 * 60 * 60 * 1000; // JST補正
        setOffsetTime(args, offsetTime);
        const result = await query(args);
        setOffsetTime(result, -offsetTime);
        return result;
      },
    },
  },
});

DB/SQL直書き

DBやSQLで直接時刻を生成・更新する場合も、MySQLサーバー自体のタイムゾーン設定がJSTで統一されていれば、DEFAULT CURRENT_TIMESTAMPON UPDATE CURRENT_TIMESTAMPによる自動生成時刻もJSTとなります。 そのため、アプリやORM経由でJSTに統一した時刻と、DB/SQL直書きで生成される時刻にズレが生じず、一貫性が保たれるため問題ありません。


実機検証:MySQL 9.7 + Prisma 7 で実際に確認する

ここまでの説明が実際のMySQL・Prismaの挙動と一致することを、ローカルに構築した実環境で検証しました。この節の数値・ログはすべて実際に実行して得たものであり、「典型的な結果」のような推測値は含みません。

検証環境

  • OS: macOS(sandboxのため今回はDockerが利用できず、Homebrewで直接MySQLをインストールして検証しました)
  • MySQL: 9.7.1(Homebrewのbottle版。mysqld_safeでTCP/Unixソケット両対応の一時インスタンスを起動し、検証後に完全にアンインストール・データディレクトリ削除まで実施済みです)
  • Prisma: 7.8.0 / @prisma/adapter-mariadb 7.8.0 / mariadb (npm) 3.5.3
  • MySQLのセッション・グローバルtime_zoneSET GLOBAL time_zone = '+09:00'; SET SESSION time_zone = '+09:00';でJSTに設定し、「日本の開発現場でDBサーバーのタイムゾーンがJSTになっている」という本記事の前提を再現しました。

なお、Prisma 7ではschema.prismadatasourceブロックにurlを直接書く方式が廃止され、接続はドライバアダプタ経由でprisma.config.tsから与える方式に変わっています(後述「最新動向」参照)。そのため今回はMySQL/MariaDB用の公式アダプタ@prisma/adapter-mariadbを使って接続しました。

3つの書き込み経路の実測:9時間のズレを再現する

以下のPrisma schemaで、3つの経路に対応するフィールドを持つモデルを定義しました。

model SomeModel {
  id           Int      @id @default(autoincrement())
  createdAtNow DateTime @default(now()) @map("created_at_now")
  createdAtDbg DateTime @default(dbgenerated("CURRENT_TIMESTAMP(3)")) @map("created_at_dbg")
  appWritten   DateTime? @map("app_written")
  eventDate    DateTime @db.Date @map("event_date")
  createdAtTs  DateTime @default(now()) @db.Timestamp(3) @map("created_at_ts")
}

npx prisma db push適用後にDESCRIBEで実カラム型を確認すると、@db.Timestamp(3)を明示したcreated_at_tsだけがTIMESTAMP(3)になり、それ以外はDateTimeスカラーのままでもDATETIME(3)になっていることが確認できました(前章で述べた「PrismaのDateTimeは明示しない限りDATETIME」という説明の裏付けです)。

Node.jsのプロセスタイムゾーンをTZ=UTCにして1件createした実測ログ(log: ["query"]で取得したそのままの出力):

=== new Date() from Node ===  2026-07-19T01:27:09.333Z

prisma:query INSERT INTO `some_model_prisma`
  (`created_at_now`,`updated_at_dbg`,`app_written`,`event_date`,`created_at_ts`)
  VALUES (?,?,?,?,?)

createdAtNow.toISOString() = 2026-07-19T01:27:09.429Z
createdAtDbg.toISOString() = 2026-07-19T10:27:09.431Z
createdAtTs.toISOString()  = 2026-07-19T01:27:09.429Z
appWritten.toISOString()   = 2026-07-19T01:27:09.333Z

createdAtDbgdbgenerated("CURRENT_TIMESTAMP(3)"))だけが、他の3フィールドからちょうど9時間進んだ値として返ってきています。これはMySQLがセッションのtime_zone(+09:00)に従ってJSTの壁時計値を計算し、それをDATETIME型にそのまま格納、Prisma ClientがそのDATETIME値を無条件にUTCとして解釈した結果です。つまりアプリケーションのログや画面には、実際にはJSTの時刻が「9時間先の未来のUTC時刻」として表示されてしまいます。

TIMESTAMPカラムとの比較実験:セッションのtime_zoneを変えて読む

同じ行を、MySQLクライアントのセッションtime_zoneを変えて生のSQLで読み直しました。

-- セッション time_zone = '+09:00' の場合
SELECT id, created_at_now, created_at_dbg, created_at_ts FROM some_model_prisma WHERE id=1;
-- id=1  created_at_now=2026-07-19 01:27:09.429  created_at_dbg=2026-07-19 10:27:09.431  created_at_ts=2026-07-19 01:27:09.429

-- セッション time_zone = '+00:00' の場合(同じ行を再読み込み)
SELECT id, created_at_now, created_at_dbg, created_at_ts FROM some_model_prisma WHERE id=1;
-- id=1  created_at_now=2026-07-19 01:27:09.429  created_at_dbg=2026-07-19 10:27:09.431  created_at_ts=2026-07-18 16:27:09.429

DATETIME型のcreated_at_nowcreated_at_dbgはセッションのtime_zoneを変えても一切値が変わりません(リテラルとして格納されているため)。一方TIMESTAMP型のcreated_at_tsは、セッションを+09:00から+00:00に変えただけで表示値が9時間シフトしました(2026-07-19 01:27:09.4292026-07-18 16:27:09.429)。これは、この値が内部的にはUTC 2026-07-18 16:27:09.429として格納されており、+09:00セッションではその9時間後(JST表現)である2026-07-19 01:27:09.429として表示されていた、ということを意味します。TIMESTAMP型を使う場合は、書き込み時と読み出し時でセッションのtime_zoneが一致していないと値がズレるという別種のリスクがあることが分かります。

日付のみのフィールド(@db.Date)の落とし穴

@db.Dateを指定したフィールドは、DB上は時刻情報を持たないDATE型ですが、Prisma ClientはこれをUTC 0時を指すDateオブジェクトとして返します。この値をNode.jsのDateのローカルタイムゾーン依存メソッド(getDate()など)で扱うと、Node.jsプロセスのタイムゾーン設定によって日付がずれることを実際に確認しました。

### TZ=UTC ###
eventDate.toISOString() = 2026-07-19T00:00:00.000Z
eventDate.getDate()     = 19   (UTCと一致)

### TZ=America/Los_Angeles ###
eventDate.toISOString() = 2026-07-19T00:00:00.000Z
eventDate.getDate()     = 18   (前日にズレる!)

### TZ=Asia/Tokyo ###
eventDate.toISOString() = 2026-07-19T00:00:00.000Z
eventDate.getDate()     = 19   (UTCと一致)

UTCより西側のタイムゾーン(例:米国西海岸)でNode.jsプロセスを動かすと、@db.Dateの値をローカル日付として扱った瞬間に日付が1日巻き戻ることが実測で確認できました。JSTはUTC+9でUTCより東側にあるため今回のデータでは問題が顕在化しませんでしたが、これはたまたまです。@db.Dateを扱う際は、getUTCDate()など明示的にUTC基準のメソッドを使うか、日付文字列(YYYY-MM-DD)として比較する実装にするべきです。

タイムゾーンをまたぐDST(夏時間)の影響

日本はDST(夏時間)を採用していないため、JST自体の変換オフセットは年間を通じて常に+9時間で一定です。しかし、$extendsで示した「オフセットを固定値で加減算する」補正方式は、DBサーバーやアプリケーションサーバーがDSTを採用する地域にある場合に破綻します。MySQLのCONVERT_TZで名前付きタイムゾーンの変換差を実測すると:

SELECT CONVERT_TZ('2026-07-19 10:00:00','Asia/Tokyo','America/New_York') AS ny_summer,
       CONVERT_TZ('2026-01-19 10:00:00','Asia/Tokyo','America/New_York') AS ny_winter;
-- ny_summer = 2026-07-18 21:00:00  (EDT, UTC-4 → Tokyoとの差13時間)
-- ny_winter = 2026-01-18 20:00:00  (EST, UTC-5 → Tokyoとの差14時間)

同じ「JSTの10:00」でも、変換先がDST採用地域だと夏季・冬季で変換結果が1時間変わります。本記事の$extendsのコード例のように、オフセットを9 * 60 * 60 * 1000のような固定ミリ秒値でハードコードする手法は、JST⇔UTCのようなDSTのない地域同士の変換であれば年間を通じて正しく動作しますが、そのままの発想をDST採用地域とのタイムゾーン変換に流用すると、季節によって1時間ズレる不具合を静かに埋め込むことになります。DSTをまたぐ変換が必要な場合は、固定オフセットではなくIntl.DateTimeFormatのタイムゾーン名指定や、MySQLのCONVERT_TZ(要mysql_tzinfo_to_sqlによるタイムゾーンテーブルのロード)など、暦に基づいた変換機構を使う必要があります。

$extends補正の往復動作を検証する

本記事で紹介した$extendsによる書き込み時+9時間・読み出し時-9時間の補正コードが、実際に正しく往復することも確認しました。

Node.js UTCクロック(書き込み時): 2026-07-19T01:31:25.868Z
$extends経由でPrisma Clientから読み出した値: 2026-07-19T01:31:25.869Z
DBに格納された生の値(セッションtime_zone=+09:00で読んだ場合): 2026-07-19 10:31:25.869

DBには9時間進んだJST壁時計値(10:31:25.869)がDATETIMEとして格納されていますが、$extendsでラップされたClientを介して読み出すと、9時間差し引かれて元のUTC時刻(01:31:25.869Z、末尾1msの差はnew Date()呼び出しタイミングの誤差)に復元されています。書き込み・読み出しの両方を必ず同じ$extends済みClient経由で行う限り、このアプローチは実際に機能することを確認できました。ただし前節のDSTの議論の通り、固定オフセットが安全なのはJSTのようにDSTを持たない基準タイムゾーンとの変換に限られます。

migrationループバグの再現

「DB自動生成の見直し」節で触れたdbgenerated起因のmigrationループも、Prisma 7.8.0で実際に再現しました。updatedAtDbg@default(dbgenerated("CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"))を設定したスキーマでprisma migrate dev --name initを実行後、スキーマファイルを一切変更せずに同じコマンドを再実行すると、以下の差分が新規migrationとして生成されました。

-- 2回目の `prisma migrate dev` が生成した migration.sql(schema.prisma は無変更)
ALTER TABLE `some_model_prisma` MODIFY `created_at_dbg` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    MODIFY `updated_at_dbg` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3);

スキーマファイルに変更がないにもかかわらず、Prisma Migrateの差分検出(シャドウDBとの比較)がdbgeneratedによるDEFAULT/ON UPDATE句を「差分あり」と誤判定し続けることが、2026年7月時点の最新バージョンでも確認できました。運用上は、このようなmigrationが生成された場合、実質的な差分がないことを確認した上で該当migrationファイルを削除するか、prisma migrate diffで意図した変更のみを反映する運用が必要です。


最新動向(2026年7月時点)

  • Prisma 7でのドライバアダプタ必須化:Prisma 7からはschema.prismadatasourceブロックにurlを直接書く方式が廃止され(Error: The datasource property 'url' is no longer supported in schema files)、MySQL/PostgreSQL/SQLiteいずれの接続もprisma.config.tsでドライバアダプタ(MySQL/MariaDBの場合@prisma/adapter-mariadb)を明示的に構成する方式に統一されました。これにより、タイムゾーンに関わる実際の型変換処理の一部が、Prisma自身のRustクエリエンジンではなく、選択したJSドライバ(mariadbパッケージなど)の実装に委ねられる場面が増えています。
  • @prisma/adapter-mariadb@db.Timestamp関連の未解決issue:2026年7月時点で、@prisma/adapter-mariadbにタイムゾーン付き接続オプション(timezone)を指定した状態で@db.Timestamp(3)カラムに書き込むと、オフセットが二重に適用されてズレるという未確認バグが報告されています( prisma/prisma#29096 )。ドライバアダプタ経由の接続でタイムゾーンオプションを使う場合は、必ず実データで書き込み・読み出しの往復を検証することを推奨します。
  • 本記事の対応策(dbgeneratedによるDB側JST統一、$extendsによるアプリ側補正)自体は、Prisma 7・ドライバアダプタ方式でも変わらず有効であることを、今回の実機検証で確認しています。

結果・まとめ

MySQLのDATETIMEはセッションのtime_zoneに関係なく壁時計値をそのまま保持し、TIMESTAMPはセッションのtime_zoneに基づいてUTCと相互変換するという、2つの型の根本的な違いがタイムゾーン不整合の直接の原因です。PrismaのDateTimeスカラーは明示しない限りMySQLのDATETIMEにマッピングされ、かつ@default(now())はNode.js側でUTCクロックとして評価される一方、dbgenerated("CURRENT_TIMESTAMP(3)")やDB/SQL直書きはMySQLサーバーのセッションtime_zoneで評価されるため、両者を混在させると9時間のズレが生じます。本記事で示した対応策——DB自動生成をJSTに統一するdbgeneratedパターン、アプリ側で$extendsによりUTC⇔JSTを補正するパターン——は、実際にMySQL 9.7 + Prisma 7.8.0の環境で書き込み・読み出しを実行して、狙い通りに機能することを確認しました。ただし@db.Dateのようなタイムゾーン非依存に見えるフィールドや、DSTを採用する地域をまたぐデプロイでは、固定オフセットの補正がそのままでは通用しない点に注意してください。


関連記事


関連ツール