JAVA / DATES, NUMBERS, REGEX AND CORE LIBRARIES
LocalDate, LocalTime and modelling calendar dates
Model calendar dates and wall-clock times with LocalDate, LocalTime and LocalDateTime, and predict how month arithmetic clamps and time arithmetic wraps.
What you will learn
- Choose LocalDate, LocalTime or LocalDateTime by which fields the value actually has
- Chain plus/minus/with calls and assign the result; Local* values are immutable
- Predict day-of-month clamping and avoid drift by recomputing from a fixed anchor
- Use MonthDay, YearMonth and TemporalAdjusters for recurring and month-relative dates
Understanding LocalDate, LocalTime and modelling calendar dates
LocalDate is a year, month and day in the ISO calendar and nothing else: no hour, no offset, no zone. That makes it the right type for facts a calendar states rather than moments that pass, such as a date of birth, an invoice date or a public holiday. LocalTime is the mirror image, an hour-minute-second-nanosecond reading on a wall clock with no date attached, which is how you model "the shop opens at 09:30" for every day of the year. LocalDateTime joins the two and is still only a description; on its own it cannot tell you which moment it refers to, and that is deliberate.
All three are immutable value types with private constructors, so you create them through factories like of and parse and derive new ones with plusDays, minusWeeks or withDayOfMonth. Every one of those methods returns a new object and leaves the receiver untouched, so a call whose result you throw away does nothing at all. The accessors are human-numbered rather than array-indexed: getMonthValue() returns 1 for January, and the Month and DayOfWeek enums exist so you rarely pass a bare int. of() also validates immediately, so asking for 30 February throws DateTimeException instead of quietly sliding into March.
Arithmetic follows calendar rules, not fixed-length units. plusMonths keeps the day-of-month when the target month has one and otherwise clamps to that month's last day, so 31 January plus one month is 28 or 29 February depending on the year, and subtracting that month again does not bring back the 31st. LocalTime has no date to carry into, so its arithmetic wraps modulo 24 hours and discards the overflow: 23:30 plus one hour is 00:30, which is now earlier in the day than where you started. When the roll-over matters, do the arithmetic on a LocalDateTime so the day can move with the time.
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.Month;
public class CalendarDates {
public static void main(String[] args) {
LocalDate release = LocalDate.of(2024, Month.JANUARY, 31);
LocalTime doorsOpen = LocalTime.of(9, 30);
System.out.println("date : " + release);
System.out.println("day of week : " + release.getDayOfWeek());
System.out.println("day of year : " + release.getDayOfYear());
System.out.println("leap year : " + release.isLeapYear());
System.out.println("month length : " + release.lengthOfMonth());
LocalDate nextMonth = release.plusMonths(1);
System.out.println("plus 1 month : " + nextMonth);
System.out.println("minus 1 again: " + nextMonth.minusMonths(1));
System.out.println("original : " + release);
LocalDateTime opening = release.atTime(doorsOpen);
System.out.println("date + time : " + opening);
System.out.println("time wraps : " + doorsOpen.plusHours(20));
}
}LocalDate, LocalTime and LocalDateTime describe what a calendar or clock reads rather than a point on the timeline, so their arithmetic obeys calendar rules: adding months clamps the day and adding hours wraps at midnight.
Worked examples
Dates without a year, and month-relative rules
Shows MonthDay and YearMonth for recurring calendar facts, and a TemporalAdjuster for "the third Thursday".
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.MonthDay;
import java.time.YearMonth;
import java.time.temporal.TemporalAdjusters;
public class Recurring {
public static void main(String[] args) {
MonthDay leapDay = MonthDay.of(2, 29);
System.out.println(leapDay + " in 2024 -> " + leapDay.isValidYear(2024));
System.out.println(leapDay + " in 2025 -> " + leapDay.isValidYear(2025));
YearMonth billing = YearMonth.of(2025, 2);
System.out.println("days in " + billing + ": " + billing.lengthOfMonth());
System.out.println("last day: " + billing.atEndOfMonth());
LocalDate thirdThursday = LocalDate.of(2025, 5, 1)
.with(TemporalAdjusters.dayOfWeekInMonth(3, DayOfWeek.THURSDAY));
System.out.println("third Thursday of May 2025: " + thirdThursday);
}
}Example explained
Line 1MonthDay.of(2, 29) is legal because no year is involved yet; its ISO toString is --02-29.
Line 2isValidYear(2025) is false, which is how you decide whether a leap-day anniversary occurs at all in a given year.
Line 3YearMonth already fixes the year, so lengthOfMonth() answers 28 for 2025-02 and atEndOfMonth() hands back a real LocalDate.
Line 4dayOfWeekInMonth(3, THURSDAY) searches within the month of the receiver, so starting from May 1 or May 20 both yield 2025-05-15.
Ordering and equality of local values
Compares dates and times by order rather than by compareTo signs, and shows why nanoseconds break equals.
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.temporal.ChronoUnit;
public class CompareLocals {
public static void main(String[] args) {
LocalDate checkIn = LocalDate.of(2025, 7, 4);
LocalDate checkOut = LocalDate.of(2025, 7, 11);
System.out.println("checkIn before checkOut: " + checkIn.isBefore(checkOut));
System.out.println("same day: " + checkIn.isEqual(checkOut.minusDays(7)));
System.out.println("compareTo sign: " + Integer.signum(checkIn.compareTo(checkOut)));
LocalTime scan = LocalTime.of(8, 59, 59, 750_000_000);
System.out.println("scan: " + scan);
System.out.println("to minutes: " + scan.truncatedTo(ChronoUnit.MINUTES));
System.out.println("equals 08:59:59? " + scan.equals(LocalTime.of(8, 59, 59)));
System.out.println("open by 09:00? " + scan.isBefore(LocalTime.of(9, 0)));
}
}Example explained
Line 1isBefore gives the same ordering as compareTo but as a boolean, so there is no sign convention to remember.
Line 2checkOut.minusDays(7) produces a new value equal to checkIn, and isEqual compares the calendar fields.
Line 3scan prints as 08:59:59.750 because LocalTime stores nanoseconds and shows only the fractional digits it needs.
Line 4equals is false since 750 ms is part of the value; truncatedTo(MINUTES) is the explicit way to drop smaller fields before comparing.
Why month-end schedules drift
Contrasts feeding each result back into plusMonths with recomputing every date from the original anchor.
import java.time.LocalDate;
import java.time.temporal.TemporalAdjusters;
public class MonthEnd {
public static void main(String[] args) {
LocalDate start = LocalDate.of(2025, 1, 31);
LocalDate naive = start;
for (int i = 0; i < 3; i++) {
naive = naive.plusMonths(1);
System.out.println("naive " + naive);
}
for (int i = 1; i <= 3; i++) {
LocalDate anchored = start.plusMonths(i).with(TemporalAdjusters.lastDayOfMonth());
System.out.println("anchor " + anchored);
}
}
}Example explained
Line 1The first loop reassigns naive each round, so January's 31 is lost at February and 28 silently becomes the new day-of-month.
Line 2The second loop always starts from start, so March and April recover the 31st and the 30th.
Line 3Nothing throws here: plusMonths clamps to the last valid day, whereas LocalDate.of(2025, 2, 31) would be rejected outright.
Important notes
LocalDate.now() and LocalTime.now() consult the JVM's default time zone to decide which day and which wall time it currently is, so they are not as zone-free as their names suggest; pass an explicit zone or a fixed Clock when a test needs a deterministic answer.
These classes use the ISO proleptic calendar, so dates before the Gregorian switch are extrapolated and will not match Julian historical records.
Common mistakes
Reusing the previous result in a monthly loop: 2025-01-31 becomes 02-28 and then 03-28, so statement dates creep earlier and never return to the end of the month.
Treating LocalDateTime as a timestamp: it stores no offset, so the same value denotes different moments on different machines and cross-machine comparisons quietly give wrong answers.
Carrying over Calendar habits: LocalDate.of(2025, 0, 1) and LocalDate.of(2025, 2, 30) both throw DateTimeException instead of normalising, so migrated code crashes at the of() call rather than shifting the date.
Try it yourself
Change, predict, then run
Store a leap-day birthday as MonthDay.of(2, 29), then for each year from 2024 to 2028 print the year, isValidYear(year) and atYear(year) on one line. Note which years fall back to February 28 even though isValidYear said false.
Open the Java workspaceCheck your understanding
A gate closes at LocalTime.of(23, 30). The code computes close.plusMinutes(45) for a grace period and asserts that the result isAfter(close). Why does the assertion fail?
- plusMinutes(45) wraps past midnight to 00:15, and as a time-of-day 00:15 is before 23:30
- plusMinutes(45) throws DateTimeException because the result would leave the current day
- plusMinutes(45) mutates close, so both sides of the comparison are the same value
- LocalTime.isAfter compares only the hour field, so 00 and 23 are treated as equal
Show answer
LocalTime has no date to carry into, so arithmetic wraps modulo 24 hours and the overflowed day is discarded; ordering is then purely within a single day, making 00:15 earlier than 23:30. It does not throw - only invalid field values are rejected, never crossing midnight - and it cannot mutate close, since LocalTime is immutable. The fix is to do the arithmetic on a LocalDateTime so the day can advance with the time.