Hibernate - Using @TimeZoneStorage & @TimeZoneColumn Annotations

This tutorial shows you how to use @TimeZoneStorage and @TimeZoneColumn introduced in Hibernate 6.

If you need to store a time instant in the database, you may need to store the time zone information as well. It can be stored in the same column as the time instant if the database supports it. Another alternative is to store it in another column. There are other options as well, such as normalizing the value to a specific time zone or to UTC.

Hibernate 6 introduced annotations that allow us to specify how time zone information should be stored in the database. The annotations are @TimeZoneStorage and @TimeZoneColumn. With those annotations, we delegate the responsibility of storing time zone information to Hibernate.

Using @TimeZoneStorage Annotation

In Java, a time instant that contains time zone information is usually represented using ZonedDateTime or OffsetDateTime. With Hibernate, we can also use those types to define entity columns.

By default, Hibernate 6 uses the value of hibernate.timezone.default_storage property to determine the default strategy for storing the timezone information. If you don't set it in the property, the default value is DEFAULT. So, if you want to change the default storage type for all ZonedDateTime and OffsetDateTime fields, you have to set it in the property. The meaning of the DEFAULT value will be explained later along with other supported types.

To specify how the time zone information of a column should be persisted, you can add a @TimeZoneStorage annotation to the field. In the annotation, pass a TimeZoneStorageType enum value to define the storage type to be used. The enum has some values which include NATIVE, NORMALIZE, NORMALIZE_UTC, COLUMN, AUTO, and DEFAULT. I'm going to explain those values one by one.

Below is an example of an entity that uses the annotation. It has a field of type ZonedDateTime and another field of type OffsetDateTime. Both fields are annotated with @TimeZoneStorage.

  @Entity
  @Table(name = "orders")
  public class Order {
  
    @Id
    @GeneratedValue
    @GenericGenerator(name = "UUID", type = UuidGenerator.class)
    private UUID id;

    @TimeZoneStorage(TimeZoneStorageType.NATIVE)
    private ZonedDateTime orderedAt;

    @TimeZoneStorage(TimeZoneStorageType.NATIVE)
    private OffsetDateTime issuedAt;
  }

We are going to use the simple code below for persisting and retrieving records of the entity above. The times are hard-coded with the zone set to 'Asia/Jakarta' (GMT+7).

  ZonedDateTime currentTime = ZonedDateTime.of(2023, 6, 11, 15, 0, 0,  0, ZoneId.of("Asia/Jakarta"));
  OffsetDateTime currentTime2 = OffsetDateTime.of(2023, 6, 11, 15, 0, 0,  0, ZoneOffset.of("+07:00"));

  Order order = Order.builder()
      .orderedAt(currentTime)
      .issuedAt(currentTime2)
      .build();

  Order savedOrder = this.orderRepository.save(order);

  Order retrievedOrder = this.orderRepository.findById(savedOrder.getId())
      .orElse(null);

TimeZoneStorageType.NATIVE

With NATIVE strategy, the value is stored in a column with time zone (SqlType.TIMESTAMP_WITH_TIMEZONE). Therefore, the database you use must support that data type and you also need to create the column with the correct type. Otherwise, you may get an error or a wrong value.

Log:

  insert into orders (id,ordered_at,issued_at) values (?,?,?)
  binding parameter [1] as [UUID] - [0f2f73c0-3d37-4a95-97f2-7ee3e2e27c90]
  binding parameter [2] as [TIMESTAMP_WITH_TIMEZONE] - [2023-06-11T15:00+07:00[Asia/Jakarta]]
  binding parameter [3] as [TIMESTAMP_WITH_TIMEZONE] - [2023-06-11T15:00+07:00]  

Database values:

  ordered_at: 2023-06-11 15:00:00+07
  issued_at: 2023-06-11 15:00:00+07

Retrieved values:

  orderedAt: 2023-06-11T08:00Z
  issued_at: 2023-06-11T08:00Z

TimeZoneStorageType.NORMALIZE

This storage type normalizes the value to a specific time zone based on hibernate.jdbc.time_zone property. If the property is not set, Hibernate will use the JVM default time zone. When the value is retrieved from the database, the zone (for ZonedDateTime) or the offset (for OffsetDateTime) will be set to the JVM default time zone. If you use this type, the column that stores the timestamp should not have the timezone

Log:

  insert into orders (id,ordered_at,issued_at) values (?,?,?)
  binding parameter [1] as [UUID] - [0f2f73c0-3d37-4a95-97f2-7ee3e2e27c90]
  binding parameter [2] as [TIMESTAMP] - [2023-06-11T15:00+07:00[Asia/Jakarta]]
  binding parameter [3] as [TIMESTAMP] - [2023-06-11T15:00+07:00]  

Database values:

  ordered_at: 2023-06-11 15:00:00
  issued_at: 2023-06-11 15:00:00

Retrieved values:

  orderedAt: 2023-06-11T15:00+07:00[Indian/Christmas]
  issued_at: 2023-06-11T15:00+07:00

TimeZoneStorageType.NORMALIZE_UTC

NORMALIZE_UTC is similar to NORMALIZE, which doesn't preserve the time zone. The difference is the timestamp is normalized to UTC. When the value is retrieved from the database, the zone (for ZonedDateTime) or the offset (for OffsetDateTime) will be set to UTC.

Warning: For NORMALIZE_UTC, currently there is a potential issue that causes the value to be not stored as UTC value in the database.

Log:

  insert into orders (id,ordered_at,issued_at) values (?,?,?,?,?,?)
  binding parameter [1] as [UUID] - [0f2f73c0-3d37-4a95-97f2-7ee3e2e27c90]
  binding parameter [2] as [TIMESTAMP_UTC] - [2023-06-11T15:00+07:00[Asia/Jakarta]]
  binding parameter [3] as [TIMESTAMP_UTC] - [2023-06-11T15:00+07:00]  

(Expected) Database values:

  ordered_at: 2023-06-11 08:00:00 // because of the current issue, stored as 2023-06-11 15:00:00
  issued_at: 2023-06-11 08:00:00 // because of the current issue, stored as 2023-06-11 15:00:00

(Expected) Retrieved values:

  orderedAt: 2023-06-11T08:00Z // because of the current issue, retrieved as 2023-06-11T15:00Z
  issued_at: 2023-06-11T08:00Z // because of the current issue, retrieved as 2023-06-11T15:00Z

TimeZoneStorageType.COLUMN

The COLUMN strategy stores the timezone information in a separate column of integer type. By default, the name for the timezone column is the column name that stores the timestamp, added with _tz suffix. That means the value of the timestamp column is normalized to UTC.

Warning: For COLUMN, currently there is a potential issue that causes the value to be not stored as UTC value in the database.

Log:

  insert into orders (id,ordered_at,ordered_at_tz,issued_at,issued_at_tz) values (?,?,?,?,?)
  binding parameter [1] as [UUID] - [0f2f73c0-3d37-4a95-97f2-7ee3e2e27c90]
  binding parameter [2] as [TIMESTAMP_UTC] - [2023-06-11T08:00:00Z]
  binding parameter [3] as [INTEGER] - [+07:00]
  binding parameter [4] as [TIMESTAMP_UTC] - [2023-06-11T08:00:00Z]
  binding parameter [5] as [INTEGER] - [+07:00]  

(Expected) Database values:

  ordered_at: 2023-06-11 08:00:00 // because of the current issue, stored as 2023-06-11 15:00:00
  ordered_at_tz: 25200
  issued_at: 2023-06-11 08:00:00 // because of the current issue, stored as 2023-06-11 15:00:00 
  issued_at_tz: 25200

(Expected) Retrieved values:

  orderedAt: 2023-06-11T15:00+07:00, because of the current issue, retrieved as 2023-06-11T22:00+07:00
  issued_at: 2023-06-11T15:00+07:00, because of the current issue, retrieved as 2023-06-11T22:00+07:00

TimeZoneStorageType.AUTO

If the type is set to AUTO, it will use NATIVE if Dialect#getTimeZoneSupport() is set to NATIVE. Otherwise, it will use the COLUMN strategy. To change the value returned by Dialect#getTimeZoneSupport(), you can create a custom Dialect and override the method.

  public class MyCustomDialect extends PostgreSQLDialect {

    @Override
    public TimeZoneSupport getTimeZoneSupport() {
      return TimeZoneSupport.NATIVE;
    }
  }

TimeZoneStorageType.DEFAULT

If the type is set to DEFAULT, it will use NATIVE if Dialect#getTimeZoneSupport() is set to NATIVE. Otherwise, it will use the NORMALIZE_UTC strategy.

Using @TimeZoneColumn Annotation

The @TimeZoneColumn annotation can be used to specify a custom name for the column that stores the timezone information. By default, the name is the same as the name of the timestamp column, added with _tz suffix. This annotation can only be used if the TimeZoneStorageType is COLUMN or AUTO.

If you use it with an incompatible storage type, you'll get the following exception.

  Caused by: java.lang.IllegalStateException: @TimeZoneColumn can not be used in conjunction with @TimeZoneStorage( NATIVE ) with attribute com.woolha.hibernate6.model.Order.processedAt

Summary

The @TimeZoneStorage makes it easier to specify how a time instant with time zone information should be persisted to the database. With that annotation, the process of converting a time instant to a database column value becomes the responsibility of Hibernate. If the time zone information is stored in another column, you can use @TimeZoneColumn to define the column name.