A shared SimpleDateFormat parsed yesterday’s timestamp as next month. A Calendar you passed into a helper came back with the month already incremented. Both bugs are the same class of problem: mutable date types, plus a formatter that was never thread-safe.
java.time is the replacement that shipped in Java 8. Every type is immutable. The formatter is immutable. The timeline and the calendar are different types, so you stop treating “a moment” and “a birthday” as the same thing.
Store an Instant (or UTC) at the boundary; show a LocalDate in the user’s zone at the edge.
The problem Calendar and SimpleDateFormat leave you
java.util.Date is a number of milliseconds since the epoch, but it is mutable and its toString pretends to know a timezone. Calendar is worse: months are zero-based, add mutates the receiver, and two threads sharing one instance race.
Calendar cal = Calendar.getInstance();
cal.set(2026, Calendar.SEPTEMBER, 5, 18, 30, 0);
doSomething(cal);
// cal may no longer be 5 September — doSomething can mutate it
SimpleDateFormat is not thread-safe. The classic “optimisation” of a static formatter is a production bug:
private static final SimpleDateFormat ISO =
new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss"); // shared = racy
With java.time, the same intent does not mutate and does not need a lock:
LocalDateTime stamp = LocalDateTime.of(2026, 9, 5, 18, 30);
LocalDateTime later = stamp.plusHours(2); // stamp is still 18:30
DateTimeFormatter iso = DateTimeFormatter.ISO_LOCAL_DATE_TIME; // share freely
DateTimeFormatter is immutable and thread-safe. Put one in a static final and forget about it.
When it shipped
| Release | Status | Spec |
|---|---|---|
| Java 8 | Standard library | JSR-310 |
The design comes from Joda-Time. On Java 8+ you do not need that library: the types live in java.time, with java.time.format and java.time.zone beside them. No preview flag, no extra dependency.
Note: java.util.Date, Calendar, and SimpleDateFormat are still in the JDK. They are legacy. Convert at the edge when a library still speaks them; do not grow new code on them.
Mental model: Instant, Local, Zoned
Three ideas, three types. Mixing them is how “the date jumped by a day” bugs happen.
| Type | What it is | Zone? |
|---|---|---|
Instant | A point on the UTC timeline | Always UTC |
LocalDate / LocalTime / LocalDateTime | Calendar date and/or wall-clock time | None |
ZonedDateTime | Date + time + named zone (DST rules) | ZoneId |
OffsetDateTime | Date + time + fixed offset | ZoneOffset |
Instant is the timeline; LocalDate has no timezone. An Instant is “this nanosecond in UTC.” A LocalDate is “5 September 2026” — a civil date that only becomes a moment once you pick a zone.
LocalDateTime is the same trap with a clock on it: 2026-09-05T18:30 is not a unique instant. In America/New_York on a DST overlap it can be two instants; on a spring-forward gap it can be none.
ZonedDateTime is the combination you mean when you say “5 September 2026, 18:30 in Kolkata.” OffsetDateTime is the combination you mean when you already have +05:30 and do not need DST rules — typical for API timestamps.
Instant: the timeline
Reach for Instant whenever you mean “when did this happen?” — created-at, expires-at, log lines, database timestamptz.
Instant now = Instant.now();
Instant epoch = Instant.parse("2026-09-05T12:00:00Z");
Instant later = epoch.plus(Duration.ofHours(2));
Convert to a wall clock only when a human (or a zone-aware rule) needs it:
ZonedDateTime inNy = epoch.atZone(ZoneId.of("America/New_York"));
LocalDate nyDate = inNy.toLocalDate();
The reverse is localDate.atStartOfDay(zone).toInstant() — you must name the zone. There is no honest LocalDate.toInstant().
LocalDate: a civil date, not a moment
Birthdays, invoice due dates, “the report for 5 September” — these are calendar dates. They are not instants.
LocalDate due = LocalDate.of(2026, 9, 5);
LocalDate next = due.plusDays(1); // 2026-09-06; due unchanged
boolean overdue = LocalDate.of(2026, 9, 6).isAfter(due);
LocalDate.now() uses the JVM default zone. Two app servers in UTC and Asia/Kolkata can disagree about “today” around midnight. Prefer LocalDate.now(zone) or, better, LocalDate.now(clock) once you have a Clock.
ZonedDateTime and OffsetDateTime
Named zones carry DST. Offsets do not.
ZoneId kolkata = ZoneId.of("Asia/Kolkata");
ZonedDateTime meeting = ZonedDateTime.of(
LocalDateTime.of(2026, 9, 5, 18, 30),
kolkata);
OffsetDateTime wire = meeting.toOffsetDateTime(); // 2026-09-05T18:30:00+05:30
Instant onTheWire = meeting.toInstant();
Use ZonedDateTime for scheduling in a city. Use OffsetDateTime when you are serialising a timestamp you already resolved. Do not store a LocalDateTime in the database and hope every reader shares your JVM’s default zone.
Duration vs Period
Both are amounts. They are not interchangeable.
Duration | Period | |
|---|---|---|
| Counts | Seconds and nanos (fixed) | Years, months, days (calendar) |
| Typical between | Two Instants | Two LocalDates |
ofDays(1) means | Always 24 hours | One calendar day (23 or 25 hours across DST) |
Duration timeout = Duration.ofSeconds(30);
Duration elapsed = Duration.between(started, Instant.now(clock));
Period notice = Period.ofMonths(1);
LocalDate renewal = LocalDate.of(2026, 1, 31).plus(notice); // 2026-02-28
Instant understands Duration. It does not understand months:
instant.plus(Duration.ofDays(1)); // fine — 86_400 seconds
instant.plus(Period.ofDays(1)); // UnsupportedTemporalTypeException
Do not add a Period to an Instant. Apply calendar math to LocalDate or ZonedDateTime; apply elapsed-time math to Instant.
Formatting without SimpleDateFormat
DateTimeFormatter is the replacement. ISO constants cover most APIs; ofPattern covers the rest.
DateTimeFormatter human = DateTimeFormatter.ofPattern("dd MMM uuuu");
String label = LocalDate.of(2026, 9, 5).format(human); // 05 Sep 2026
DateTimeFormatter isoOffset = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
String payload = meeting.format(isoOffset);
Parse returns the type you asked for:
LocalDate parsed = LocalDate.parse("2026-09-05");
Instant instant = Instant.parse("2026-09-05T12:00:00Z");
Note: In patterns, prefer uuuu for year. yyyy is year-of-era and surprises you on dates before year 1. For day-of-month use dd, not DD (that is day-of-year).
Legacy Date and Calendar at the edge
Libraries and JDBC still hand you java.util.Date. Convert once, then stay in java.time.
Instant instant = Instant.now(clock);
Date legacy = Date.from(instant);
Instant back = legacy.toInstant();
java.sql.Timestamp ts = Timestamp.from(instant);
Instant fromSql = ts.toInstant();
java.sql.Date sqlDate = java.sql.Date.valueOf(LocalDate.of(2026, 9, 5));
LocalDate civil = sqlDate.toLocalDate();
From a Calendar, take the instant and the zone separately:
Instant when = calendar.toInstant();
ZoneId zone = calendar.getTimeZone().toZoneId();
ZonedDateTime zoned = when.atZone(zone);
Do not call new Date() deep in domain code “just this once.” That is the same now() problem as LocalDate.now() — untestable, and it reintroduces mutability the moment someone calls setTime.
Clock: fake “now” in tests
Instant.now(), LocalDate.now(), and friends all consult a Clock. The no-arg overloads use the system clock. Domain logic that needs a deterministic “today” should take a Clock instead.
final class Membership {
private final Clock clock;
Membership(Clock clock) {
this.clock = Objects.requireNonNull(clock);
}
boolean isExpired(LocalDate expiry) {
return LocalDate.now(clock).isAfter(expiry);
}
}
In production, inject Clock.systemUTC() (or Clock.system(zone) if “today” is a civil date in a known zone). In tests, freeze it:
Clock frozen = Clock.fixed(
Instant.parse("2026-09-05T12:00:00Z"),
ZoneOffset.UTC);
Membership membership = new Membership(frozen);
assertFalse(membership.isExpired(LocalDate.of(2026, 9, 5)));
assertTrue(membership.isExpired(LocalDate.of(2026, 9, 4)));
Do not call LocalDate.now() deep in domain logic if you need determinism. A frozen Clock is cheaper than waiting for midnight in CI.
Boundaries: what you store, what you show
A created-at column, a Kafka timestamp, a cache expiry — those are moments. Persist Instant (or a UTC OffsetDateTime). A “report date” the user picked in a date picker is a LocalDate. Do not shove one into the other’s column.
record Order(String id, Instant createdAt) {}
String labelFor(Order order, ZoneId userZone) {
return order.createdAt()
.atZone(userZone)
.toLocalDate()
.format(DateTimeFormatter.ISO_LOCAL_DATE);
}
The conversion lives at the edge — HTTP response, email, UI. The domain keeps the instant.
Cheat sheet
Java 8 / JSR-310 — java.time; all types immutable
Instant UTC timeline ("when did this happen?")
LocalDate civil date, no zone ("what calendar day?")
LocalDateTime date + time, still no zone — not a moment
ZonedDateTime date + time + ZoneId (DST)
OffsetDateTime date + time + ZoneOffset (APIs, timestamps)
Duration seconds / nanos; Instant arithmetic; timeouts
Period years / months / days; LocalDate arithmetic
DateTimeFormatter immutable, thread-safe; prefer uuuu over yyyy
Date.from(instant) / date.toInstant() java.util.Date bridge
Clock.fixed(instant, zone) tests; inject Clock, not now()
Store Instant (or UTC) at DB / API boundaries
Show LocalDate / ZonedDateTime in the user's ZoneId at the edge
Do:
- Treat
java.timetypes as values:plusreturns a new object. - Keep
Instant(or UTC) at persistence and message boundaries. - Share one
DateTimeFormatter— it is safe. - Inject a
Clockwherever “now” or “today” is a business rule. - Use
Durationfor elapsed time and timeouts;Periodfor calendar spans.
Don’t:
- Share a
SimpleDateFormator mutate aCalendaryou do not own. - Treat
LocalDateandInstantas interchangeable — they are not the same kind of time. - Call
LocalDate.now()/Instant.now()inside domain logic you need to test. - Store
LocalDateTimewithout a zone and hope every JVM agrees. - Add a
Periodto anInstant.
Wrap-up
JSR-310 gave Java a date-time library that matches how people actually talk: a moment on the timeline, a civil date, a zoned wall clock, and two kinds of span. The types are immutable, DateTimeFormatter is safe to share, and Clock makes “now” a dependency instead of a hidden System.currentTimeMillis().
Start by replacing new Date() with Instant.now(clock), Calendar arithmetic with plus on the matching type, and every SimpleDateFormat with a DateTimeFormatter. Keep instants at the boundaries; convert to the user’s zone only when something has to be read.