tubo28's blog
つぼの 140 文字より長い文章置き場
About me

西暦 200 年が閏年かは MySQL と Ruby で食い違う

Date: / Lastmod:
Tags: calendar

この記事は、とあるサービスのフォームに 2002 年を 200 年 2 月と間違えて入力してきたユーザーがいて、閏年判定の違いによる 500 エラーを起こしたことをきっかけに調べて書きました。


西暦 200 年が閏年かどうかを MySQL で評価すると true となり、Ruby の Date は false となります。

SELECT IF(DAY(LAST_DAY('200-02-01')) = 29, 'true', 'false') AS result;
+--------+
| result |
+--------+
| false  |
+--------+
irb(main):002> Date.parse("200-02-01").leap?
=> true

これはどの暦で解釈するかの違いによるものです。

今我々が普通に使っている暦、つまりグレゴリオ暦の閏年ルールはこうなっています。

200 年は以上のルールに従うと平年です。

一方昔のユリウス暦の規則はシンプルに「4 で割り切れれば閏年、そうでなければ平年」なので、200 年は閏年です。

このようなユリウス暦のシンプルなルールは紀元前 45 年に始まった地球の公転が 365.25 日であることを仮定するものです。しかし実際には 265.25 日から僅かにズレており約 365.2422 日であるため、何百年も経つとズレが蓄積されて困ったことになりました。

そこで導入されたのがグレゴリオ暦で、1582 年 10 月 15 日に始まりユリウス暦から切り変わりました。


さて、現在から過去を見るとき、切り替えのタイミングより過去をグレゴリオ暦を過去に伸ばして見るか、ユリウス暦で見るかという選択があります。前者で計算するものを先発グレゴリオ暦と呼ぶそうです。後者はユリウス暦とのハイブリッドなどと呼べばいいのでしょうか。

最初の現象に戻ると、MySQL は先発グレゴリオ暦、Ruby はハイブリッドを採用しているということです。

Claude に適当に調べさせると、だいたいの言語は先発グレゴリオ暦を採用しています。Ruby は珍しくデフォルトでハイブリッドを採用しているようです。普通は実装を使い回せる先発グレゴリオ歴をデフォルトにしたくなると思いますが、Ruby ではわざわざ実装したのでしょう。まあそんなに昔の日付を扱うことはレアなので普通は問題になることは少なそう。


ユリウス暦からグレゴリオ暦への切り替えについてもっと詳しく。

グレゴリオ暦が導入された 1582 年当時、ユリウス暦とは 10 日間のズレがありました。切り替えではグレゴリオ歴を伸ばし、ユリウス暦の 10 月 5 日 = グレゴリオ暦の 10 月 15 日と定義して行われました。これはキリスト教の「ニカイア公会議 (325 年) 」当時の春分の日に季節を合わせるためでした。

なぜ当時としてもそんな昔の 325 年が基準かというと、イースター (復活祭) の計算ルールがそこで定まったからです。ニカイア公会議はキリスト教の重要な教義を定めた宗教会議で、その議題のひとつがイースターをいつ祝うかでした。ここで「春分の日以降、最初の満月の次の日曜日」という現在も使われているルールが採択され、その春分の日は 3 月 21 日とされました。ユリウス暦では当時の春分が確かに 3 月 21 日頃だったためです。

しかし、1582年の当時の天文観測能力でも、春分がずれていることには気づいていました。ユリウス暦では 128 年ごとに 1 日ずつ春分が早まっていきます。1582 年には実際の春分が 3 月 11 日頃になっており、325 年時点から約 10 日ずれていました。このままではイースターの日付が本来の季節感からどんどん離れていくため、教会としては放っておけませんでした。

グレゴリオ暦への改暦は、この 10 日のズレを一度に取り戻し、さらに閏年ルールを精密化して同じズレが再び蓄積しないようにするために行われました。1582 年の改暦では 10 月に 10 日を飛ばして 3 月 21 日の春分を取り戻し、以降はグレゴリオ暦の閏年ルールによってズレを 3000 年以上にわたってほぼ抑えることができるようになりました。

当時の観測技術でも現在の閏年判定のルールでほぼぴったりということが分かっていたのですね。400 年に 97 回の閏年なので 97/400 = 365.2425 となります。ほぼ 365.2422 です。


したがって先発グレゴリオ歴とユリウス暦のずれは、毎年 1 月 1 日で比べるとこのようにずれていきます。最初のズレが大きいので過去に遡るとだんだん小さくなっていき、300 年で一致し 200 年で追い抜くということです。

import datetime

# Start from Julian Jan 1, 1582 (Gregorian Oct 15, 1582 minus 277 days)
g_date = datetime.date(1582, 10, 15) - datetime.timedelta(days=277)

found_0 = None
found_neg1 = None

# Print initial discrepancy for 1582
print(f"Discrepancy in 1582: {(g_date - datetime.date(1582, 1, 1)).days} days")

# Traverse backward to Year 1
for y in range(1582, 0, -1):
    diff = (g_date - datetime.date(y, 1, 1)).days

    # Catch the target discrepancies
    if diff == 0 and found_0 is None:
        found_0 = y
    if diff == -1 and found_neg1 is None:
        found_neg1 = y

    # Move to previous year's Jan 1 using Julian leap rule
    prev_y = y - 1
    if prev_y < 1:
        break
    days = 366 if prev_y % 4 == 0 else 365
    try:
        g_date -= datetime.timedelta(days=days)
    except OverflowError:
        break # Stops before reaching Gregorian Year 0

print(f"First year with 0-day gap: {found_0}")
print(f"First year with -1-day gap: {found_neg1}")
Discrepancy in 1582: 10 days
First year with 0-day gap: 300
First year with -1-day gap: 200

ちなみにグレゴリオ歴 2026-05-22 現在はユリウス暦で 2026 年 5 月 9 日、13 日のずれです。

ちなみに ISO 8601 では明確に先発グレゴリオ暦を採用しています。なのであえてユリウス暦だけ年月日で書いています。