The java.time API at a Glance
Java 8 introduced the java.time package as JSR-310, designed by Stephen Colebourne (creator of Joda-Time). The package centers on five main classes: Instant, OffsetDateTime, ZonedDateTime, LocalDateTime, and LocalDate. Each represents a different level of information about a moment in time, and choosing the right one is the foundation of correct time zone handling.
The selection rule is to use the class with the minimum information needed. Use LocalDate or LocalDateTime when no time zone is involved, OffsetDateTime when a UTC offset is sufficient, ZonedDateTime when you need IANA time zone semantics that follow future DST changes, and Instant when you only care about an absolute moment. Picking the least expressive type that fits prevents accidental information loss and simplifies reasoning.
Instant - Machine Time Without Zones
Instant represents an absolute moment as nanoseconds since the Unix epoch (1970-01-01T00:00:00Z). It carries no time zone information and represents the same moment everywhere on Earth. Log timestamps, event creation times, and API response times typically should be Instant because their meaning is independent of any local time zone.
Arithmetic on Instant is straightforward and predictable. plus(Duration.ofHours(1)) always advances exactly one physical hour, regardless of any DST transition. The trade-off is that Instant cannot directly answer questions like "what date is it in Tokyo?" without first being combined with a ZoneId. Conversion only happens at presentation, which is exactly the right place for it.
ZonedDateTime - Full Zone Awareness
ZonedDateTime combines a LocalDateTime with a ZoneId such as Asia/Tokyo. It carries the IANA zone name and therefore tracks future DST changes and historical zone updates. For ambiguous wall-clock times during DST transitions, ZonedDateTime offers withEarlierOffsetAtOverlap() and withLaterOffsetAtOverlap() to make the choice explicit.
ZonedDateTime arithmetic respects wall-clock semantics. Calling plusHours(1) on a ZonedDateTime that crosses a DST spring-forward boundary effectively adds two hours to the underlying instant, matching the human intuition of "the same time tomorrow." If you instead want to advance physical time, convert to Instant first. Always include DST boundary tests when arithmetic is critical.
OffsetDateTime - When Offset Is Enough
OffsetDateTime is similar to ZonedDateTime, but it stores only a UTC offset like +09:00 instead of a full IANA zone. The moment is unambiguous because the offset locks in the UTC time, but it does not track future DST policy changes. This makes it ideal for serialization formats like ISO 8601 and RFC 3339, where both the local time and the UTC offset are expressed verbatim.
PostgreSQL's TIMESTAMP WITH TIME ZONE column stores values as UTC internally, and the JDBC driver typically maps it to OffsetDateTime or Instant. Persisting OffsetDateTime preserves the original offset for audit purposes while still permitting unambiguous conversion to UTC. Using ZonedDateTime for stored data risks reinterpretation if the country later changes its DST policy retroactively.
Spring Boot and JPA Integration
Spring Boot 3.x and Hibernate 6 support Instant and OffsetDateTime as entity field types directly. Legacy code using java.util.Date or Calendar should be migrated, with column types selected to match: TIMESTAMP for unzoned local times, TIMESTAMPTZ (PostgreSQL) or TIMESTAMP (MySQL with serverTimezone configured) for absolute times. The mapping you pick at the entity level directly determines whether time zone bugs are possible at all.
JSON serialization with Jackson can quietly emit Instants as numeric epoch seconds, surprising API consumers expecting ISO 8601 strings. Setting spring.jackson.serialization.write-dates-as-timestamps=false produces ISO 8601 output, which is the modern standard and what JavaScript libraries like Day.js and Luxon expect. Aligning serialization conventions across the stack saves countless interop bugs.
A Decision Flow for Class Selection
Start with the question "do I need a notion of human geography here?" If the answer is no (logs, events, machine timestamps), use Instant. If yes, ask whether the value must follow future zone rule changes. For user-scheduled future events such as alarms, ZonedDateTime is correct. For historical records and completed transactions, OffsetDateTime is safer because the meaning is frozen at write time. For pure dates without any time-of-day component, LocalDate is the right answer.
It is normal and healthy for a single application to use multiple types from java.time. Forcing everything into ZonedDateTime gives logs more information than they need and complicates serialization. Document at the boundary layers (DTOs, DB columns, API contracts) which type each field uses, and 90 percent of timezone bugs simply disappear. The remaining 10 percent are usually about test coverage at DST boundaries, which proper unit tests can catch early.