Introduction
On one unfortunate day, the alert is fired – there is a difference between data in two systems that should be in sync. You checked all the code that might be related, and everything looks good. However, the timestamps are indeed different. Or some users complain about incorrect history records order. Again, the code looks fine, and only some users are affected. Or maybe after some server maintenance, your data doesn’t look like the day before. You didn’t touch the code, and there are no changes in the database, but the system returns different results.
All of the above begins the tiresome and complex journey to find the root cause of these issues. And the reason is the same everywhere – incorrect choice of datetime types in the code, to be more precise – using LocalDateTime as a default option.
Working correctly with time has always been quite challenging due to a number of hidden issues that you can experience after a successful release to production. There are a lot of things that can go wrong. Of course, there are some cases when LocalDateTime is helpful, but you have to choose it deliberately and for a reason, not just select it as a default and most straightforward option. This article is for Java/Kotlin developers. It explains the consequences of misusing LocalDateTime to store timestamps and provides a solution. Also, it contains an overview of all datetime types from the java.time package, together with use cases for each of them.
Issues with LocalDateTime
So, what is wrong with LocalDateTime?
Firstly, and most importantly, since it is Local, no time zone is stored in the value. In other words, there is no explicit time zone, so we must rely on an implicit one. Thus, our code depends on its environment or, if we are lucky, on the hardcoded constants (with the current time zone passed to the objects generating timestamps). The environment is generally considered unreliable and for a reason: server, client, and DB can have different time zones and even change them at some point.
Imagine the following situation: we had a server in Warsaw’s data center that moved to another data center in Lisbon. The time zone changed, but the server domain name remained the same. For both developers and users, there was just a maintenance downtime. Nothing has changed from their perspective. But in fact, for server the environment changed, default time zone changed and all timestamps in the data generated without explicit time zone, will change. And that is the root cause of numerous possible issues.
At the same time, clients calling an API and receiving a response with LocalDateTime inside have to rely on the documentation to identify the time zone. If it is declared in this documentation. Unfortunately, often, it is not even mentioned there. If the latter is the case, it makes the API contract implicit; it is not enforced and, again, is subject to change at any time.
Secondly, another big problem is that you can not compare two LocalDateTime values. It doesn’t make much sense to compare time without a time zone, at least without an implicit one. But even if you are sure you have two values in the same time zone, you still can not compare them because local time is not linear. As a matter of fact, things are much worse: it is not only possible to have duplicates, but time can even go backwards. And DST is one of the examples of how such a situation can arise. DST is a practice of setting clocks forward by one hour in spring and setting it back also by one hour in autumn to bring more daylight to working hours. It is used in many countries.
So, DST is not some hypothetical situation. It is what happens every year in many countries. Let’s take the winter shift on October 29th 2023, in the Europe/Amsterdam time zone as an example. Currently, local time is 02.55, which matches UTC 00:55. Five minutes later, at 03.00, the time shift is executed, and the clocks are set back by one hour. Thus, 10 minutes after 02.55, the local time is 02.05 (UTC 01:05). Fifty minutes later, the time is again 02.55 (UTC 01:55). And here we get two duplicates. Time is not unique anymore.
Consequently, if you can not compare timestamp values, you cannot also sort them. But this is required for many essential features such as providing a list of products, order history records, audit records, to search on some time interval, etc.
Let’s think about it in a more picturesque way. Imagine the following situation: your handsome cat accidentally comes to your kitchen at 02.35 at night, looks at the clock on the kitchen’s wall, and starts doing his business with the food. At 03.00, you also come to the kitchen and set back the clocks by one hour. 40 minutes after your manipulations, the fat cat looks at the clocks again and sees 02.40. He is not able to understand if you changed the time or not and sees the duplicate. He doesn’t know time zones very well and doesn’t know that clocks have shown the same time twice.
And so the cat is confused. For him, just 5 minutes had passed. But he is so hungry again!
ZonedDateTime is not a solution
How can we solve this problem? If the implicit time zone is a cause, should we make it explicit using ZonedDateTime?
Well, ZonedDateTime is significantly better because indeed it contains an explicit time zone. As a result, the API contract is clear now. Users don’t have to check the documentation to understand the time zone – in API, you return ZonedDateTime, which includes the timestamp and a time zone. Even better, there is no dependency on the environment anymore. Since the time zone is exposed in API, it is safe to change it. Therefore, you can choose whichever you want without unpleasant consequences, and backward compatibility will be preserved.
But unfortunately, ZonedDateTime is still non-linear. In the case of DST, the time zone remains the same, but the actual offset from UTC changes. In our first example, before the time shift, the local time has two hour offset from UTC, and after, there is a one hour offset. But even if we know the time zone, it will not help us to avoid getting a duplicate time. We still can’t compare two values, in this case, of the ZonedDateTime type.
Back to the clock on the wall example. Even if your cat knows the time zone, it will not help him understand that it is time to have the next snack.
Maybe OffsetDateTime?
Ok, there is another type, OffsetDateTime. Maybe it is a solution?
OffsetDateTime represents time with some defined offset from UTC. Yes, it is finally linear and safe to use in all cases: you can compare two variables of OffsetDateTime, store them and use them in API without any issues. It is even valid to compare two values with different offsets. However, unfortunately, it’s still too early to celebrate victory.
The real problem lies in OffsetDateTime being very generic. It is very abstract and not tied to any real time zone, and it represents time with some defined offset from UTC. Time and time zone are geographical and political terms because they are tied to the borders of the countries or their subdivisions, and their decisions to follow one or another time zone. Time in any place in the world is defined by the government of the country where this place is located. On the other hand, an OffsetDateTime variable corresponds to some point in time not connected to a real time zone, country, or place in the world.
For example, if we take OffsetDateTime with offset = +1, it will match wintertime in Europe/Amsterdam, but in summer, it will be incorrect for this location. It doesn’t make much sense. Theoretically, the Netherlands might decide to refuse DST a few years later, and after that, our variable will be incorrect.
After all, on the one hand, OffsetDateTime solves all the issues related to LocalDateTime and ZonedDateTime, but on the other, it does not match with any real time zone. Due to that, it introduces its own problems. So, maybe it’s better to use a type that always matches only one time zone? Ideally, the UTC zone as it is a basis for all time zones. And yes, it is a great idea and, luckily, such a type exists!
Instant is your choice by default
And finally, there is good news! You can use one type for timestamp everywhere without problems: Instant is your default option.
Basically, it is ZonedDateTime with a locked UTC time zone. UTC time zone will save us from all the pesky DST issues. When some event happens, it happens at the exact moment in all time zones. And Instant implements this idea: it represents a fixed moment on the timeline and is timezone-agnostic in the sense that it does not relate to the time zone term. There is no way to change the timezone. It just matches UTC. Instant is also fully compatible with OffsetDateTime/ZonedDateTime types both on the serialisation and database level. All of these types implement the same ISO-8601 datetime serialisation/deserialisation standard. Moreover, if you store in DB timestamp with timezone, it’s absolutely correct to refer to this timestamp using Instant/ZonedDateTime/OffsetDateTime in your code. No additional actions are required.
As a result, Instant solves all the issues introduced by LocalDateTime. It doesn’t depend on the environment due to the constant time zone, and the serialised timestamp contains the UTC zone in the API. Furthermore, it’s linear because there is no DST in UTC. Thus, it is safe to use Instant in any case.
All that doesn’t mean you should always use only Instant and nothing more. The idea is that Instant should be your default type. If it doesn’t suit your needs for some reason, then you can consider other types. Each of them is useful for its particular cases.
What are valid use cases for ZonedDateTime?
What are valid use cases for ZonedDateTime?
Well, first of all, it is used for integration and backward compatibility. For example, if you already provide some legacy API for other systems that expect to receive this type from your service or if you need to integrate with another system with tricky requirements. In these cases, you can always use ZonedDateTime to maintain compatibility.
Another example is timezone-aware configuration for schedulers. Imagine you would like to start a promo campaign at a specific time. But in which time zone? With ZonedDateTime, you can explicitly define that it should be at 10:00 a.m. Amsterdam time because it’s a campaign in the Netherlands. You don’t need to think about DST in this case because it should be in the most straightforward format for people supporting this configuration, and you don’t care how this time matches UTC. The last case is when the business logic of your application/feature is heavily connected with timezones (calendar, task manager, time management tools).
But can we ever use LocalDateTime?
Of course, LocalDateTime was created for a reason. Again, it can be used for integration and backward compatibility, it always will be there. You can create a new version of API with Instant for more advanced consumers, and leave an old one with LocalDateTime for those you don’t want to change their logic.
Furthermore, we can use LocalDateTime for timezone-related configuration. For example, we have users distributed between multiple timezones and want to send them a promo notification. Obviously, we would prefer that our users will open this notification, but a chance of that is much higher if we send it at some reasonable time(like 10:00), not in the middle of the night. Consequently, our goal is that all our users will receive a promo at 10.00 a.m. in their time. Thus, they all will get it at a different moment on the timeline. And we can achieve this by defining a desired time as local time and sending notifications based on users timezone. This way all users will receive a push at 10:00 in their timezone. On the implementation side we will use LocalDateTime to store configuration settings, and convert it to ZonedDateTime based on the users timezones, for actual distribution.
And the most common case for LocalDateTime is formatting. LocalDateTime can be used in WEB UI or desktop/mobile applications, for reports. For these purposes when you don’t need to show the time zone for each timestamp. The user doesn’t want to see the time together with time zone, because he knows where he is located. Also, he doesn’t want to see it in UTC. He wants to see it in local time. On the client’s side, you can take the data received from the server, convert it to local time and display it to the user. And here LocalDateTime is the best option.
OffsetDateTime?
As for OffsetDateTime, the real need for this type appears only in highly specialised applications working with offsets.
Conclusion
One day, thoughtless use of the LocalDateTime type will lead to very unpleasant production issues. Yes, these issues might be pretty rare. However, for some applications, the reliability requirements are very high, and the consequences of unpredictable issues might be harrowing. Unfortunately, it’s complicated to predict what might happen, how critical the issue will be, and how extensive the scope of such an issue will be.
But you can easily avoid all the troubles and make your application much more reliable. To achieve that with no maintenance and minimal mental effort, you need to use Instant as a default type whenever you need to work with datetime.
And it is one of the tiny but extremely important bits that can help your application overcome the gap between “works almost all the time” and “works all the time”. Even if you are not writing a mission critical system, using the best practice will make life much easier in the future.