When mapping date and time attributes with JPA/Hibernate, how do java.time types Instant, LocalDate, LocalDateTime, OffsetDateTime and ZonedDateTime differ in what they store, and how do you choose between them?
answer
- Moment vs calendar fact — pick the kind first
- LocalDateTime = digits, not an instant
- Instant = UTC timeline point, default for events
- Offset/zone not preserved unless COLUMN/NATIVE storage
- MySQL DATETIME defaults to 0 fractional digits
basics
~20 sLocalDate maps to DATE, LocalTime to TIME, LocalDateTime to a timestamp without zone (wall-clock, no instant), Instant to a point on the timeline, and OffsetDateTime/ZonedDateTime carry an offset that survives only with a with-time-zone column or explicit offset storage. Use Instant for events, LocalDate/LocalDateTime for calendar facts.
solid answer
~60 sHibernate maps java.time types natively — no converter needed: - **`LocalDate` → `DATE`**, `LocalTime` → `TIME`. Pure calendar/clock values, no zone, no ambiguity. - **`LocalDateTime` → `TIMESTAMP` (without time zone)**. Wall-clock date+time with **no instant semantics** — two rows written in different zones are not comparable as moments. - **`Instant` → a point on the UTC timeline.** Hibernate 6 prefers a with-time-zone type where the dialect has one (PostgreSQL `timestamptz`), otherwise a plain timestamp with the value normalised. - **`OffsetDateTime` / `ZonedDateTime`** — the offset (and certainly the zone id) is not preserved by default: Hibernate 6 normalises to UTC unless you opt into `@TimeZoneStorage(TimeZoneStorageType.COLUMN)` with a separate offset column, or `NATIVE` on a dialect with a true with-zone type. Choosing: for *when something happened* use `Instant` (or `OffsetDateTime` if callers need an offset), stored in UTC. For *a calendar fact* — a birthday, an invoice date, a shop's opening time — use `LocalDate`/`LocalTime`, which have no instant to convert. Reach for `LocalDateTime` only for genuine wall-clock semantics; if you meant a moment, it is the wrong type.
code
java · 15 lines@Entity
public class Booking {
@Id @GeneratedValue private Long id;
private Instant createdAt; // a moment: UTC timeline point
private LocalDate serviceDate; // calendar fact: DATE
private LocalTime openingTime; // clock fact: TIME
private LocalDateTime startsAtLocal; // wall clock ...
private String startsAtZoneId; // ... plus the zone it means
@TimeZoneStorage(TimeZoneStorageType.COLUMN)
@TimeZoneColumn(name = "submitted_at_offset")
private OffsetDateTime submittedAt; // offset really round-trips
}go deeper
Know the straightforward mappings — LocalDate to DATE, LocalTime to TIME, Instant for timestamps — and that java.time needs no converter or @Temporal.
Explain moment versus calendar fact, why LocalDateTime does not identify an instant, and that offsets and zone ids are not preserved by default.
Cover @TimeZoneStorage options, dialect differences for with-time-zone types, fractional-second precision truncation, and the instant-plus-zone-id modelling pattern.
Set a system-wide convention (UTC everywhere, conversion only at the edges), decide where zone information legitimately belongs in the domain, and account for zone-rule changes over the data's lifetime.
## The core distinction Time values fall into two kinds, and picking the wrong kind is the bug that outlives every mapping detail. - **A moment on the timeline** — "the payment was captured at this instant". Universal, comparable across the world, correctly represented by `Instant` (or `OffsetDateTime` when a caller must see an offset). - **A calendar or wall-clock fact** — "the invoice is dated 2026-03-14", "the shop opens at 09:00". These have no single instant; converting them to one requires inventing a time zone. JPA 2.2 onward requires providers to support the java.time types, so `@Temporal` is neither needed nor allowed on them. ## Type by type **`LocalDate` → `DATE`.** No time, no zone. The right choice for birthdays, due dates, accounting periods. Avoid `Instant` here: normalising a date through a zone shifts it by a day near midnight. **`LocalTime` → `TIME`.** A clock reading; opening hours, cutoffs. **`LocalDateTime` → `TIMESTAMP` without time zone.** Stores exactly the digits you gave it. This is the type most often chosen by accident: it looks like "a date and time", but it does not identify a moment. Two services in different zones writing `2026-03-14T09:00` mean different instants, and comparing or ordering such rows across zones is meaningless. Worse, converting `LocalDateTime` to an instant via a civil zone is ambiguous during the daylight-saving fall-back hour and non-existent during the spring-forward gap. **`Instant` → a UTC point in time.** The default choice for `created_at`, `occurred_at`, `expires_at`. On Hibernate 6, `Instant` is mapped to a with-time-zone JDBC type where the dialect supports one — PostgreSQL's `timestamptz`, which stores a normalised UTC value — and to a plain timestamp otherwise; `hibernate.type.preferred_instant_jdbc_type` lets you force the choice. **`OffsetDateTime` / `ZonedDateTime`.** These carry information most columns cannot hold. A `TIMESTAMP WITH TIME ZONE` column in PostgreSQL does *not* store the original offset — it stores a UTC instant and renders it in the session zone — and a zone id like `Europe/Berlin` is never stored by a standard column type. So by default the original offset is lost on the round trip. Hibernate 6 makes this explicit with `@TimeZoneStorage`: - `NORMALIZE_UTC` (the Hibernate 6 default behaviour): convert to UTC, read back with a UTC offset. - `NORMALIZE`: convert into the JVM default zone — avoid; it makes stored meaning depend on where the app runs. - `COLUMN`: store the offset in a companion column declared with `@TimeZoneColumn`, so the original offset really does round-trip. - `NATIVE`: use a true with-time-zone type on dialects that have one. If the *original offset or zone matters to the business* — "show the meeting in the time zone the organiser booked it in" — do not rely on the timestamp type at all. Store the instant plus a separate `zone_id` string column, and reconstruct. That survives dialect changes and is explicit about intent. ## Precision java.time carries nanoseconds; databases often do not. MySQL `DATETIME` defaults to zero fractional digits, so an unqualified column silently truncates sub-second values — a classic "my test compares written and read timestamps and fails" bug. Declare the precision you need (`TIMESTAMP(6)`, `DATETIME(6)`) and be aware that comparing a freshly built object with a reloaded one requires truncating to the column's precision. ## Practical defaults 1. Events and audit columns: `Instant`, stored as UTC, JVM and database configured for UTC. 2. Calendar facts: `LocalDate` / `LocalTime`. 3. Wall-clock schedules where the zone is a separate business fact: `LocalDateTime` **plus** an explicit zone column. 4. `ZonedDateTime` in the entity only when you have deliberately configured offset storage; otherwise convert at the boundary and keep the entity on `Instant`. 5. Choose per-attribute meaning, not per-codebase habit — but be consistent for the same kind of meaning.
- Why is LocalDateTime a poor choice for a created_at column?Because it records wall-clock digits without identifying a moment. Rows written by instances in different zones — or by the same instance before and after a daylight-saving shift — cannot be ordered or compared as events, and converting them to instants later is ambiguous during the fall-back hour and impossible during the spring-forward gap. `Instant` stores the moment unambiguously and renders into any zone at the edge.
- If you store OffsetDateTime and read it back, do you get the same offset you wrote?Not by default. Hibernate 6 normalises to UTC, so you get the same instant with a UTC offset rather than the original one; a PostgreSQL `timestamptz` column likewise stores a UTC instant, not the offset you supplied. To preserve the original offset you must opt into `@TimeZoneStorage(TimeZoneStorageType.COLUMN)` with a companion offset column, or use a dialect type that genuinely stores it.
- How would you model 'a meeting at 09:00 in the organiser's time zone' so it survives a rule change in that zone?Store the wall-clock value and the zone separately: a `LocalDateTime` (or `LocalDate` + `LocalTime`) plus a `zone_id` string such as `Europe/Berlin`, and compute the instant on read. If you instead freeze an `Instant` at booking time, a later change to that zone's offset rules moves the meeting relative to the local clock, which is usually not what the business means.
saying these in an interview costs you the question
- Using LocalDateTime for event timestamps and calling it 'the same as Instant'
- Expecting ZonedDateTime to round-trip its zone id through a standard timestamp column
- Believing PostgreSQL TIMESTAMPTZ stores the offset you supplied
- Adding @Temporal to a java.time attribute
- Ignoring fractional-second truncation and then blaming Hibernate for 'changing' the value