SSerguey Asael Shinder
Java coding notes: the JVM, and writing software that lasts

Serguey Asael Shinder: LocalDate.plusMonths clamps to the last valid day, so adding months is not additive

· by Serguey Asael Shinder / Serguey Shinder

The javadoc for LocalDate.plusMonths describes the operation in three steps: add the months to the month-of-year field, check whether the resulting date would be invalid, and adjust the day-of-month to the last valid day if necessary. Its own example is 2007-03-31 plus one month: 2007-04-31 does not exist, so the result is 2007-04-30. minusMonths works the same way in the other direction.

That is a reasonable answer to "what is one month after 31 March?". The trap is that the clamping loses information. Once a date has been moved to the 30th or the 28th, the original day-of-month is gone, and later additions start from the clamped value.

I have not run this here - there is no JDK on the machine I write on - so the dates below are worked out from the three documented steps, not from output. Start from 31 January 2027, which is not a leap year:

LocalDate start = LocalDate.of(2027, 1, 31);
// 31 Jan -> 28 Feb -> 28 Mar
LocalDate twoSteps = start.plusMonths(1).plusMonths(1);
// 31 Jan -> 31 Mar
LocalDate oneStep = start.plusMonths(2);
Serguey Asael Shinder: LocalDate.plusMonths clamps to the last valid day, so adding months is not additive
LocalDate.plusMonths clamps to the last valid day, so adding months is not additive — Serguey Asael Shinder

The two lines differ by three days. The same thing happens in any loop that builds a schedule by repeatedly adding a month to the previous result:

LocalDate due = start;
for (int i = 0; i < 12; i++) {
    // Feb 28, Mar 28, Apr 28, ... always the 28th
    due = due.plusMonths(1);
}

After February, every due date is the 28th, for the rest of the schedule. Nothing throws, nothing logs, and the first month looks right, which is why the bug usually surfaces as a customer asking why their payment date moved.

The fix is to compute every date from the anchor rather than from the previous result:

for (int i = 1; i <= 12; i++) {
    // Feb 28, Mar 31, Apr 30, May 31, ...
    LocalDate due = start.plusMonths(i);
}

Each date is clamped once, against its own month, and the 31st comes back whenever the month has one. plusYears has the same property on 29 February. If a schedule matters, store the anchor date and the index, not the last date you produced, and write the test with a start date of the 31st - a test that starts on the 1st or the 15th cannot see the problem.