LocalDate and LocalTime
Model calendar dates and wall-clock times with LocalDate and LocalTime, and know when a value with no time zone is the right choice.
LocalDate d = LocalDate.of(2026, 10, 13); // or LocalDate.parse("2026-10-13")
LocalTime t = LocalTime.of(9, 30);
LocalDate later = d.plusDays(30); // returns a new value
boolean early = t.isBefore(LocalTime.NOON);java.time splits what older code crammed into java.util.Date into separate types, each holding exactly one idea. LocalDate is a date on a calendar with no time and no zone: 2026-10-13. LocalTime is a time on a wall clock with no date and no zone: 09:30. Both are immutable and thread-safe, so you can keep them in static fields, share them between requests and use them as map keys.
When "local" is the right answer
Local means "not tied to any place". That fits values that people agree on as text, wherever they are: a date of birth, an invoice date, a public holiday, a shop's opening hour, the day a subscription renews. A birthday is 12 March for the person whatever server reads it; converting it to UTC would only add a chance of shifting it by a day.
It does not fit moments in time. "The order was placed at 09:30" means nothing until you know where. For those, use Instant or ZonedDateTime, covered in the next lessons.
Creating and changing values
LocalDate due = LocalDate.of(2026, 1, 31); // months are 1-12
LocalDate next = due.plusMonths(1); // 2026-02-28, a new object
LocalTime open = LocalTime.parse("08:30");
Every "change" returns a new value. Calling due.plusMonths(1); on its own line does nothing useful; you must keep the result. Month arithmetic clamps to the end of the month, so 31 January plus one month is 28 February, and adding another month gives 28 March, not 31 March.
Invalid values fail fast: LocalDate.of(2026, 2, 30) throws DateTimeException with the message Invalid date 'FEBRUARY 30'. That makes these types a good first line of validation for request input.
Mistakes people make
- Calling
LocalDate.now()with no argument. It uses the JVM's default zone, which is usually UTC in a container and something else on a laptop. A server in UTC thinks it is still yesterday for a user in Sydney. Pass a zone or aClock. - Comparing with
equalswhen you mean order. UseisBefore,isAfterandisEqual, orcompareTofor sorting. - Forgetting that
LocalTimewraps.LocalTime.of(23, 30).plusHours(1)is00:30, with no hint that the day changed. Opening hours that cross midnight need explicit handling.
Databases
Through JDBC 4.2, a SQL DATE column maps to LocalDate and TIME maps to LocalTime: rs.getObject("due_on", LocalDate.class). There is no need for java.sql.Date in new code.
Example
When to use it
- Store an invoice date or a user's date of birth as a SQL DATE read with getObject(column, LocalDate.class).
- Compute subscription renewal dates from the original start date so month-end customers are not drifted to the 28th.
- Check whether a store's pickup slot falls inside its opening hours before accepting a click-and-collect order.
More examples
Validating a date from a request
LocalDate.of rejects impossible dates itself, so the handler only adds the business rule. Passing today in, rather than calling LocalDate.now() inside, keeps the method deterministic and testable.
String validateDob(int year, int month, int day, LocalDate today) {
try {
LocalDate dob = LocalDate.of(year, month, day);
if (dob.isAfter(today)) {
return "400 date of birth is in the future";
}
return "200 " + dob;
} catch (DateTimeException e) {
return "400 " + e.getMessage();
}
}
void main() {
LocalDate today = LocalDate.of(2026, 10, 13);
IO.println(validateDob(1990, 3, 12, today));
IO.println(validateDob(2026, 2, 30, today));
IO.println(validateDob(2027, 1, 1, today));
IO.println(validateDob(2026, 13, 1, today));
}
// Output:
// 200 1990-03-12
// 400 Invalid date 'FEBRUARY 30'
// 400 date of birth is in the future
// 400 Invalid value for MonthOfYear (valid values 1 - 12): 13Monthly renewals without drift
Once February clamps the day to 28, chaining keeps it there for every later month. Computing start.plusMonths(n) each time renews on the last day of the month as the customer expects.
void main() {
LocalDate start = LocalDate.of(2026, 1, 31);
// Wrong: each step clamps, and the clamped day sticks.
LocalDate chained = start;
for (int i = 1; i <= 3; i++) {
chained = chained.plusMonths(1);
IO.println("chained " + i + ": " + chained);
}
// Right: always count from the original anchor date.
for (int i = 1; i <= 3; i++) {
IO.println("anchored " + i + ": " + start.plusMonths(i));
}
}
// Output:
// chained 1: 2026-02-28
// chained 2: 2026-03-28
// chained 3: 2026-04-28
// anchored 1: 2026-02-28
// anchored 2: 2026-03-31
// anchored 3: 2026-04-30Opening hours that cross midnight
A naive 'after opens and before closes' check is never true when closing time is earlier than opening time. Treating the overnight case separately keeps the rule correct and the end time exclusive.
record Hours(LocalTime opens, LocalTime closes) {
boolean isOpen(LocalTime t) {
if (opens.isBefore(closes)) {
return !t.isBefore(opens) && t.isBefore(closes);
}
// Overnight: open from opens to midnight, and from midnight to closes.
return !t.isBefore(opens) || t.isBefore(closes);
}
}
void main() {
Hours cafe = new Hours(LocalTime.of(8, 0), LocalTime.of(18, 0));
Hours bar = new Hours(LocalTime.of(18, 0), LocalTime.of(2, 0));
for (String s : List.of("07:59", "12:00", "23:15", "01:30", "02:00")) {
LocalTime t = LocalTime.parse(s);
IO.println(t + " cafe=" + cafe.isOpen(t) + " bar=" + bar.isOpen(t));
}
}
// Output:
// 07:59 cafe=false bar=false
// 12:00 cafe=true bar=false
// 23:15 cafe=false bar=true
// 01:30 cafe=false bar=true
// 02:00 cafe=false bar=false
Discussion