# 13 — Date and time mapping: what actually round-trips [← Previous: 12 — Association mappings](12-association-mappings.md) | [Next: 14 — Named queries →](14-named-queries.md) Backs [ankurm.com: Hibernate 7 date/time mapping](https://ankurm.com/mastering-hibernate-7-date-time-mapping-java-time-timezones-and-jdbc-4-2/). Everything below comes from a JUnit test in [`src/test/java/com/ankurm/hibernatedemo/datetime/`](../src/test/java/com/ankurm/hibernatedemo/datetime/) against Hibernate 7.4.5.Final, run on H2 2.4.240 (and HSQLDB 2.7.3 where noted), Java 25. The sandbox JVM's own default time zone during these runs was `Asia/Calcutta` (+05:30) unless a test explicitly overrides `-Duser.timezone`. ## Basic temporal types round trip [`TemporalTypesEntity`](../src/main/java/com/ankurm/hibernatedemo/datetime/TemporalTypesEntity.java) maps every basic temporal type in one entity. Generated DDL ([`BasicTemporalTypesTest`](../src/test/java/com/ankurm/hibernatedemo/datetime/BasicTemporalTypesTest.java), H2 2.4.240): ``` create table temporal_types ( id bigint generated by default as identity, instant timestamp(6) with time zone, legacy_calendar timestamp(6), legacy_date_as_date date, legacy_date_as_timestamp timestamp(6), legacy_date_no_temporal timestamp(6), local_date date, local_date_time timestamp(6), local_time time(0), offset_date_time timestamp(6) with time zone, zoned_date_time timestamp(6) with time zone, primary key (id) ) ``` Round trip of every value came back correct ([`docs/output/datetime-basic-types.txt`](output/datetime-basic-types.txt)). One finding worth flagging: `legacyDateNoTemporal` is a `java.util.Date` field with **no** `@Temporal` annotation at all. It did not fail to bootstrap and did not throw -- Hibernate 7.4.5 defaulted it to a `TIMESTAMP` column and round-tripped it correctly. The old JPA-provider requirement that `@Temporal` is mandatory on `Date`/`Calendar` fields does not hold here. ## `@Temporal` verified deprecated, and verified harmless when misapplied ``` $ javap -v jakarta.persistence.Temporal # from jakarta.persistence-api-3.2.0.jar Deprecated: true RuntimeVisibleAnnotations: java.lang.Deprecated(since="3.2") ``` `@Temporal` is formally deprecated since Jakarta Persistence 3.2 -- confirmed by bytecode inspection, not the javadoc prose. Chapter 05 confirms the identical boot-time warning on a `LocalDate` field; this chapter's [`TemporalOnJavaTimeEntity`](../src/main/java/com/ankurm/hibernatedemo/datetime/TemporalOnJavaTimeEntity.java) puts it on an `Instant` field instead -- see [`05 — JPA persistence annotations`](05-jpa-persistence-annotations.md#temporal-is-formally-deprecatedsince--32--and-it-is-a-silent-no-op-not-silent-silent) for the `LocalDate` case. Using it anyway on `java.time` fields (`java.util.Date`/`Calendar` are its only legal targets) does not break anything in Hibernate 7.4.5. [`TemporalAnnotationTest`](../src/test/java/com/ankurm/hibernatedemo/datetime/TemporalAnnotationTest.java) puts `@Temporal(TemporalType.TIMESTAMP)` on an `Instant` field: the application context boots, and the value round-trips exactly. The framework logs a deprecation warning at boot (`HHH90000033: Encountered use of deprecated annotation ... at ...instantWithTemporalAnnotation`) but does not reject it. See [`docs/output/datetime-temporal-annotation.txt`](output/datetime-temporal-annotation.txt). ## The central experiment: `@TimeZoneStorage` ``` $ javap org.hibernate.annotations.TimeZoneStorageType # hibernate-core-7.4.5.Final.jar NATIVE, NORMALIZE, NORMALIZE_UTC, COLUMN, AUTO, DEFAULT ``` Six constants, not five -- `DEFAULT` is a real enum member (a sentinel meaning "consult `hibernate.timezone.default_storage`"), separate from the five storage strategies. **What "default" resolves to.** `hibernate.timezone.default_storage` (confirmed present as `org.hibernate.cfg.MappingSettings.TIMEZONE_DEFAULT_STORAGE`) defaults, when unset, to `TimeZoneStorageType.DEFAULT` itself -- a second layer of indirection resolved by `MetadataBuildingOptions.getDefaultTimeZoneStorage()`, which asks the current SQL **Dialect** for its `TimeZoneSupport` and converts that into a storage strategy (confirmed by decompiling `MetadataBuilderImpl` and `TimeZoneStorageHelper` in hibernate-core 7.4.5.Final -- not from a blog post). In practice, on H2 (which has native `TIMESTAMP WITH TIME ZONE` support), a column with **no** `@TimeZoneStorage` annotation at all ([`TimeZoneStorageEntity`](../src/main/java/com/ankurm/hibernatedemo/datetime/TimeZoneStorageEntity.java)) behaved identically to `NATIVE` in every test below, per [`TimeZoneStorageTest`](../src/test/java/com/ankurm/hibernatedemo/datetime/TimeZoneStorageTest.java). **Storing `+05:30` and reading it back**, under the JVM's own default zone (`Asia/Calcutta`, itself `+05:30` -- chosen deliberately as a first baseline where the JVM zone matches the data): ``` TZ_MODE no-annotation (default) = 2026-06-15T14:00+05:30 TZ_MODE NATIVE = 2026-06-15T14:00+05:30 TZ_MODE NORMALIZE = 2026-06-15T14:00+05:30 TZ_MODE NORMALIZE_UTC = 2026-06-15T08:30Z TZ_MODE COLUMN = 2026-06-15T14:00+05:30 TZ_MODE AUTO = 2026-06-15T14:00+05:30 ``` That alone doesn't show much -- `NORMALIZE` had nothing to normalize *to* since the JVM zone already matched. Rerunning the identical test with `-Duser.timezone=America/New_York` (JVM default zone changed, database untouched) is where the real behavior shows up: ``` TZ_MODE no-annotation (default) = 2026-06-15T14:00+05:30 <- unchanged TZ_MODE NATIVE = 2026-06-15T14:00+05:30 <- unchanged TZ_MODE NORMALIZE = 2026-06-15T04:30-04:00 <- CHANGED: re-expressed in JVM's zone TZ_MODE NORMALIZE_UTC = 2026-06-15T08:30Z <- unchanged (always UTC) TZ_MODE COLUMN = 2026-06-15T14:00+05:30 <- unchanged TZ_MODE AUTO = 2026-06-15T14:00+05:30 <- unchanged ``` Both readings represent the exact same instant (`2026-06-15T08:30:00Z`); only the *displayed* offset for `NORMALIZE` moved, because `NORMALIZE` explicitly re-expresses the stored value in whatever the JVM's current default zone is at read time. Every other mode is immune to a change in the JVM's default time zone -- **this is the JVM-default-timezone hazard, made concrete**: if your fleet ever runs with inconsistent `user.timezone` settings (a classic container migration issue), `NORMALIZE` is the one mode that will show you a different offset for identical data depending on which box read it. `NATIVE`, `COLUMN`, `AUTO`, and Hibernate's default all store and return the exact offset supplied, immune to the reading JVM's zone. **`COLUMN` mode's DDL** does add a second column, exactly as advertised: ``` create table tz_storage ( ... column_mode_col timestamp(6) with time zone, column_mode_col_tz integer, ... ) ``` (On HSQLDB, which lacks a native `WITH TIME ZONE` timestamp type for every mode, `AUTO` also picks up a companion `_tz integer` column -- confirming `AUTO`'s behavior is dialect-dependent, consistent with the `TimeZoneSupport`-driven resolution above.) Raw output: [`docs/output/datetime-timezone-storage-default-jvm.txt`](output/datetime-timezone-storage-default-jvm.txt), [`docs/output/datetime-timezone-storage-nydefault.txt`](output/datetime-timezone-storage-nydefault.txt). ## Second-precision / truncation Stored `LocalDateTime`/`Instant` ([`NanoPrecisionEntity`](../src/main/java/com/ankurm/hibernatedemo/datetime/NanoPrecisionEntity.java)) with `123456789` ns and read back, via [`NanosecondTruncationTest`](../src/test/java/com/ankurm/hibernatedemo/datetime/NanosecondTruncationTest.java) (H2) and [`NanosecondTruncationHsqldbTest`](../src/test/java/com/ankurm/hibernatedemo/datetime/NanosecondTruncationHsqldbTest.java) (HSQLDB): | Database | Nanos in | Nanos out | Behavior | |---|---|---|---| | H2 2.4.240 | 123456789 | **123457000** | Rounds to microsecond precision | | HSQLDB 2.7.3 | 123456789 | **123456000** | Truncates to microsecond precision | Same input, two different databases, two different results -- H2 rounds the last three digits away, HSQLDB drops them. Neither preserves true nanosecond precision (both cap at `timestamp(6)`, i.e. microseconds), but "rounds" vs "truncates" is a real, silent, database-specific behavior difference that can shift a stored value by up to half a microsecond depending on which engine is under the app. **Correction**: `@Column(precision = 9)` on the temporal field had **zero effect** on the generated DDL or the stored precision in this experiment -- the column type stayed `timestamp(6)` regardless, on both databases. JPA's `precision`/`scale` `@Column` attributes are defined for **numeric** (`DECIMAL`) columns; they do not control fractional-second digits on a temporal column in Hibernate 7.4.5. A common piece of blog advice ("use `@Column(precision = 6)` to force microsecond storage") does not do anything here -- the precision was already fixed at 6 by the dialect's default temporal column type, with or without the annotation. Raw output: [`docs/output/datetime-nanosecond-h2.txt`](output/datetime-nanosecond-h2.txt), [`docs/output/datetime-nanosecond-hsqldb.txt`](output/datetime-nanosecond-hsqldb.txt). ## `hibernate.jdbc.time_zone` Confirmed to exist as `org.hibernate.cfg.JdbcSettings.JDBC_TIME_ZONE`. Setting it to `America/New_York` (JVM default left at `Asia/Calcutta`) and inspecting the **raw** stored value via a native `CAST(... AS VARCHAR)` query, in [`JdbcTimeZoneTest`](../src/test/java/com/ankurm/hibernatedemo/datetime/JdbcTimeZoneTest.java): ``` original LocalDateTime = 2026-07-04T09:00 raw DB value for LocalDateTime column = 2026-07-03 23:30:00 <- shifted! round-tripped LocalDateTime = 2026-07-04T09:00 <- but reads back correctly original OffsetDateTime (NATIVE) = 2026-07-04T09:00+05:30 raw DB value for NATIVE offset column = 2026-07-04 09:00:00+05:30 <- unchanged round-tripped OffsetDateTime (NATIVE) = 2026-07-04T09:00+05:30 ``` `hibernate.jdbc.time_zone` converts the wall-clock value of a zone-less `LocalDateTime` into the configured zone before it is bound to the JDBC driver -- the literal bytes stored in the database shift, even though the application-level round trip through the *same* Hibernate configuration is transparent (you get your `LocalDateTime` back unchanged). The danger is exactly the "looks fine in the app, wrong when another tool reads the table directly" class of bug. It has **no effect** on a value that already carries an explicit offset (`OffsetDateTime` with `TimeZoneStorage.NATIVE`) -- that value is bound and stored exactly as given, confirmed by both the raw column value and the round trip being unchanged. Raw output: [`docs/output/datetime-jdbc-time-zone.txt`](output/datetime-jdbc-time-zone.txt). ## Summary | Claim | Verified value | |---|---| | `@Temporal` deprecated since | Jakarta Persistence 3.2 (confirmed via `javap -v`) | | `@Temporal` misapplied to `java.time` | Logs a deprecation warning, does not fail | | `TimeZoneStorageType` constants | NATIVE, NORMALIZE, NORMALIZE_UTC, COLUMN, AUTO, DEFAULT (6, not 5) | | Hibernate 7.4.5 default storage on H2 | Behaves like NATIVE (dialect-derived, not a fixed constant) | | JVM-zone hazard | Only `NORMALIZE` changes displayed offset when JVM zone changes | | `COLUMN` mode DDL | Adds a companion `_tz integer` column | | Nanosecond round trip | H2 rounds to microseconds; HSQLDB truncates to microseconds | | `@Column(precision=9)` on a temporal field | No effect on generated DDL or stored precision | | `hibernate.jdbc.time_zone` | Shifts raw stored value for zone-less types; no effect on explicit-offset types | `@Temporal`'s deprecation is visible in the class file itself — see [`docs/output/datetime-javap-temporal-deprecated.txt`](output/datetime-javap-temporal-deprecated.txt). [← Previous: 12 — Association mappings](12-association-mappings.md) | [Next: 14 — Named queries →](14-named-queries.md)