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?
| Type | Holds | Use for |
|---|---|---|
LocalDate | a date, no time | birthday, invoice date, due date |
LocalTime | a time, no date | opening hours, an alarm |
LocalDateTime | both, no zone | "9am local, wherever you are" — a scheduled meeting |
Instant | a moment on the UTC timeline | timestamps — created_at, logs, anything you store |
ZonedDateTime | an instant plus a zone | displaying a moment to a user in their zone |
Duration / Period | an 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.