Date Time API

July 13, 20265 min readUpdated 8/20/2026

Java has two date APIs. The old one — Date, Calendar, SimpleDateFormat — is mutable, not thread-safe, and confusing enough that it was replaced. The modern one, java.time, arrived in Java 8 and is what you should use for everything.

This matters more than most "prefer the new API" advice, because search results are full of the old one and it is not labelled as obsolete.

Pick the right type

The most important decision is which class your field should be, and it follows from one question: does this instant need a time zone to mean anything?

TypeHoldsUse for
LocalDatea date, no timebirthday, invoice date, due date
LocalTimea time, no dateopening hours, an alarm
LocalDateTimeboth, no zone"9am local, wherever you are" — a scheduled meeting
Instanta moment on the UTC timelinetimestamps — created_at, logs, anything you store
ZonedDateTimean instant plus a zonedisplaying a moment to a user in their zone
Duration / Periodan amount of time / of days"30 minutes", "2 months"

LocalDateTime is not a timestamp. It has no zone, so it does not identify a moment in history — two people in different countries writing down the same LocalDateTime mean two different instants. Using it for created_at is the most common mistake in this area.

Creating and reading

LocalDate today = LocalDate.now();
LocalDate release = LocalDate.of(2023, 9, 19);          // month is 1-based, unlike the old API
LocalDate parsed = LocalDate.parse("2026-08-20");       // ISO-8601 by default

System.out.println(release.getYear());        // 2023
System.out.println(release.getMonthValue());  // 9
System.out.println(release.getDayOfWeek());   // TUESDAY
System.out.println(release.isLeapYear());     // false

Instant now = Instant.now();                  // UTC, the thing to store
LocalTime opening = LocalTime.of(9, 30);

Note LocalDate.of(2023, 9, 19) — month 9 is September. The old Calendar used 0-based months, which caused a decade of off-by-one bugs. That is fixed.

Everything is immutable

LocalDate date = LocalDate.of(2026, 8, 20);

date.plusDays(1);                             // returns a new date — and discards it
System.out.println(date);                     // 2026-08-20, unchanged

LocalDate tomorrow = date.plusDays(1);        // assign the result
System.out.println(tomorrow);                 // 2026-08-21

Same rule as String: every method returns a new object. This is what makes the API thread-safe, and forgetting to assign the result is the mistake to watch for.

Date arithmetic

LocalDate date = LocalDate.of(2026, 8, 20);

System.out.println(date.plusWeeks(2));            // 2026-09-03
System.out.println(date.minusMonths(1));          // 2026-07-20
System.out.println(date.withDayOfMonth(1));       // 2026-08-01
System.out.println(date.plusMonths(1).withDayOfMonth(1).minusDays(1));  // end of this month

LocalDate other = LocalDate.of(2026, 12, 25);
System.out.println(date.isBefore(other));         // true
System.out.println(ChronoUnit.DAYS.between(date, other));   // 127

Period gap = Period.between(date, other);
System.out.println(gap.getMonths() + " months, " + gap.getDays() + " days");

Duration meeting = Duration.ofMinutes(90);
System.out.println(meeting.toHours());            // 1

Period is for calendar amounts (years, months, days) and Duration for clock amounts (hours, minutes, seconds). Adding one month to 31 January gives 28 or 29 February — the API picks the last valid day rather than overflowing, which is almost always what you want.

Compare with isBefore / isAfter / isEqual, not with ==.

Formatting and parsing

LocalDate date = LocalDate.of(2026, 8, 20);

System.out.println(date.toString());                       // 2026-08-20 (ISO — the default)

DateTimeFormatter uk = DateTimeFormatter.ofPattern("dd/MM/yyyy");
System.out.println(date.format(uk));                       // 20/08/2026

LocalDate back = LocalDate.parse("20/08/2026", uk);
System.out.println(back);                                  // 2026-08-20

DateTimeFormatter stamp = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
System.out.println(LocalDateTime.of(2026, 8, 20, 14, 30).format(stamp));

Pattern letters are case-sensitive and two pairs are routinely confused: MM is month while mm is minutes, and HH is 24-hour while hh is 12-hour. A format that prints "20/08/2026" correctly and then shows every time as 12:xx is nearly always hh where HH was meant.

Unlike SimpleDateFormat, a DateTimeFormatter is immutable and thread-safe, so it can and should be a static final constant. From the console bank app this site uses for examples:

private static final DateTimeFormatter DISPLAY = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");

One instance, created once, shared by every transaction in the application. Doing the same with a SimpleDateFormat would be a bug that appears only under concurrent load.

Time zones

Instant now = Instant.now();                       // UTC — no zone, just a moment

ZonedDateTime inLA = now.atZone(ZoneId.of("America/Los_Angeles"));
ZonedDateTime inTonga = now.atZone(ZoneId.of("Pacific/Tongatapu"));

System.out.println(inLA.getHour() + " vs " + inTonga.getHour());   // same instant, different clocks

LocalDateTime naive = LocalDateTime.of(2026, 8, 20, 9, 0);
Instant real = naive.atZone(ZoneId.of("America/Los_Angeles")).toInstant();   // now it is a moment
System.out.println(real != null);

Use region ids such as America/Los_Angeles, never fixed offsets like GMT-8 — the region id knows about daylight saving and the offset does not.

The rule that avoids nearly every time-zone bug: store and compute in UTC (Instant), convert to a zone only when displaying to a person. A database column should hold an instant; the user's zone is a presentation concern.

The questions you will actually ask

Four things come up constantly, and each has a one-liner rather than the loop you might reach for:

LocalDate birthday = LocalDate.of(1990, 5, 14);
LocalDate today = LocalDate.of(2026, 8, 20);

// Someone's age
System.out.println(Period.between(birthday, today).getYears());     // 36

// Is a date inside a range? (inclusive on both ends)
LocalDate from = LocalDate.of(2026, 1, 1), to = LocalDate.of(2026, 12, 31);
System.out.println(!today.isBefore(from) && !today.isAfter(to));    // true

// The next Monday, without arithmetic
System.out.println(today.with(TemporalAdjusters.next(DayOfWeek.MONDAY)));   // 2026-08-24

// Start and end of the month
System.out.println(today.withDayOfMonth(1));                        // 2026-08-01
System.out.println(today.with(TemporalAdjusters.lastDayOfMonth())); // 2026-08-31

TemporalAdjusters is worth knowing about specifically because the hand-written version of "next Monday" or "last working day of the month" is where the bugs live.

What not to use

// Legacy — avoid in new code
// Date date = new Date();                         // mutable, misleading toString
// SimpleDateFormat f = new SimpleDateFormat(...);  // NOT thread-safe: shared instances corrupt output
// Calendar c = Calendar.getInstance();             // 0-based months

// Converting at a boundary you do not control
Date legacy = Date.from(Instant.now());
Instant modern = legacy.toInstant();
System.out.println(modern != null);

You cannot always avoid the old classes — an older library may hand you a Date. Convert at the boundary with toInstant() and Date.from(), and keep java.time everywhere inside.

The SimpleDateFormat note is worth taking seriously: it is not thread-safe, and the standard mistake is a static final SimpleDateFormat shared across requests, which produces intermittently mangled dates that are very hard to reproduce.

Next

Next: StringJoiner — a small class for the very common job of building a delimited string.