エラーの内容
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-01 は yyyy-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 を強制しよう。複数のフォーマットを受け入れることは今は柔軟に見える。だが半年後、また深夜にフォーマットの不一致をデバッグすることになる。

