Hibernate Date and Time Mapping: LocalDateTime and Instant

Hibernate maps LocalDate, LocalDateTime, Instant, OffsetDateTime and ZonedDateTime to SQL date, time and timestamp columns without annotations. See the DDL and stored values on Hibernate 7.4 and how @TimeZoneStorage keeps or drops the offset.

logo

Hibernate date and time mapping turns the java.time fields of an entity into SQL date, time and timestamp columns, without any extra annotation. A LocalDate field becomes a date column, a LocalDateTime becomes a timestamp, and an Instant, OffsetDateTime or ZonedDateTime becomes a timestamp with time zone.

We map date and time fields whenever an entity records when something happens, for example the start time of a webinar that hosts in different cities enter in their own zones. The types differ in whether the value is a wall-clock reading such as “18:30” or an exact moment on the timeline. The difference between a wall-clock value and a moment decides what the database stores and which time zone comes back, so we pick the Java type first and let Hibernate pick the column.

The following example maps one field of each common type, with the H2 column Hibernate 7.4 creates, and saves and reads one webinar.

private LocalDate eventDate;                // date
private LocalTime startTime;                // time(0)
private LocalDateTime registrationCloses;   // timestamp(6)
private Instant startsAt;                   // timestamp(6) with time zone
private OffsetDateTime startWithOffset;     // timestamp(6) with time zone
private ZonedDateTime startInZone;          // timestamp(6) with time zone
private Duration length;                    // numeric(21,0)

@CreationTimestamp
private Instant createdOn;                  // set on insert

@UpdateTimestamp
private Instant updatedOn;                  // set on insert and on every update
ZonedDateTime start = ZonedDateTime.of(2026, 11, 12, 18, 30, 0, 0, ZoneId.of("Asia/Kolkata"));
em.persist(new Webinar("Java Records", start, Duration.ofMinutes(90)));

Webinar webinar = em.find(Webinar.class, id);
LocalDate eventDate = webinar.getEventDate();           // 2026-11-12
Instant startsAt = webinar.getStartsAt();               // 2026-11-12T13:00:00Z
ZonedDateTime startInZone = webinar.getStartInZone();   // 2026-11-12T18:30+05:30 (the region Asia/Kolkata is not stored)

List<Webinar> november = em.createQuery("from Webinar w where w.eventDate between :from and :to", Webinar.class)
    .setParameter("from", LocalDate.of(2026, 11, 1))
    .setParameter("to", LocalDate.of(2026, 11, 30))
    .getResultList();            // [Java Records, Virtual Threads]

Notice that startsAt comes back in UTC and startInZone comes back with the offset +05:30, because the database keeps no region.

Next, we see how Hibernate maps each kind of type, and then save and query webinars hosted in three cities, including the time zone storage options and the creation and update timestamps.

1. How Hibernate Maps Java Date and Time Types

The java.time package has three kinds of date and time types. A webinar that starts at 18:30 in Kolkata shows all three.

  • Local types (LocalDate, LocalTime, LocalDateTime) hold a wall-clock value without a zone. “18:30” alone does not say which city’s 18:30.
  • Offset and zoned types (OffsetDateTime, ZonedDateTime) hold the wall-clock value plus an offset from UTC, such as +05:30. Together they name one exact moment.
  • Instant holds the moment itself on the UTC timeline, 13:00 UTC, without any wall clock.
A webinar start shown as 08:00 in New York, 13:00 UTC and 18:30 in Kolkata, all one instant; below, LocalDateTime holds 2026-11-12T18:30 with no zone, OffsetDateTime and ZonedDateTime hold 2026-11-12T18:30+05:30, and Instant holds 2026-11-12T13:00:00Z
The same moment has a different wall-clock time in every city. Only the offset types and Instant identify the moment.

Hibernate 7.4 supports all of them as basic types, so a plain field is enough, and we need @Column only to change the name, the nullability or the precision. The column type on H2 depends on the Java type.

Java typeColumn on H2Use it for
LocalDatedateA day without a time: event date, birthday
LocalTimetime(0)A time of day: daily start time
LocalDateTimetimestamp(6)A wall-clock date and time in a known zone
Instanttimestamp(6) with time zoneA moment: created, paid, started
OffsetDateTimetimestamp(6) with time zoneA moment plus the offset it was entered in
ZonedDateTimetimestamp(6) with time zoneSame as OffsetDateTime; the region is not stored
OffsetTimetime(0) with time zoneA time of day with an offset
Durationnumeric(21,0)A length of time, stored in nanoseconds
YearintegerA year: 2026
ZoneIdvarchar(255)A region such as Asia/Kolkata
YearMonthvarbinary(255)Nothing: it is serialized; use a converter (FAQ 3.5)

The number in brackets is the fraction precision, so timestamp(6) keeps microseconds and time(0) keeps whole seconds. Other databases may use other names for the same column types. The older java.util.Date and Calendar still work with @Temporal, but the @Temporal annotation is deprecated in Jakarta Persistence 3.2, and FAQ 3.1 shows the migration.

2. Date and Time Mapping Example

The following example is a webinar scheduling app on Hibernate 7.4, Java 25 and H2. The hosts live in different cities, and the application server runs in America/New_York, which matters for the time zone results.

2.1. Domain Model

A Webinar has a title and a start time entered in the host’s zone. The constructor fills every date and time field from that one ZonedDateTime, so we can compare what each type stores for the same moment.

@Id
@GeneratedValue
private Long id;

private String title;
private LocalDate eventDate;
private LocalTime startTime;
private LocalDateTime registrationCloses;
private Instant startsAt;
private OffsetDateTime startWithOffset;
private ZonedDateTime startInZone;
private ZoneId hostZone;
private Duration length;

@CreationTimestamp
private Instant createdOn;

@UpdateTimestamp
private Instant updatedOn;

public Webinar(String title, ZonedDateTime start, Duration length) {
  this.title = title;
  this.eventDate = start.toLocalDate();
  this.startTime = start.toLocalTime();
  this.registrationCloses = start.toLocalDateTime().minusDays(1);
  this.startsAt = start.toInstant();
  this.startWithOffset = start.toOffsetDateTime();
  this.startInZone = start;
  this.hostZone = start.getZone();
  this.length = length;
}

Hibernate creates this table from the mapping. @CreationTimestamp and @UpdateTimestamp also make their columns not null.

create table Webinar (
    eventDate date,
    length numeric(21,0),
    startTime time(0),
    createdOn timestamp(6) with time zone not null,
    id bigint not null,
    registrationCloses timestamp(6),
    startInZone timestamp(6) with time zone,
    startWithOffset timestamp(6) with time zone,
    startsAt timestamp(6) with time zone,
    updatedOn timestamp(6) with time zone not null,
    hostZone varchar(255),
    title varchar(255),
    primary key (id)
)

2.2. Saving and Reading the Values

We save three webinars hosted in Kolkata, London and New York. Each persist() runs one INSERT with a parameter for every column.

emf.runInTransaction(em -> {
  em.persist(new Webinar("Java Records",
      ZonedDateTime.of(2026, 11, 12, 18, 30, 0, 0, ZoneId.of("Asia/Kolkata")), Duration.ofMinutes(90)));
  em.persist(new Webinar("Virtual Threads",
      ZonedDateTime.of(2026, 11, 19, 10, 0, 0, 0, ZoneId.of("Europe/London")), Duration.ofMinutes(60)));
  em.persist(new Webinar("Spring Security",
      ZonedDateTime.of(2026, 12, 3, 9, 0, 0, 0, ZoneId.of("America/New_York")), Duration.ofMinutes(45)));
});
// insert into Webinar (createdOn, eventDate, hostZone, length, registrationCloses, startInZone,
//     startTime, startWithOffset, startsAt, title, updatedOn, id) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

Reading the “Java Records” row with a native query (each column cast to text) shows what H2 holds. After that, em.find() loads the entity.

FieldStored in the columnComes back as
eventDate2026-11-122026-11-12
startTime18:30:0018:30
registrationCloses2026-11-11 18:30:002026-11-11T18:30
startsAt2026-11-12 13:00:00+002026-11-12T13:00:00Z
startWithOffset2026-11-12 18:30:00+05:302026-11-12T18:30+05:30
startInZone2026-11-12 18:30:00+05:302026-11-12T18:30+05:30
hostZoneAsia/KolkataAsia/Kolkata
length5400000000000PT1H30M

The local types store the wall-clock values as they are, and Instant is converted to UTC. A ZonedDateTime comes back with the offset +05:30, not with the region Asia/Kolkata. The two values are the same moment (isEqual() returns true), but equals() returns false. So the entity keeps the region in its own hostZone column.

2.3. Storing the Time Zone With @TimeZoneStorage

For OffsetDateTime and ZonedDateTime, Hibernate can store the zone information in several ways. The Hibernate annotation @TimeZoneStorage picks one per field, with a value of the TimeZoneStorageType enum.

private ZonedDateTime defaultStart;                  // no annotation: DEFAULT

@TimeZoneStorage(TimeZoneStorageType.NATIVE)
private ZonedDateTime nativeStart;

@TimeZoneStorage(TimeZoneStorageType.NORMALIZE)
private ZonedDateTime normalizedStart;

@TimeZoneStorage(TimeZoneStorageType.NORMALIZE_UTC)
private ZonedDateTime utcStart;

@TimeZoneStorage(TimeZoneStorageType.COLUMN)
@TimeZoneColumn(name = "columnStart_offset")
private ZonedDateTime columnStart;

We save one Broadcast with 2026-11-12T18:30+05:30[Asia/Kolkata] in every field, while the JVM default zone is America/New_York.

TimeZoneStorageTypeColumn(s)StoredComes back as
DEFAULT (no annotation)timestamp(6) with time zone18:30:00+05:3018:30+05:30
NATIVEtimestamp(6) with time zone18:30:00+05:3018:30+05:30
NORMALIZEtimestamp(6)08:00:0008:00-05:00[America/New_York]
NORMALIZE_UTCtimestamp(6) with time zone13:00:00+0013:00Z
COLUMNtimestamp(6) with time zone + integer13:00:00+00 and 1980018:30+05:30

The strategies differ in what they write.

  • DEFAULT uses NATIVE when the database has a timestamp with time zone type that keeps the offset, as H2 does, and NORMALIZE_UTC on other databases.
  • NATIVE writes the value with its offset into a timestamp with time zone column.
  • NORMALIZE converts the value to the JVM zone, or to hibernate.jdbc.time_zone when it is set, and drops the offset.
  • NORMALIZE_UTC converts the value to UTC.
  • COLUMN stores the UTC value, and the offset in seconds (19800 = 5.5 hours) in a second column named with @TimeZoneColumn.
  • AUTO uses NATIVE when the database supports it, and COLUMN otherwise.
A ZonedDateTime 2026-11-12T18:30+05:30[Asia/Kolkata] persisted while the JVM zone is America/New_York: DEFAULT or NATIVE stores 18:30:00+05:30 and returns 18:30+05:30; NORMALIZE stores 08:00:00 and returns 08:00-05:00[America/New_York]; NORMALIZE_UTC stores 13:00:00+00 and returns 13:00Z; COLUMN stores 13:00:00+00 and offset 19800 and returns 18:30+05:30
All four keep the same instant. Only NATIVE and COLUMN keep the offset, and none keeps the region.

To change the strategy for every field at once, we set hibernate.timezone.default_storage. With NORMALIZE_UTC, startWithOffset and startInZone of a Webinar are both stored as 13:00:00+00 and come back as 2026-11-12T13:00Z. @TimeZoneStorage is marked @Incubating in Hibernate 7.4, so its details can still change in a minor release.

2.4. Creation and Update Timestamps

@CreationTimestamp sets a field to the current time when the entity is inserted, and @UpdateTimestamp sets it on insert and on every update. Both are Hibernate annotations and work with Instant, LocalDateTime and the other timestamp types.

emf.runInTransaction(em -> em.find(Webinar.class, id).setTitle("Java Records in Practice"));
// update Webinar set eventDate=?, hostZone=?, ..., title=?, updatedOn=? where id=?
// createdOn: unchanged
// updatedOn: later than before

By default the time comes from the JVM clock (SourceType.VM). With source = SourceType.DB, Hibernate asks the database for the time in the INSERT itself, which keeps the values consistent when several servers write to one database.

@CreationTimestamp(source = SourceType.DB)
private Instant createdOn;

em.persist(series);
em.flush();      // insert into WebinarSeries (breakLength, createdOn, ...) values (?, localtimestamp, ?, ...)
                 // series.getCreatedOn() is set after the flush

The JPA alternative is a @PrePersist or @PreUpdate method that sets the field.

2.5. Querying by Date Ranges With JPQL

Date and time parameters are passed as Java objects, and Hibernate binds them with the column’s type. For a range of moments, we use a half-open range (>= start and < end) instead of between, because between includes the end value and a value at midnight would match two days.

// 1. All webinars in November, on a LocalDate column
List<Webinar> inNovember = em.createQuery("from Webinar w where w.eventDate between :from and :to order by w.eventDate", Webinar.class)
    .setParameter("from", LocalDate.of(2026, 11, 1))
    .setParameter("to", LocalDate.of(2026, 11, 30))
    .getResultList();                  // [Java Records, Virtual Threads]

// 2. Webinars that start on 2026-11-12 in UTC, on an Instant column
List<Webinar> onDay = em.createQuery("from Webinar w where w.startsAt >= :from and w.startsAt < :to", Webinar.class)
    .setParameter("from", Instant.parse("2026-11-12T00:00:00Z"))
    .setParameter("to", Instant.parse("2026-11-13T00:00:00Z"))
    .getResultList();                  // [Java Records]

// 3. One day on a LocalDateTime column
LocalDate day = LocalDate.of(2026, 11, 11);
List<Webinar> closing = em.createQuery("from Webinar w where w.registrationCloses >= :from and w.registrationCloses < :to", Webinar.class)
    .setParameter("from", day.atStartOfDay())
    .setParameter("to", day.plusDays(1).atStartOfDay())
    .getResultList();                  // [Java Records]

// 4. Compare a Duration
List<Webinar> longOnes = em.createQuery("from Webinar w where w.length >= :min order by w.length desc", Webinar.class)
    .setParameter("min", Duration.ofMinutes(60))
    .getResultList();                  // [Java Records, Virtual Threads]

JPQL also has date functions, and HQL adds more on top of them. The function extract() reads a part of a date, and local date is today’s date on the database (Hibernate sends current_date).

List<Object[]> perMonth = em.createQuery("select extract(month from w.eventDate), count(w) from Webinar w"
        + " where extract(year from w.eventDate) = 2026"
        + " group by extract(month from w.eventDate) order by 1", Object[].class)
    .getResultList();                  // [11, 2], [12, 1]

List<Webinar> upcoming = em.createQuery("from Webinar w where w.eventDate >= local date", Webinar.class)
    .getResultList();                  // where w1_0.eventDate>=current_date

The complete project on GitHub prints the DDL and the SQL of every step with the stored values, and its 22 JUnit tests check the results (mvn -q compile exec:java, mvn test).

3. Date and Time Mapping FAQs

3.1. Do I Still Need @Temporal for Date and Calendar?

Only for java.util.Date and Calendar, and only in old code. The @Temporal annotation tells Hibernate whether a Date is a DATE, TIME or TIMESTAMP; the java.time types do not need it. Hibernate 7.4 still maps it but logs a warning.

WARN org.hibernate.orm.deprecation - HHH90000033: Encountered use of deprecated annotation [interface jakarta.persistence.Temporal] at com.howtodoinjava.hibernate.datetime.WebinarSeries.legacyCreated.

The column type stays the same when we replace the field type, so the migration needs no schema change.

Old mappingColumnNew field
@Temporal(TemporalType.DATE) DatedateLocalDate
@Temporal(TemporalType.TIMESTAMP) Datetimestamp(6)LocalDateTime, or Instant for a moment
@Temporal(TemporalType.DATE) CalendardateLocalDate
java.sql.Timestamptimestamp(6)LocalDateTime

Code that still receives a Date converts it at the edge with date.toInstant() and Date.from(instant).

3.2. Should I Set hibernate.jdbc.time_zone to UTC?

The setting hibernate.jdbc.time_zone is the zone Hibernate uses when it sends a timestamp without an offset to the JDBC driver. It changes what is stored in timestamp columns, not what our Java code sees. For example, with the JVM in New York and the setting at UTC, the same LocalDateTime goes into the column five hours later.

// 1. Setting not present
em.persist(new Webinar("Java Records", start, Duration.ofMinutes(90)));
                                          // registrationCloses column: 2026-11-11 18:30:00

// 2. Setting present: config.property(JdbcSettings.JDBC_TIME_ZONE, "UTC")
em.persist(new Webinar("Java Records", start, Duration.ofMinutes(90)));
                                          // registrationCloses column: 2026-11-11 23:30:00
LocalDateTime closes = em.find(Webinar.class, id).getRegistrationCloses();
                                          // 2026-11-11T18:30

In a Spring Boot application the same setting is spring.jpa.properties.hibernate.jdbc.time_zone=UTC.

With the setting, a LocalDateTime is treated as New York time and shifted to UTC in the column. Our app reads 18:30 back, but a report that reads the table with SQL sees 23:30. Instant columns are stored in UTC either way. We prefer to run the JVM in UTC and use Instant for moments; then the setting changes nothing.

3.3. Should I Use Instant or LocalDateTime for Timestamps?

Use Instant for anything that happened at a moment, and a local type for values people read on a calendar or a clock.

ValueType
Created, updated, paid, logged inInstant
Start of a meeting entered by a userLocalDateTime plus a ZoneId column, or ZonedDateTime
Birthday, holiday, due dateLocalDate
Opening time every dayLocalTime

A LocalDateTime for a moment breaks as soon as two servers or two users are in different zones, because nothing in the column says which zone it is.

3.4. How Do I Keep the Time Zone of a ZonedDateTime?

Store the region in a separate ZoneId field. No TimeZoneStorageType keeps Asia/Kolkata; the best ones keep the offset +05:30. Daylight saving rules need the region, not the offset. For example, a weekly webinar at 18:00 in London runs at +00:00 in winter and at +01:00 in summer, and only Europe/London knows when the offset changes.

private ZonedDateTime startInZone;      // timestamp(6) with time zone
private ZoneId hostZone;                // varchar(255): Asia/Kolkata

ZonedDateTime local = webinar.getStartInZone().withZoneSameInstant(webinar.getHostZone());
                                        // 2026-11-12T18:30+05:30[Asia/Kolkata]

3.5. How Do I Map YearMonth?

Hibernate 7.4 has no built-in mapping for YearMonth, so it serializes the object into a varbinary(255) column that no query can read. An AttributeConverter stores a YearMonth as the first day of the month in a date column instead.

@Converter
public class YearMonthConverter implements AttributeConverter<YearMonth, LocalDate> {

  public LocalDate convertToDatabaseColumn(YearMonth month) {
    return month == null ? null : month.atDay(1);
  }

  public YearMonth convertToEntityAttribute(LocalDate date) {
    return date == null ? null : YearMonth.from(date);
  }
}

@Convert(converter = YearMonthConverter.class)
private YearMonth endMonth;             // date column, YearMonth.of(2027, 3) is stored as 2027-03-01

Year, by contrast, maps to an integer column without a converter.

3.6. Why Does LocalTime Lose Its Milliseconds?

Hibernate creates LocalTime columns as time(0), which has no fraction of a second. On H2, 18:30:15.5 is stored and read back as 18:30:16. A LocalDateTime in timestamp(6) keeps 18:30:15.5. When the fraction matters, we set secondPrecision, which Jakarta Persistence 3.2 added to @Column.

@Column(secondPrecision = 3)
private LocalTime reminderTime;         // time(3): 18:30:15.5 comes back as 18:30:15.500

3.7. Can Hibernate Store a Duration as an Interval?

Yes. By default a Duration is a numeric(21,0) column with nanoseconds, so 90 minutes is stored as 5400000000000. @JdbcTypeCode(SqlTypes.INTERVAL_SECOND) maps it to the SQL interval type on databases that have one, such as H2.

@JdbcTypeCode(SqlTypes.INTERVAL_SECOND)
private Duration breakLength;           // interval second(18,9), stored as INTERVAL '600' SECOND

4. Conclusion

Hibernate 7.4 maps every common java.time type without annotations. It maps the local types to date, time and timestamp, and Instant, OffsetDateTime and ZonedDateTime to timestamp with time zone.

We use Instant for moments and keep a ZoneId next to a zoned value when the region matters. We choose a TimeZoneStorageType on purpose and query ranges with >= and <. @CreationTimestamp and @UpdateTimestamp fill audit columns from the JVM or the database clock.

5. References

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.