Files
hibernate-demo/docs/13-date-and-time-mapping.md
T

12 KiB
Executable File

13 — Date and time mapping: what actually round-trips

← Previous: 12 — Association mappings | Next: 14 — Named queries →

Backs ankurm.com: Hibernate 7 date/time mapping.

Everything below comes from a JUnit test in 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 maps every basic temporal type in one entity. Generated DDL (BasicTemporalTypesTest, 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). 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 puts it on an Instant field instead -- see 05 — JPA persistence annotations 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 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.

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) behaved identically to NATIVE in every test below, per TimeZoneStorageTest.

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, docs/output/datetime-timezone-storage-nydefault.txt.

Second-precision / truncation

Stored LocalDateTime/Instant (NanoPrecisionEntity) with 123456789 ns and read back, via NanosecondTruncationTest (H2) and NanosecondTruncationHsqldbTest (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, docs/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:

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.

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.

← Previous: 12 — Association mappings | Next: 14 — Named queries →