🌊

Rails+MySQL migrationファイルで使用する日付型をまとめる

2025/01/12に公開

概要

- 前提
db/migrate/xxxxx_create_xxxxx.rb
上記の通りmigrate配下に作成する、db:migrateの対象となるファイルのことをmigrationファイルと
記載しています。
- 検証環境
Ruby 3.2
Rails 7.1.5.1
MySQL 8
- 今回の話題となるのは以下のデータ型
timestamp
datetime
timestamps

Railsでmigrationファイルを記述する際に、次のようなクラスを作成し、データ型を指定します。
db:migrateを実行した際に作成されるテーブルも併せて記述します。

# timestamp と datetime
class CreateUsers < ActiveRecord::Migration[7.1]
  def change
    create_table :users do |t|
      t.string :name, null: false
      t.string :email, null: false
      t.timestamp :created_at, null: false
      t.timestamp :updated_at, null: false
      t.datetime :deleted_at
    end
  end
end
# 作成されるテーブル
# timestampを指定するとMySQLもtimestampになる。
------------+--------------+------+-----+---------+----------------+
| Field      | Type         | Null | Key | Default | Extra          |
+------------+--------------+------+-----+---------+----------------+
| id         | bigint       | NO   | PRI | NULL    | auto_increment |
| name       | varchar(255) | NO   |     | NULL    |                |
| email      | varchar(255) | NO   |     | NULL    |                |
| created_at | timestamp    | NO   |     | NULL    |                |
| updated_at | timestamp    | NO   |     | NULL    |                |
| deleted_at | datetime(6)  | YES  |     | NULL    |                |
+------------+--------------+------+-----+---------+----------------+
# timestamps
class CreateUsers < ActiveRecord::Migration[7.1]
  def change
    create_table :users do |t|
      t.string :name, null: false
      t.string :email, null: false
      t.timestamps
      t.datetime :deleted_at
    end
  end
end
# 作成されるテーブル
# timestampsを使用すると、created_atとupdated_atがdatetime、かつNOT NULLで作られる。
+------------+--------------+------+-----+---------+----------------+
| Field      | Type         | Null | Key | Default | Extra          |
+------------+--------------+------+-----+---------+----------------+
| id         | bigint       | NO   | PRI | NULL    | auto_increment |
| name       | varchar(255) | NO   |     | NULL    |                |
| email      | varchar(255) | NO   |     | NULL    |                |
| created_at | datetime(6)  | NO   |     | NULL    |                |
| updated_at | datetime(6)  | NO   |     | NULL    |                |
| deleted_at | datetime(6)  | YES  |     | NULL    |                |
+------------+--------------+------+-----+---------+----------------+

この時、指定したデータ型でカラムが作成される訳ですが、日付を扱うデータ型の指定には上記の通り3つあります。

  1. timestamp
  2. datetime
  3. timestamps

この3つは何が違うのか、今回はそれを明確にしたく色々調べてみました。

結論

datetimeとtimestampsは何も変わりません。
timestampだけMySQLのカラムがtimestamp型になります。

解説

すっきりしない人のために調べたこと、検証したことを記述します。

datetime と timestamps

まずmigrationファイルで指定するデータ型である"datetime"と"timestamps"ですが、
デフォルトではこれらの指定により、MySQLに作成されるカラムのデータ型はどちらも"datetime"です。
つまり、何ら変わらないことになります。

timestamp

"timestamp"は、どうなるかというと
MySQLのテーブルではtimestamp型でカラムが作成されます。

timestampsはなぜデフォルトでdatetimeに変更されるのか

これはおそらく、MySQLの"timestamp"型には2038年問題があるからだ。と、世の生成AIは答えてくれます。

https://ja.wikipedia.org/wiki/2038年問題
2038年1月19日3時14分7秒(日本標準時では2038年1月19日12時14分7秒、閏秒は考慮していない)を過ぎると、この値がオーバーフローし[注釈 2]、もし時刻を正しく扱えていることを前提としたコードがあれば、誤動作する。

検証してみた

Rails consoleで、実際にそのような問題が起こるのか検証してみました。
最初に書いたmigrationファイルでテーブルを作成して、"timestamp"と"datetime"でそれぞれ境界値で更新した際にどういう挙動になるのか見ていきます。

timestamp

"timestamp"で定義したcreated_atを2038年1月19日3時14分7秒で更新する。

irb(main):024> User.find_by(id: 1).update(created_at: '2038-01-19 03:14:07')
  User Load (0.8ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
  TRANSACTION (0.3ms)  BEGIN
  User Update (0.7ms)  UPDATE `users` SET `users`.`created_at` = '2038-01-19 03:14:07', `users`.`updated_at` = '2025-01-11 13:59:18' WHERE `users`.`id` = 1
  TRANSACTION (6.1ms)  COMMIT
=> true

問題なく更新ができています。
次に、1秒後の2038年1月19日3時14分8秒で更新してみます。

irb(main):020> User.find_by(id: 1).update(created_at: '2038-01-19 03:14:08')
  User Load (0.8ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
  TRANSACTION (0.3ms)  BEGIN
  User Update (0.6ms)  UPDATE `users` SET `users`.`created_at` = '2038-01-19 03:14:08', `users`.`updated_at` = '2025-01-11 13:53:23' WHERE `users`.`id` = 1
  TRANSACTION (0.2ms)  ROLLBACK
(irb):20:in `<main>': Mysql2::Error: Incorrect datetime value: '2038-01-19 03:14:08' for column 'created_at' at row 1 (ActiveRecord::StatementInvalid)
...以下略

実際にエラーとなり、ROLL BACKされました。

datetime

"datetime"で定義した、deleted_atを境界値を過ぎた2038年1月19日3時14分8秒で更新してみます。

irb(main):021> User.find_by(id: 1).update(deleted_at: '2038-01-19 03:14:08')
  User Load (0.8ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
  TRANSACTION (0.3ms)  BEGIN
  User Update (2.6ms)  UPDATE `users` SET `users`.`updated_at` = '2025-01-11 13:58:26', `users`.`deleted_at` = '2038-01-19 03:14:08' WHERE `users`.`id` = 1
  TRANSACTION (5.6ms)  COMMIT
=> true

ちゃんと更新されました。
"datetime"では境界値を大幅に過ぎた2100年でも更新することが可能です。

irb(main):028> User.find_by(id: 1).update(deleted_at: '2100-01-19 03:14:07')
  User Load (0.6ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
  TRANSACTION (0.3ms)  BEGIN
  User Update (0.5ms)  UPDATE `users` SET `users`.`updated_at` = '2025-01-11 14:00:58', `users`.`deleted_at` = '2100-01-19 03:14:07' WHERE `users`.`id` = 1
  TRANSACTION (5.5ms)  COMMIT
=> true
mysql> select * from users;
+----+------------+-------------+---------------------+---------------------+----------------------------+
| id | name       | email       | created_at          | updated_at          | deleted_at                 |
+----+------------+-------------+---------------------+---------------------+----------------------------+
|  1 | test_user1 | example.com | 2038-01-19 03:14:07 | 2025-01-11 14:00:58 | 2100-01-19 03:14:07.000000 |
+----+------------+-------------+---------------------+---------------------+----------------------------+

Railsでどう扱われるか

MySQL上では"timestamp"と"datetime"に区別されますが、Rails上では取得したデータはどう扱われるかというと
どちらも"Time"型として扱われるので、Rails内での扱いに変化はありません。
※ timestampsで作成されたdatetimeのカラムも、取得したデータは"Time"となります。

+------------+--------------+------+-----+---------+----------------+
| Field      | Type         | Null | Key | Default | Extra          |
+------------+--------------+------+-----+---------+----------------+
| id         | bigint       | NO   | PRI | NULL    | auto_increment |
| name       | varchar(255) | NO   |     | NULL    |                |
| email      | varchar(255) | NO   |     | NULL    |                |
| created_at | timestamp    | NO   |     | NULL    |                |
| updated_at | timestamp    | NO   |     | NULL    |                |
| deleted_at | datetime(6)  | YES  |     | NULL    |                |
+------------+--------------+------+-----+---------+----------------+

irb(main):050> User.first.created_at.is_a?(Time)
  User Load (0.6ms)  SELECT `users`.* FROM `users` ORDER BY `users`.`id` ASC LIMIT 1
=> true
irb(main):053> User.first.deleted_at.is_a?(Time)
  User Load (0.7ms)  SELECT `users`.* FROM `users` ORDER BY `users`.`id` ASC LIMIT 1
=> true

まとめ

Railsのmigrationファイルにおいて、デフォルトでは"timestamps"も"datetime"も同じ扱いがされる。
明示的にtimestampを利用することも可能だが、2038年問題には注意が必要となる。

余談

  • Railsの慣習としては、migrationファイルのcreated_atとupdated_atは"timestamps"で定義する。
  • MySQLで"timestamp"は4byte、"datetime"は8byteなので、timestampの方が容量が半部で抑えられるが、それが2038年問題に繋がっているのだろう。
  • timestampはタイムゾーンの影響を受けるので、データをUTCで保存し、クエリ時にサーバーのタイムゾーン設定や接続クライアントのタイムゾーン設定に基づいてローカルタイムに変換される。
  • datetimeは文字列として保存されるので、タイムゾーンの影響を受けない。
  • もちろんユースケースによるが、基本的にはtimestampsとdatetimeを利用することになると思う。

by 株式会社DELTA
https://teamdelta.jp/


[CTO Booster]
https://costcut.cloud/

[VersionUp booster]
https://teamdelta.jp/lp/versionup


Discussion