Java

java.time (JSR-310), Instant vs LocalDateTime, and the legacy Date/Calendar minefield.

Java’s modern date and time API is java.time (JSR-310, since Java 8) — the library kotlinx-datetime builds on and the model most JVM code now follows. It gives each meaning its own type instead of one overloaded class: Instant (an absolute point in time), LocalDateTime (a civil date-and-time with no zone), LocalDate and LocalTime, ZonedDateTime (a civil reading pinned to a ZoneId), and OffsetDateTime (pinned only to a fixed offset). Spans come in two deliberate flavors: Duration (exact elapsed time) and Period (calendar units) — see Durations.

The central distinction is Instant versus LocalDateTime. An Instant names a unique moment on the global timeline — the “aware UTC” value you should store when recording when something happened. A LocalDateTime is the naive value: 2026-03-29T01:30 carries no zone, so by itself it does not identify a moment. Moving between them always names a zone: civil.atZone(zone).toInstant() one way, instant.atZone(zone).toLocalDateTime() the other. See Instant vs civil time and Time zones vs offsets.

A LocalDate names a period — a whole calendar day — while an Instant names a point: different kinds of value, not two precisions of one idea. See Points vs periods.

The safe default for “now” is Instant.now(). Reach for ZonedDateTime when you need to do calendar arithmetic in a zone or format for a reader; reach for OffsetDateTime mainly at storage/interchange boundaries that speak RFC 3339.

Instant vs LocalDateTime

As in Kotlin, the naive/aware confusion is caught by the type system: there is no implicit conversion, so you must name a ZoneId at the boundary. The remaining hazard is choosing the wrong zoneZoneId.systemDefault() where a fixed business zone was meant gives values that silently change with the host machine.

Pitfall: A LocalDateTime does not identify a moment in time. Comparing two that were meant for different zones, or assuming one is UTC, produces results off by an offset that shifts across DST transitions. Convert to Instant with an explicit ZoneId before comparing or subtracting.

Pitfall: The legacy java.util API is a minefield that predates these distinctions: Date is always an instant despite its name, its deprecated constructors count years from 1900 and months from 0, Calendar arithmetic silently clamps, and SimpleDateFormat is not thread-safe — plus its YYYY pattern is week-based year, which is wrong for a few days around New Year. Convert at the boundary (legacy.toInstant(), Date.from(instant)) instead of propagating those types, and format with DateTimeFormatter.

See also: Instant vs civil time and Parsing user input.

Go deeper: conversions, DST-safe arithmetic, and the legacy boundary

The core types and conversions:

Instant now = Instant.now();                                // an absolute instant
ZoneId tz = ZoneId.of("Europe/Paris");

LocalDateTime civil = now.atZone(tz).toLocalDateTime();     // instant -> wall clock
Instant back = civil.atZone(tz).toInstant();                // wall clock -> instant

LocalDate today = LocalDate.now(tz);                        // a calendar day (a period)

Parsing is type-directed, so the value you ask for is the value you get:

Instant.parse("2026-06-05T14:00:00Z");        // absolute; offset (Z) required
LocalDate.parse("2026-06-05");                // a date, never silently an instant
LocalDateTime.parse("2026-06-05T14:00");      // civil; stays zone-less by design

Exact vs calendar arithmeticDuration vs Period, and why it matters across DST:

ZoneId tz = ZoneId.of("Europe/London");
ZonedDateTime start = LocalDateTime.of(2026, 3, 29, 1, 30).atZone(tz);

ZonedDateTime exact = start.plus(Duration.ofHours(24));   // exactly 24h later
ZonedDateTime nextDay = start.plus(Period.ofDays(1));     // "same time tomorrow", DST-aware
// Across the spring-forward night these land on different instants.

(The shared caveat: “same wall-clock time tomorrow” can fall in a DST gap or fold; java.time resolves gaps by shifting forward and folds by keeping the earlier offset — silently.)

Formatting uses DateTimeFormatter (thread-safe, unlike SimpleDateFormat). Mind the pattern letters: uuuu is the year, yyyy is year-of-era, and YYYY is week-based year — the classic end-of-December bug. See Formatting for display.

The legacy boundary. APIs that still speak java.util.Date/Calendar should be wrapped, not imitated:

Instant fromLegacy = legacyDate.toInstant();            // Date -> java.time
java.util.Date toLegacy = java.util.Date.from(now);     // java.time -> Date

On Kotlin/JVM, kotlinx-datetime is a thin multiplatform layer over exactly these types.


← Back to all topics