When developing business applications, most of us have been faced with the challenge of dealing with dates, times and time zones at some point. The java.time package provides various abstractions for that purpose (e.g. LocalDateTime or ZonedDateTime). But which one is the right one for your project? In this article, we will go through the different classes contained in this package and explain when to use which. In order to provide a complete picture, we will take a fullstack perspective and review the ability of technologies such as JavaScript, SQL and NoSQL DBs to deal with time zones.

Java news

Figure 1: Off for another time zone! Picture by Marina Hinic on Pexels

TL;DR

When working with java.time, you should always use the simplest abstraction possible. For points in time, LocalDateTime and its variants are typically the preferred choice, unless you need to determine an unambiguous moment on the timeline and the time zone is not implicitly clear (or it cannot be deduced from the provided context). Unfortunately, when leaving the realm of the JVM, you will inevitably be faced with the limitations of other technologies. For JavaScript clients, using LocalDateTime (potentially even for dates without time!) or OffsetDateTime is usually the right approach. For persistence, UTC should be used. Depending on the underlying technology, Instant or OffsetDateTime will provide seamless persistence.

Once upon a time

Before delving into the intricacies of time zones, we need to define more clearly what we understand by a moment in time: a moment just represents an unambiguous point on the timeline.

Nothing simpler than that, you would think! Well… Let’s consider a company which has the worldwide policy of pausing its factories between 12 am and 1 pm for the lunch break. The actual position of 12 am on the timeline will vary depending on the time zone in which the factory is located. It is therefore ambiguous. In this article, we will refer to such expressions of time as wall clock times, i.e. times as seen by a human when looking at his watch or at a wall clock. Wall clock times need additional context in order to become unambiguous.

OK then, but 2:35 am on Oct. 29th, 2023 in the Europe/Berlin time zone surely is an unambiguous moment on the timeline, right? Well, no. On that day, Germany switches from summer to winter time. This is done at 3 am by moving the clock backwards to 2 am, which has the interesting effect of having wall clocks showing 2:35 am twice on that day.

If you think that with these few special cases in mind you now master the complexity of time and time zones, then take a look at https://yourcalendricalfallacyis.com. You will discover among other things that time zones are not always defined on the hour mark (e.g. Asia/Kathmandu with +05:45) and that days are not always 24x60x60, i.e. 86,400 seconds long.

Introducing java.time

The java.time package deals with all the complexity brought by time zones and supports the different use cases mentioned above, like wall clock times and moments. It was introduced with Java 8 in 2014 as a replacement for the ill-conceived java.util.Date and java.util.Calendar, whose biggest flaw was the fact that they were mutable. The java.time packages enforces immutability and provides a fluent interface which makes it very convenient to work with dates and times:

LocalDateTime firstMondayInTwoMonthsAt5pm = LocalDateTime.now()
    .plusMonths(2)
    .with(TemporalAdjusters.nextOrSame(DayOfWeek.MONDAY))
    .withHour(17)
    .truncatedTo(ChronoUnit.HOURS);

While the java.time package consists of many different classes, there are six pivotal groups that we want to have a look at in more details. Four of them (LocalDateTime, OffsetDateTime, ZonedDateTime, Instant and their derivates) represent points in time, while the other two (Duration and Period) represent time intervals. Below, you will find a diagram of the four groups used for points in time, as well as the additional information required to transition from one to the other.

Java news

Figure 2: Overview of the java.time classes representing points in time

Let’s now review these different classes in more detail.

LocalDateTime, LocalDate and LocalTime

These classes represent a wall clock time (or date). As such, they do not contain any time zone or offset information. Therefore, they do not represent an (unambiguous) moment on the timeline. This category of classes is typically used when the timezone is not defined (“our factories pause at 12 pm for the lunch break”) or implicitly clear based on the context (“Let’s meet at the bar at 9 pm”).

All these classes can be used for computations (e.g. “now plus two hours”) and the ones featuring a time have a nanosecond precision.

The prefix Local is confusing to many. It should be interpreted as a date or time that you read on your local wall clock, without considering the international context introduced by the time zone.

Examples

  • LocalDateTime: my doctor appointment next Wednesday at 3 pm.
  • LocalDate: my sister's birthdate.
  • LocalTime: the factories' lunch break starting at 12 pm.

OffsetDateTime and OffsetTime

Internally, these classes consist of a LocalDateTime (respectively of a LocalTime) together with a ZoneOffset which represents the offset from UTC/Greenwhich. With these two pieces of information, OffsetDateTime represents a moment on the timeline, while OffsetTime stands for an (unambiguous) moment within the day.

The fact that these classes represent a moment and that they are easily represented as strings (2023-10-29T02:35:00+01:00) makes them particularly appropriate when the information needs to be serialized at the point where it leaves the Java ecosystem (e.g. when persisting the information in a database or when sending it over the network). The string representation used is aligned with ISO 8601. The image below shows its components and its conversion to a local date time and to UTC (which we will deal with in the next section). As you would expect for UTC, the offset (+01:00) is subtracted from the time (02:35 becomes 01:35).

Java news

Figure 3: The ISO 8601 standard

Examples

  • OffsetDateTime: the deadline to submit the offer is 2024-12-03T17:00:00+01:00
  • OffsetTime: our daily meeting will take place at 10:00:00-04:00

Instant

As for OffsetDateTime, Instant represents a moment on the timeline. However, unlike OffsetDateTime, it dispenses with the offset information and stores the moment in UTC (e.g. 2023-06-29T13:30:03.485359700Z).

Since Instant is typically used to capture the moment at which an event happened and not to do complex computations, its API is slightly less expressive than OffsetDateTime (or ZonedDateTime which we will discuss next). Methods like plusDays(), plusMonth() are not available but can still be emulated with the generic plus() method. However, it is not possible to extract temporal units above the second (e.g. to get the number of minutes requires a conversion to another type first).

Instant is particularly useful in contexts where a moment is required, but where the actual offset or time zone is irrelevant.

Example

In a microservices application where service instances can be scattered over the planet, we might want to be able to correlate events in a consolidated log. Where exactly a server is located is not only irrelevant, it actually represents distracting noise when correlating events, because time measurements from different time zones cannot easily be compared. In such a case, it is advisable to use UTC no matter where the server is located.

ZonedDateTime

As for an OffsetDateTime, a ZonedDateTime contains both a LocalDateTime and a ZoneOffset. But it contains a ZoneId in addition. A string representation looks like this: 2023-06-29T15:41:19.278327+02:00[Europe/Zurich]. Having an offset, a ZonedDateTime represents a moment.

The ZoneId is used for computations (like adding 5 hours), because all the rules of the zone (like Daylight saving time (DST)) must be taken into account in such a computation.

But why do we need an offset if we already know the zone? Because the offset varies over time in a given time zone and is sometimes not clearly defined; just remember the 2023-10-29T02:35:00 in the Europe/Berlin time zone example from the introduction, which actually takes place twice since the time zone changes its offset from +02:00 to +01:00 on that day because DST ends. By setting the offset (either to +02:00 or +01:00 in our example), we make sure that we are dealing with an unambiguous moment.

Unlike all the other abstractions presented so far, a ZonedDateTime (by means of its ZoneId) can contain a pointer to a ZoneRegion which encapsulates all the subtle rules describing how the zone offset varies over time for the given zone (due e.g. to DST). Funnily enough, these rules can change over time (the European Union is e.g. considering abolishing DST).

This makes ZonedDateTime useful as a bridge between the UTC-based Instant and the localized LocalDateTime, but also for complex time computations which need to take the offset rules defined by the zone into account.

Examples

A ZonedDateTime makes it possible to transition from an Instant to a LocalDateTime (and vice-versa). So if you want to know when an event in the logfile happened with respect to your wall clock, you could proceed as follows:

LocalDateTime result = Instant.parse("2023-06-29T13:30:03.485359700Z")
    .atZone(ZoneId.of("America/New_York")) // ZonedDateTime
    .toLocalDateTime(); // back to LocalDateTime
    // result = 2023-06-29T09:30:03.485359700

More generally, ZonedDateTime is the class of choice for time computations. As an example, let’s take an airline which wants to fly from Hongkong (HKG) to London Heathrow (LHR), taking off on Mar. 30, 2024 at 11 pm. The airline knows that the flight takes 14h 15min. It can compute the local arrival time as follows:

LocalDateTime arrivalTime = LocalDateTime.of(2024, Month.MARCH, 30, 23, 0)
    .atZone(ZoneId.of("Hongkong")) // ZonedDateTime
    .plus(Duration.ofHours(14).plus(Duration.ofMinutes(15)))
    .withZoneSameInstant(ZoneId.of("Europe/London"))
    .toLocalDateTime(); // back to LocalDateTime
    // arrivalTime = 2024-03-31T06:15

Not bad, considering the fact that London changes from winter to summer time while the plane is in flight! As a side remark and as opposed to London, Hongkong keeps the same offset on that night. This is however not relevant for our computation.

The flight from the day before would land at 05:15 am instead of 06:15 am because this time DST has no impact on the arrival time.

Duration and Period

While all the classes explained so far represent a point in time, Duration and Period are used for time intervals. Duration stores intervals as a number of seconds (with a nanosecond precision) while Period stores the interval in form of days, months and years.

Examples

  • Duration: duration of a flight
  • Period: number of days until the next holiday

Let’s summarize

Now that we have explored the properties and capabilities of each class, let’s summarize them:

Java news

Figure 4: Summary of the class properties

*) Seconds are captured to nanosecond precision.
**) Saves the date, time or interval as a long counting seconds. Therefore, temporal units like months and years are stored implicitely but cannot be retrieved via API.

So what should I use for my project?

One thing is for sure: you should no longer use the ill-conceived java.util.Date. But for the rest, it depends ;-). Now that you have read about the specificities of the main java.time classes, you should already have a rough idea regarding the potential candidates for your project. However, this is a pivotal decision and you have to make sure that you get it right from the very beginning.

In order to support you in this decision, we will first have a look at the domain layer, since this layer should not be subjected to external contingencies (like whether the DB supports the chosen abstraction or not). Then, moving on to the infrastructure layer, we will take a look at limitations imposed both at the persistence level (SQL and NoSQL) and at the UI level, thereby assuming a REST interface consumed by a JavaScript application.

Without any surprise, we will find out that optimal abstractions for the domain layer are not always available at the infrastructure layer when leaving the realm of the JVM. We will then investigate two strategies:

  1. transition from one type to the other (e.g. ZonedDateTime to OffsetDateTime) when crossing the layer boundaries.
  2. make a compromise between perfection and simplicity, and use the same type in all layers.

java.time for the domain layer

Based on your requirements, you can use the decision tree below to choose the right abstraction.

Java news

Figure 5: Decision tree for the domain layer

Some decision points in this diagram are less trivial than they seem, so let’s review the ones that require additional explanations.

Do you really need additional context (offset, time zone) to interpret your date / time correctly?

This is probably the toughest question you will have to answer. Before trying to do so, let me insist on a vital principle: always strive for the simplest abstraction possible and only use time zones when you absolutely need to.

The Javadoc stresses this principle on many occasions:

Where possible, it is recommended to use a simpler class without a time-zone. The widespread use of time-zones tends to add considerable complexity to an application.

[…]

Where possible, applications should use LocalDate, LocalTime and LocalDateTime to better model the domain. For example, a birthday should be stored in a code LocalDate. Bear in mind that any use of a time-zone, such as 'Europe/Paris', adds considerable complexity to a calculation. Many applications can be written only using LocalDate, LocalTime and Instant, with the time-zone added at the user interface (UI) layer.

So you should always consider the types in the order of complexity:

  1. LocalDate, LocalDateTime and LocalDate (simpler)
  2. Instant
  3. OffsetDateTime and OffsetTime
  4. ZonedDateTime (more complex)

With this in mind, how can we be sure that we can stick to LocalDate, LocalDateTime and LocalDate without encountering problems later in the project? There are essentially 2 cases where these local types make sense:

  1. The time zone is implicitly clear.
  2. The time zone has to remain undefined (wall clock time).

The time zone is implicitly clear

When developing a small application to schedule the courses in your local school, you probably don’t want to deal with the complexity induced by time zones. Although: what if a teacher logs into your application from a remote holiday resort to check what courses he will teach when coming back from his vacation? Do we then need to take time zones into account? Absolutely not! No matter from which location the teacher logs in, he will know that the times displayed are to be understood in the context of the local school. Making a course start at 2 am instead of 9 am because of the -07:00 time zone difference between the resort and the school would not be of much help to the teacher.

The offset has to remain undefined (wall clock time)

Sometimes, as in the example of factories closing at 12 pm for the lunch break, we do not want to define the time zone explicitly. Therefore, the context represented by the time zone intentionally remains undefined.

When scheduling events in the future (e.g. meeting), a nice side effect of not fixing the offset with a LocalDateTime is that if time zone rules happen to change in between (e.g. the abrogation of DST by the EU), a meeting scheduled at 10:00 am will remain at 10:00 am no matter what (wall clock time), which is exactly what we want.

Cases where the context of an offset / time zone are mandatory

As explained above, two conditions need to be fulfilled for an offset to be needed:

  1. An unambiguous moment is required.
  2. The offset / time zone in which the event takes place is not implicitly clear.

As an example, when scheduling a video conference in a global company, an unambiguous moment is required so that everyone checks in at the same time (e.g. 10 am). Additionally, an anchor (in form of the offset) has to be determined for the meeting time. Otherwise, a meeting starting at 10:00am would remain ambiguous because the time zone is not implicitly clear (are we talking 10:00 am UTC, 10:00 am at the headquarter or in the timezone in which the meeting was created?).

Is it acceptable to work with UTC only?

As we will see later on, in the vast majority of cases you don’t need to persist the offset / time zone and working with UTC is just fine (actually it is even recommended to work with UTC at the persistence level). However, we are focusing on the domain layer and the business logic for now. Obviously, at this level, there are definitely use cases for which working with UTC and Instants is not ideal:

  • Conversion to wall clock time (LocalDateTime): a conversion from UTC to wall clock time requires some context (the time zone, based on which the offset can be determined). This is why an Instant has to be converted to a ZonedDateTime before it can be transformed into a LocalDateTime. Therefore, in cases like the flight between Hongkong and London where conversions to local time are involved, it is best to work with ZonedDateTime directly.
  • Retrieving or setting specific temporal units: since Instant tracks epoch-seconds, it is not possible to get or set minutes, hours or days. In such cases, it is best to go for OffsetDateTime or ZonedDateTime instead.

Do you need to know the zone and its rules?

Here again, while only very specific use cases will require the persistence of a time zone together with an event, conversions of a moment to a wall clock time (as when computing the local landing time of our airplane in Heathrow) are often a necessity at the business layer.

A word of caution regarding equals() and compareTo()

Please bear in mind that two OffsetDateTime, OffsetTime or ZonedDateTime can capture the exact same moment on the timeline while still not being equal. After all, partying for new year's eve in Sydney (2024-01-01T00:00+11:00[Australia/Sydney]) or enjoying your lunch in Barcelona (2023-12-31T14:00+01:00[Europe/Madrid]) is not quite the same thing, even if both happen perfectly simultaneously. If you are interesting in comparing the moment on the timeline, please use isEqual() instead or consider the use of Instant.

java.time for the web and presentation layer

Now that we have seen which types to use at the domain layer, let’s see which one are supported with REST and with JavaScript, since a web application relying on a REST interface is a very common scenario for an enterprise application.

REST

Here is the result of an object containing all the types presented so far and exposed as JSON. The conversion was realized with Jackson 2.15.2 and without any specific configuration.

{
    "duration": 240.0,
    "period": "P1Y2M3D",
    "localDate": "2023-06-30",
    "localDateTime": "2023-06-30T15:46:41.622250837",
    "localTime": "15:46:41.622250837",
    "offsetDateTime": "2023-06-30T15:46:41.622250837+03:00",
    "offsetTime": "15:46:41.622250837+03:00",
    "zonedDateTime": "2023-06-30T15:46:41.622250837+03:00"
    "instant": "2023-06-30T12:46:41.622250837Z",
}

As we can see, all the types have been serialized properly. The ZonedDateTime lacks the zone though. But this can be seen as an advantage in the context of a JavaScript consumer, as we will see below. However, if your really want to expose the zone ID, you can do so by setting SerializationFeature.WRITE_DATES_WITH_ZONE_ID.

JavaScript

JavaScript is a lot more limited than Java when dealing with dates and times. It only knows Date objects, which do not make the distinction between moments and wall clock times (they are just moments). Furthermore, according to the specification, JavaScript engines are only required to support the format YYYY-MM-DDTHH:mm:ss.sssZ, whereas parts of this string may be omitted. But beware: "date-only forms are interpreted as a UTC time and date-time forms are interpreted as local time". This is due to a historical spec error that was not consistent with ISO 8601 but could not be changed due to web compatibility. Luckily, JavaScript engines may go beyond what the spec allows, and in fact most browsers do. Here is an example with the console of Firefox 114.0.2:

# LocalDateTime
>> new Date("2023-06-30T15:46:41.622250837")
Date Fri Jun 30 2023 15:46:41 GMT+0200 (Central European Summer Time)
 
# LocalDate: ouch! This is the spec error mentioned above. But at least it is backward compatible ;-)
>> new Date("2023-06-30")
Date Fri Jun 30 2023 02:00:00 GMT+0200 (Central European Summer Time)
 
# LocalTime
>> new Date("15:46:41.622250837")
Invalid Date
 
# OffsetDateTime: the original offset is lost but the moment remains the same
>> new Date("2023-06-30T15:46:41.622250837+03:00")
Date Fri Jun 30 2023 14:46:41 GMT+0200 (Central European Summer Time)
 
# OffsetTime
>> new Date("15:46:41.622250837+03:00")
Invalid Date
 
# ZonedDateTime: the original offset is lost but the moment remains the same
>> new Date("2023-06-30T15:46:41.622250837+03:00")
Date Fri Jun 30 2023 14:46:41 GMT+0200 (Central European Summer Time)
 
# Instant: the original offset (UTC) is lost but the moment remains the same
>> new Date("2023-06-30T12:46:41.622250837Z")
Date Fri Jun 30 2023 14:46:41 GMT+0200 (Central European Summer Time)

As illustrated above, the browser systematically uses the local time zone, even if the original time zone / offset is specified explicitly.

In summary

Java news

Figure 6: Support for the different java.time classes

In order to circumvent the spec error mentioned above, you might want to translate your LocalDates to LocalDateTimes when exposing them for a JavaScript client. If you really want to be on the safe side and prevent any misinterpretation, it is usually best to stick to the YYYY-MM-DDTHH:mm:ss.sssZ format when working with JavaScript. This will be converted to a local datetime by the JavaScript engine.

If you have more specific requirements (like supporting OffsetTime, LocalDateTime or Duration / Period in your JavaScript code), you might want to take a look at a library like js-joda.

java.time for the persistence layer

After having had a look at the domain and web layer, let’s take a look at the persistence layer. When persisting datetimes, it is always best to do so in the UTC format unless you have the specific requirement of being able to reconstruct the initial offset / time zone, which is extremely rare. Let’s have a look at the support for java.time with relational databases and with MongoDB as an example of NoSQL DBs.

Relational Databases: Jakarta Persistence and Hibernate

As mentioned above, in most cases you are best off enforcing UTC as the default offset.

With JDBC 4.2 and JPA 2.2, a subset of the java.time classes started to be supported:

  • LocalDate, LocalTime and LocalDateTime
  • OffsetTime, OffsetDateTime

This is fully sufficient for 99% of the cases, since you can store wall clock times as well as moments on the timeline in the UTC format. Now for the special cases not covered with this subset, Hibernate went beyond the JPA specification and started supporting the following types:

  • Duration
  • Instant
  • ZonedDateTime

For ZonedDateTime, Hibernate 5 converted it to the local timezone and stored it without the timezone information. This caused a lot of problems and with Hibernate 6, you now have the possibility to store the time zone as well. Depending on the support provided by the underlying DB, you can take different approaches to persist the time zone. Depending on the approach taken, the offset might be kept but the time zone (e.g. Europe/Berlin) will be lost in all cases.

To finish with, Jakarta Persistence 3.1 introduced special functions for Local Date and Time.

MongoDB

Following the paradigm outlined above, MongoDB’s Date objects use the BSON UTC datetime datatype. This means that datetimes will be converted to UTC. Applications which need to keep an offset or a time zone must store them in a separate field and reconstruct the local time in their application logic.

This means that the only java.time types supported out of the box are:

  • LocalDate, LocalTime and LocalDateTime
  • Instant

For the others, custom converters can be written.

In summary

At the persistence layer, as long as you stick to the rule of either using wall clock times (Local*) or UTC, you will be fine. Otherwise, things will rapidly start getting hairy.

Java news

Figure 7: Support for the different java.time classes

The fullstack perspective

Without any surprise, there is no silver bullet when dealing with dates and times and taking a fullstack perspective: we basically have to resort to one of two strategies: either convert between types at layer boundaries or try to find a one-size-fits-all type which covers most of our requirements across the stack. Let’s take a closer look at these two strategies.

Conversion between types

The java.time package provides a lot of ways to convert one type into the other, as can be seen in the picture below.

Java news

Figure 8: Transitions between the different types

The quest for the one-size-fits-all type

When dealing with wall clock times, LocalDateTime is well supported from the persistence up to the UI layer. For moments, OffsetDateTime seems like a good candidate when working with a relational database. With MongoDB, Instant should be preferred. However, all these types have their limitations and working with ZonedDateTimes at the domain layer might become a necessity.

This concludes our journey in the realm of time zones. So don’t forget: only use time zones if you really have to. And if so, use the simplest abstraction possible, especially when interacting with non-Java technologies.

Written by Cédric Schaller
Senior Architect at ELCA Group