Java で java.time.format.DateTimeParseException: Invalid value for MonthOfYear を修正する

intermediate Java2026-06-12| Java 8以降、任意のOS(Windows / Linux / macOS)、java.time APIを使用する任意のフレームワーク

Error Message

java.time.format.DateTimeParseException: Text '2023-13-01' could not be parsed: Invalid value for MonthOfYear (valid values 1 - 12): 13
#java#datetime#date-format

エラーの内容

java.time.format.DateTimeParseException: Text '2023-13-01' could not be parsed: Invalid value for MonthOfYear (valid values 1 - 12): 13

本番環境が落ち、ログに DateTimeParseException が大量に流れている。どこかアップストリームで月が13の日付文字列がパーサーをクラッシュさせている。JVMの言う通り、13月は存在しない。しかし本当の原因は、完全に不正な日付であることはほぼない。フォーマットの不一致だ。コードが日のスロットを月のスロットとして読んでいる。

入力 2023-13-01yyyy-dd-MM(年-日-月)という構造になっている。Javaのデフォルトパーサーや大半の DateTimeFormatter パターンは yyyy-MM-dd を期待している。13という日の値が月の位置に入り、パースが失敗する。

根本原因

  • フォーマットの不一致:パターンは yyyy-MM-dd なのに、入力が yyyy-dd-MM。これが圧倒的に多い原因だ。
  • アップストリームの不正データ:外部API、CSVインポート、レガシーDBカラムがISO以外のフォーマットで日付をシリアライズしており、受け取り時に誰もバリデーションしていない。
  • ヨーロッパ式と米国式の日付順序dd/MM/yyyy(EU)と MM/dd/yyyy(US)。日の値が12を超えると、パースは即座に明確に失敗する。
  • 未検証のユーザー入力:ユーザーが誤って入力し、チェックなしでパーサーに渡ってしまった。

手順ごとの修正方法

ステップ1:入力の実際のフォーマットを確認する

まだコードに触れないこと。まず、文字列が実際にどのフォーマットなのかを確認する。2023-13-01 を受け取っている場合、13は日のはずだ。日は31まであるが、月は12までしかない。つまりパターンは yyyy-dd-MM だ。ソースが明確でない場合は、パース呼び出しの直前に生の入力をログに記録しよう。

ステップ2:フォーマッターを入力構造に合わせる

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

public class DateParseExample {
    public static void main(String[] args) {
        String input = "2023-13-01";

        // 誤り — デフォルトのISOパーサーはyyyy-MM-ddを期待する
        // LocalDate.parse(input); // DateTimeParseExceptionがスローされる!

        // 正解 — パターンを実際の入力構造に合わせる
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-dd-MM");
        LocalDate date = LocalDate.parse(input, formatter);
        System.out.println(date); // 2023-01-13
    }
}

ステップ3:信頼できない入力のパースをtry-catchで囲む

ユーザー、API、ファイルから来る日付は、ラップせずにパースすべきでない。次のように囲もう:

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;

public LocalDate parseDateSafely(String raw, String pattern) {
    try {
        return LocalDate.parse(raw, DateTimeFormatter.ofPattern(pattern));
    } catch (DateTimeParseException e) {
        // 生の値をログに記録する — データソースの追跡に必要
        log.error("Failed to parse date '{}' with pattern '{}': {}", raw, pattern, e.getMessage());
        throw new IllegalArgumentException("Invalid date format: " + raw, e);
    }
}

ステップ4:複数の入力フォーマットに対応する

複数のソースからデータを取得する場合によく起きるシナリオだ。CSVエクスポート、レガシーAPI、サードパーティの連携では、日付フォーマットが揃っていることはほとんどない:

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.List;

public LocalDate parseFlexible(String raw) {
    List<String> patterns = List.of(
        "yyyy-MM-dd",   // ISO 8601 — 最初に試す
        "yyyy-dd-MM",   // 日/月が逆転
        "dd/MM/yyyy",   // ヨーロッパ式
        "MM/dd/yyyy",   // アメリカ式
        "dd-MM-yyyy"
    );

    for (String pattern : patterns) {
        try {
            return LocalDate.parse(raw, DateTimeFormatter.ofPattern(pattern));
        } catch (DateTimeParseException ignored) {
            // 次のパターンを試す
        }
    }
    throw new IllegalArgumentException("Cannot parse date string: " + raw);
}

注意2023-05-06 のような日付は曖昧だ。yyyy-MM-dd(5月6日)と yyyy-dd-MM(6月5日)の両方に一致する。パターンは期待度の高い順から低い順に並べ、各データソースが実際に使うフォーマットをドキュメント化しておこう。

ステップ5:APIの境界でバリデーションする(Spring Boot)

RESTエンドポイントは不正な入力をブロックするのに最適な場所だ。サービス層に到達する前にSpringに弾かせよう:

@RestController
public class ReportController {

    @GetMapping("/reports")
    public List<Report> getReports(
        @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
        @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to
    ) {
        // 不正な日付に対してSpringが自動的に400 Bad Requestを返す
        // サービスが不正な入力を受け取ることはない
        return reportService.find(from, to);
    }
}

修正の確認

次のスニペットを実行して、問題のある入力が正しくパースされることを確認しよう:

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

public class VerifyFix {
    public static void main(String[] args) {
        DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-dd-MM");

        LocalDate result = LocalDate.parse("2023-13-01", fmt);
        System.out.println("Parsed: " + result);               // Parsed: 2023-01-13
        System.out.println("Month: " + result.getMonthValue()); // Month: 1
        System.out.println("Day: " + result.getDayOfMonth());   // Day: 13

        assert result.equals(LocalDate.of(2023, 1, 13)) : "Unexpected result";
        System.out.println("Fix confirmed.");
    }
}

期待される出力:

Parsed: 2023-01-13
Month: 1
Day: 13
Fix confirmed.

補足Tips

パース前に必ず生の値をログに記録する

深夜2時にこのエラーで一番辛いのは、問題の文字列がどこから来たか突き止めることだ。修正自体は難しくない。信頼できない入力の全パース呼び出しの前にログを1行入れておくだけで、その苦労が省ける:

log.debug("Parsing date string: '{}'", rawDateString);
LocalDate date = LocalDate.parse(rawDateString, formatter);

より厳密なバリデーションにはResolverStyle.STRICTを使う

DateTimeFormatter はデフォルトでSMARTモードで動作する。これは範囲外の値を例外なく黙ってクランプすることを意味する。2月30日は警告なく2月28日になる。そのようなケースを検知するにはSTRICTに切り替えよう:

import java.time.format.ResolverStyle;

DateTimeFormatter strictFormatter = DateTimeFormatter
    .ofPattern("uuuu-MM-dd")  // STRICTモードには'y'の代わりに'u'が必要
    .withResolverStyle(ResolverStyle.STRICT);

LocalDate date = LocalDate.parse("2023-02-30", strictFormatter); // 例外がスローされる

タイムスタンプ変換のデバッグ

不正な日付の発生元を追跡するとき、特にUnixタイムスタンプや外部APIからの場合は、ToolCraftのTimestamp Converter をタブで開いておくようにしている。UnixエポックやISO文字列を貼り付けると、何もアップロードせずに人間が読める形式で即座に表示される。データソースが実際に送った値とパーサーが期待した値を照合するときに役立つ。

システム境界ではISO 8601に統一する

APIの仕様を自分でコントロールできるなら、OpenAPIスペックやドキュメントで yyyy-MM-dd を強制しよう。複数のフォーマットを受け入れることは今は柔軟に見える。だが半年後、また深夜にフォーマットの不一致をデバッグすることになる。

Related Error Notes