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 →