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

ReleaseStatusSpec
Java 8Standard libraryJSR-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.

TypeWhat it isZone?
InstantA point on the UTC timelineAlways UTC
LocalDate / LocalTime / LocalDateTimeCalendar date and/or wall-clock timeNone
ZonedDateTimeDate + time + named zone (DST rules)ZoneId
OffsetDateTimeDate + time + fixed offsetZoneOffset

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.

DurationPeriod
CountsSeconds and nanos (fixed)Years, months, days (calendar)
Typical betweenTwo InstantsTwo LocalDates
ofDays(1) meansAlways 24 hoursOne 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.time types as values: plus returns a new object.
  • Keep Instant (or UTC) at persistence and message boundaries.
  • Share one DateTimeFormatter — it is safe.
  • Inject a Clock wherever “now” or “today” is a business rule.
  • Use Duration for elapsed time and timeouts; Period for calendar spans.

Don’t:

  • Share a SimpleDateFormat or mutate a Calendar you do not own.
  • Treat LocalDate and Instant as interchangeable — they are not the same kind of time.
  • Call LocalDate.now() / Instant.now() inside domain logic you need to test.
  • Store LocalDateTime without a zone and hope every JVM agrees.
  • Add a Period to an Instant.

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.

Next optional step in the series Talk HTTP from the JDK without pulling in a client stack. HttpClient: Talk HTTP Without a Third-Party Stack