The short answer
Store the instant in UTC and keep the ZoneId in a separate column. That combination is the most portable and the least exposed to JDBC driver quirks. Reaching for TIMESTAMP WITH TIME ZONE alone is tempting but unreliable in practice, because several databases (notably PostgreSQL) do not actually store the original zone in that column type — they normalize to UTC on write and render in the session zone on read.
Why the zone is lost by default
Hibernate's default mapping for java.time.ZonedDateTime binds the value as a JDBC TIMESTAMP, which corresponds to TIMESTAMP WITHOUT TIME ZONE in most databases. That column type has no room for a ZoneId or offset — it stores a bare date-time. What actually gets written is the instant converted through the JVM's default time zone (or the driver's configured zone), so the original regional context is discarded at the persistence boundary.
This is the likely explanation for what you're seeing; the confirmed part is that the SQL type itself cannot represent a zone, so no Hibernate setting can make TIMESTAMP WITHOUT TIME ZONE preserve one.
Recommended strategy: UTC instant + zone column
Split the two concerns that ZonedDateTime conflates:
- The instant — store as UTC in a plain timestamp column. This is what you query, sort, and compare on. DST transitions are unambiguous because UTC has no DST.
- The zone — store the
ZoneId string (e.g. America/New_York) in a VARCHAR column. This is what you use to render the value back in the user's regional context.
An AttributeConverter keeps this tidy at the entity boundary:
@Converter(autoApply = false)
public class ZonedDateTimeUtcConverter
implements AttributeConverter<ZonedDateTime, Instant> {
@Override
public Instant convertToDatabaseColumn(ZonedDateTime value) {
return value == null ? null : value.toInstant();
}
@Override
public ZonedDateTime convertToEntityAttribute(Instant dbValue) {
return dbValue == null ? null : dbValue.atZone(ZoneOffset.UTC);
}
}
Then keep a second field for the zone and reconstruct the full value in the domain layer:
@Convert(converter = ZonedDateTimeUtcConverter.class)
private ZonedDateTime occurredAtUtc;
@Column(length = 64)
private String occurredAtZone; // e.g. "America/New_York"
public ZonedDateTime occurredAtInOriginalZone() {
return occurredAtUtc.withZoneSameInstant(ZoneId.of(occurredAtZone));
}
Storing the ZoneId rather than the offset matters: an offset like +02:00 can't tell you whether a future date in that region falls inside DST, while the zone ID carries the full rule set.
What about TIMESTAMP WITH TIME ZONE?
Where the database genuinely stores offset/zone data (SQL Server's DATETIMEOFFSET is the common example), you can map with a single column. But verify your specific database first — PostgreSQL's TIMESTAMP WITH TIME ZONE is a frequent source of confusion because it stores only the UTC instant despite the name. If you go this route, confirm the column type in the actual schema and test with a non-UTC zone before committing to it.
Avoiding double conversion
Regardless of strategy, pin the JVM's time zone explicitly (-Duser.timezone=UTC or TimeZone.setDefault at startup) so the app server, container, and driver can't silently disagree. Double conversion typically appears when the driver assumes the JVM zone on write and the session zone on read, shifting values by hours around DST boundaries.
Verifying the fix
- Enable SQL logging (
hibernate.show_sql=true) and inspect the bound parameter type on insert.
- Check the column type in the schema itself (e.g.
\d table_name in psql).
- Round-trip a value with a DST-observing zone: persist
2026-03-08T01:30[America/New_York], read it back, and assert the reconstructed ZonedDateTime equals the input.
One caveat: if you already have rows written under the old mapping, changing the column type without a migration that converts existing values will reinterpret them — treat that as a data migration, not a schema tweak.