Clean Dates & Times Handling in Java Backends

Here is how to properly handle dates and times since Java 8, and while sending/receiving to/from JavaScript and databases.

Java Date & Time Class Relationships

Here are most of the available classes to manipulate dates and times in Java.
Their relations to the ISO-8601 standard is also described.
The standard is widely understood by most technologies of your stack.
You should then aim to use the standard to store and exchange dates and times.

Class relationships

Explanation of the Schema

The best standard to represent dates and times is ISO-8601.

The schema shows the whole representation.
A lot of its parts are optional.
For dates and/or times having the same parts (and have same ), they can be sorted alphabetically.

The standard is widely supported by all technical tools of our development stacks without much configuration.

Sub-second resolution depends on tools.
Java can generate nano-seconds precision.
JavaScript handles only milli-seconds precision, but has no trouble reading a more precise string by ignoring nano-seconds.

LocalDate is for a relative date valid on any timezone.
Christmas of 2022 is at 2022-12-24, which means a different local time depending on the timezone.
Same concept for LocalTime: e.g. work hours mostly start at 08:00:00 all around the world.
LocalDateTime groups both.

The standard only represents fixed-offset timezones.
Java adds the zone-id concept for dynamic offset computations.
This is important when you need to do computations with dates.
For a zone with Daylight Saving Time, like Europe, a +1h winter offset becomes a +2h summer offset.
When adding 6 months to a winter time of 16:00+01:00 (15:00Z), you get 16:00+01:00 which is now 17:00+02:00 at summer time.
When adding 6 months, Java allows to keep 16:00 local-time, no matter if DST is on or off: the offset is recomputed from the zone-id and the date and time.
Keep in mind that ZoneOffset extends ZoneId: a lot of methods require a ZoneId but you can pass them a zoneOffset.
The most useful one is ZoneOffset.UTC, which is equivalent to an Instant.

OffsetDateTime allows to represent a date and time at a particular timezone offset.
OffsetTime only represents a time on that timezone offset.

ZonedDateTime is the most complete class in Java.
Note: there is no ZonedTime class, because a date is needed for the ZoneId rule to know which ZoneOffset to apply.

Other useful classes include:
DateOfWeek is an enum from MONDAY to SUNDAY.
You can use a custom Clock instance instead of the default system one: all now() static constructor methods can optionally take a Clock instance as a parameter. This is useful to mock dates and times in unit tests and ensure consistent and reliable execution of tests.
DateTimeException is the RuntimeException (unchecked) thrown when doing wrong parsing or calculations on dates.

All these classes must replace the legacy Date and Calendar classes.
The java.util.Date class was quite about the equivalent of an Instant, but at the timezone of the computer running the program, but also viewable as an UTC date/time if queried correctly.
And java.util.GregorianCalendar was quite about the equivalent of a ZonedDateTime but was also a catchall-class with ill-defined purposes.
The new APIs are better segmented, allowing simpler manipulations and compositions.
The new APIs are also immutable, so we benefit from a more stable program, e.g. we can assign the same date to several objects while being sure no object will mutate a date into a foreign other object.

The legacy java.sql.Date and java.sql.Time are equivalent to LocalDate and LocalTime (but without sub-seconds precision) while java.sql.Timestamp is about a LocalDateTime (WITH sub-second precision, this time... for time).
These three classes were extending java.util.class for implementation-purpose, making them really difficult to apprehend.
One such problem is the violation of the Liskov Substitution Principle.

The Period and Duration classes let you compute durations between two date/time values.
The String representation is also part of the ISO-8601 standard.
It starts with a "P" for "Period", then a number of Year, Month and Day (all optional) then a "T" for "Time" and a number of Hours, Minutes and Seconds, also all optional. Note that "M" means Month BEFORE the "T" and Minutes AFTER the "T", so there is no confusion.
The standard allows a period of days AND times, but Java separates the two in two classes.
A Java Duration can still parse "P1DT2H" ("a Period of 1 day and 2 hours") but will generate "P26H" (a Period of 26 jours").

The ISO-8601 Standard

Its Wikipedia article is very well written: concise but still detailed and clear to understand.
The standard is also well-thought, complete, yet simple to parse by machine and to read by humans.

Date Handling Best Practices

Always store/exchange date/times using the standard representation

Only format them to the user's format at the very last moment when displaying them.
Parse them from the user's format at the very first moment they are input.
There is thus no ambiguity about the format (like "is the first number the day or month?").
And conversion can be done automatically (by JSON parsers/writers, by classes constructors, etc.) instead of having to clutter the code with possibly-bugged and very-technical parsing code.

User-locale <=> ISO in JavaScript <=> ISO in REST <=> java.time.* classes in backend (never String) <=> ISO in database

Note: frameworks can serialize/deserialize most java.time.* classes.
A few classes are non-standard, like ZonedDateTime or ZoneId: search for libraries that enhance support for your frameworks (see below to make ZonedDateTime work on MongoDB).

Do not convert between classes just because it is technically easy: always use the classes that are needed by the subject of the current function: if classes of the function input parameters are of another type, convert these parameters to the correct classes instead of converting the business objects into the parameters types.

Write Guidelines for your Projects

It is helpful to write guidelines for your own projects on which classes to pick for each use-cases.

First example of a realistic project:

We support users of several countries with different time zones.
Our servers are deployed across the world: we use servers of varying zones from our cloud company to have a low latency for users.
Every action is historized so we can recall who, why and when an action occurred.

Instant: historize the instant when an action occurred (store at UTC; will be formatted to user's timezone when displayed)
LocalDateTime: never use it in our project
LocalDate & LocalTime: used in database to configure time limits; only used for storage of configuration: always interpret them at headquarters' timezone
ZonedDateTime at user's timezone: converted at the last moment, while displaying a date/time to the user
ZonedDateTime at headquarters' timezone: when a timezone is needed but no user is currently connected to the application, like in batches that generate PDFs or exports destined to headquarters
ZonedDateTime.now() at default's timezone: only used when comparing ZonedDateTime.now() to another ZonedDateTime: comparison also work when time zones differ
ZoneId: the HEADQUARTERS_TIMEZONE is a constant for ZoneId.of("Europe/Paris")
ZoneOffset: the frontend sends the current user's timezone offset in order to generate PDFs and other exports when dates and times are presented to this user's timezone

Second example for a very simple project:

Be sure to be in that exact same case to apply these rules to your project too.

We only support users of our small company in Paris, France.
Our servers are configured explicitly to be in the Europe/Paris timezone.
Thus, we use only LocalDate and sometimes LocalDateTime: they are to be understood at Paris' time zone.
The software mainly manage ranges of dates, represented as:

class DateRange {
  /** Mandatory, inclusive, the range begins at 00:00:00 (morning) of this date */
  LocalDate beginDate;
  /** Optional (extends to infinity if null), inclusive, the range ends at 23:59:59 (evening) of this date  */
  LocalDate endDate;
}

Java

Never use Legacy Date, Calendar, Time and Timestamp Classes Again

Never use java.util.Date nor java.util.Calendar again.

If you use a library that needs them, add an abstraction layer on top of it.
For instance, create a facade to transform objects to the new classes and isolate the code that handles old classes.
Or just configure the library to accept the java.time classes or use the newest version that already configures this.

The API Allows to Produce Concise and Expressive Code

If your code converts date/time using complicated or multi-stage conversions, you are likely not using the correct classes.
The API is well-thought and proposes convenient helper-methods: read the JavaDoc of the package and its classes.

Time-Zones are not Standardized

The "[Europe/France]" is Java-specific, but there is a RFC trying to create a standard for this information.
When storing/loading/exchanging have two fields:
{ "timestamp": "2021-12-31T15:34:09.385426601+01:00", "timezone": "Europe/Paris" }
See this StackOverflow question.

Sub-Second Precision

Since Java 9, times are produced with nanoseconds precision.
JavaScript and MongoDB (among others) handle only millisecond precision: sub-millisecond digits are just ignored.
If you need to compare two timestamps produced or transformed partly in JavaScript/MongoDB and partly in Java, you may discover timestamps are not equal anymore because of sub-millisecond-truncation.
A proper way could be to use Java's Clock.tickMilli from Java 9 to get compatible results.

ZonedDateTime Comparisons

Note: ZonedDateTime can be compared even with different time zones (no need to convert them all to UTC before comparing):

ZonedDateTime europeFranceTime = ZonedDateTime.parse("2022-02-28T14:28:22.160826300+01:00[Europe/Paris]");
ZonedDateTime exactSameUtcTime = ZonedDateTime.parse("2022-02-28T13:28:22.160826300Z");
boolean areSameInstant = europeFranceTime.isEqual(exactSameUtcTime); // true

ZoneId vs. ZoneOffset

ZoneOffset extends ZoneId, so you can pass a simple ZoneOffset on methods that need a ZoneId.
Be careful when creating ZonedDateTime: do not create a ZoneId if you only need a static offset, aka. a ZoneOffset.
For instance:

// Do not create a ZoneId named "UTC" if you only need the ZoneOffset UTC
var dirtyUtcNow = ZonedDateTime.now(ZoneId.ofOffset("UTC", ZoneOffset.UTC)); // 2022-03-02T13:09:37.424516200Z[UTC]
var cleanUtcNow = ZonedDateTime.now(ZoneOffset.UTC);                         // 2022-03-02T13:09:37.424516200Z

// Do not create a ZoneId named "GMT+x" if you only need the ZoneOffset GMT+x
var dirtyGmt1Now = ZonedDateTime.now(ZoneId.of("GMT+1")));    // 2022-03-02T13:09:37.424516200+01:00[GMT+01:00]
var cleanGmt1Now = ZonedDateTime.now(ZoneOffset.ofHours(1))); // 2022-03-02T13:09:37.424516200+01:00

ZoneId are standardized by the IANA Time Zone Database.

Use the Correct Classes

Be sure to not have too many conversions: most often, only one is needed.
If you happen to have several conversions in a row, you are probably not using the correct classes.
For instance:

ZoneOffset gmtPlus1 = ZoneOffset.ofHours(1);

ZonedDateTime dirtyZonedCreation = Instant.now().atZone(gmtPlus1);     // Avoid
ZonedDateTime cleanZonedCreation = ZonedDateTime.now(gmtPlus1);        // Prefer (same result, more direct)

OffsetDateTime dirtyOffsetCreation = Instant.now().atOffset(gmtPlus1); // Avoid
OffsetDateTime cleanOffsetCreation = OffsetDateTime.now(gmtPlus1);     // Prefer (same result, more direct)

Learn More About the java.time Package

More detailed explanations of java.time classes can be found in this StackOverflow question

Another more detailed explanation of java.time API, and why they were needed to replace entirely Date and Calendar from java.util: Java 101: Catching up with the Java Date and Time API (7 pages)

MongoDB

MongoDB uses BSON to store documents.
BSON is a binary representation of JSON.
BSON also adds more types than JSON supports.
For instance, an ObjectId is a special type in BSON but is serialized to a string when viewed as JSON.
Another example: JSON only supports the number type, while BSON allows refinements with Int16 or Int32 types.
JSON does not support a date/time type, so it must be serialized as an ISO string (or a number of seconds since January 1, 1970).
BSON only has one "UTC datetime" data-type (see the BSON specification, BSON is a binary-representation of JSON).
Note: BSON stores UTC date-time in millisecond precision.

All Java classes that fit into a portion of an Instant representation can be stored as "UTC datetime", with ignored fields.
Here is the resulting document as stored in MongoDB.
Although a date object is stored, only the green characters are relevant, other white ones are ignored when read:

{
  "LocalDate":      { "$date": "2022-02-28T00:00:00.000Z" },  // All examples are inserted
  "LocalDateTime":  { "$date": "2022-02-28T11:04:26.777Z" },  // at 11:04 by a computer
  "LocalTime":      { "$date": "1970-01-01T11:04:26.777Z" },  // hosted in GMT+1
  "Instant":        { "$date": "2022-02-28T10:04:26.777Z" },  // (time-zone-id Europe/Paris
  "java.util.Date": { "$date": "2022-02-28T10:04:26.777Z" },  // winter-time: no DST)
}

Java classes that do not fit into a portion of an Instant representation cannot be stored out of the box in MongoDB:

In order to correctly serialize OffsetDateTime and ZonedDateTime, use this Maven/Gradle dependency:
groupId io.github.cbartosiak / artifactId bson-codecs-jsr310

Configure your project with the help of the dependency README.

Here is a decent configuration:

@Singleton
public class Jsr310AdditionalCodecsProvider implements CodecProvider {
    // As date objects to be able to compare them and have an ISO and smaller representation
    private static final LocalDateTimeCodec LOCAL_DATE_TIME_CODEC = new LocalDateTimeCodec();

    // As strings to have an ISO and smaller representation
    private static final ZoneOffsetAsStringCodec ZONE_OFFSET_CODEC = new ZoneOffsetAsStringCodec();

    // As strings to have a smaller representation
    private static final ZoneIdAsStringCodec ZONE_ID_CODEC = new ZoneIdAsStringCodec();

    @Override
    public <T> Codec<T> get(Class<T> clazz, CodecRegistry registry) {
        if (clazz.equals(ZonedDateTime.class)) {
            return (Codec<T>) new ZonedDateTimeAsDocumentCodec(
                    LOCAL_DATE_TIME_CODEC,
                    ZONE_OFFSET_CODEC,
                    ZONE_ID_CODEC);
        } else if (clazz.equals(OffsetDateTime.class)) {
            return (Codec<T>) new OffsetDateTimeAsDocumentCodec(
                    LOCAL_DATE_TIME_CODEC,
                    ZONE_OFFSET_CODEC);
        } else if (clazz.equals(ZoneOffset.class)) {
            return (Codec<T>) ZONE_OFFSET_CODEC;
        } else if (clazz.equals(ZoneId.class)) {
            return (Codec<T>) ZONE_ID_CODEC;
        }
        return null; // Other default codecs are fine to us
    }
}

It produces this stored document (on a machine that is currently at GMT+1):

{
  "OffsetDateTime1": { // = 2022-03-02T14:47:22.745+01:00
    "dateTime": { "$date": "2022-03-02T14:47:22.745Z" },
    "offset": "+01:00"
  },
  "OffsetDateTime2": { // = 2022-03-02T15:47:22.745+02:00
    "dateTime": { "$date": "2022-03-02T15:47:22.745Z" },
    "offset": "+02:00"
  },
  "ZoneId": "Europe/Paris",
  "ZoneOffset": "+01:15",
  "ZonedDateTime": {   // = 2022-03-02T14:47:22.745+01:00[Europe/Paris]
    "dateTime": { "$date": "2022-03-02T14:47:22.745Z" },
    "offset": "+01:00",
    "zone": "Europe/Paris"
  }
}

Unfortunately, dateTime fields are stored as LocalDateTime values, and not converted to UTC.
So comparisons are not possible in most cases.
See this issue, search for a new dependency or create a simple one.

JavaScript

The Date getDay()/getHours()/etc. functions return the local time of the browser.
There are getDayUTC()/getHoursUTC()/etc. functions to get the date at the UTC timezone.

The function date.toISOString() returns an ISO representation, at UTC timezone, like a Java Instant.
The Date constructor accepts an ISO representation:

// Executed on a client with GMT+1 timezone:
new Date("2021")                                  .toISOString() // '2021-01-01T00:00:00.000Z'    Year           read as UTC   ( /!\ changes value once loaded if used as local with negative timezone! )
new Date([2021])                                  .toISOString() // '2021-01-01T00:00:00.000Z'    Year           read as UTC   ( /!\ changes value once loaded if used as local with negative timezone! )

new Date("2021-12")                               .toISOString() // '2021-12-01T00:00:00.000Z'    YearMonth      read as UTC   ( /!\ changes value once loaded if used as local with negative timezone! )
new Date([2021,12])                               .toISOString() // '2021-11-30T23:00:00.000Z'    YearMonth      read as local

new Date(   "--12")                               .toISOString() // '2001-11-30T23:00:00.000Z'    Month          read as local ( /!\ changes value once loaded if used as UTC! )
new Date(    [,12])                               .toISOString() // '2001-11-30T23:00:00.000Z'    Month          read as local ( /!\ changes value once loaded if used as UTC! )

new Date(   "--12-31")                            .toISOString() // '2001-12-30T23:00:00.000Z'    MonthDay       read as local ( /!\ changes value once loaded if used as UTC! )
new Date(    [,12,31])                            .toISOString() // '2001-12-30T23:00:00.000Z'    MonthDay       read as local ( /!\ changes value once loaded if used as UTC! )

new Date("2021-12-31")                            .toISOString() // '2021-12-31T00:00:00.000Z'    LocalDate      read as UTC   ( /!\ LocalDate would be expected to be local, like LocalDateTime! )
new Date([2021,12,31])                            .toISOString() // '2021-12-30T23:00:00.000Z'    LocalDate      read as local

new Date("2021-12-31T14:34")                      .toISOString() // '2021-12-31T13:34:00.000Z'    LocalDateTime  read as local

new Date("2021-12-31T14:34Z")                     .toISOString() // '2021-12-31T14:34:00.000Z'    Instant        read as UTC
new Date("2021-12-31T14:34:09Z")                  .toISOString() // '2021-12-31T14:34:09.000Z'    Instant        read as UTC
new Date("2021-12-31T14:34:09.385Z")              .toISOString() // '2021-12-31T14:34:09.385Z'    Instant        read as UTC
new Date("2021-12-31T14:34:09.385426601Z")        .toISOString() // '2021-12-31T14:34:09.385Z'    Instant        read as UTC   ( /!\ truncated to milliseconds! )

new Date("2021-12-31T15:34:09.385426601+01:00")   .toISOString() // '2021-12-31T14:34:09.385Z'    OffsetDateTime read as offset

new Date("2021-12-31T15:34:09.385426601+01:00[Europe/Paris]")    // Invalid Date                  ZonedDateTime  not supported, because non-ISO
new Date(          "T14:34:09.385")                              // Invalid Date                  LocalTime      not supported (it is ISO)
new Date(          "T14:34:09.385+01:00")                        // Invalid Date                  OffsetTime     not supported (it is ISO)

JavaScript's API is very similar to the old Date API in Java.
JavaScript has a proposal for classes similar to the Java 8 API: Temporal.

To be able to pass the current user's timezone offset to the Java backend as a ZoneOffset, use this code to generate a string like "+01:00".

Angular

In HTML

Use the date pipe (the default timezone is user machine's).

Examples of feeding it with ISO-8601 strings:

{{ '2021-12-31' | date:'MM/dd/yyyy' }}
{{ '2021-12-31T14:34:09.385Z' | date:'MM/dd/yyyy HH:mm' }}

In JavaScript or TypeScript

Use the formatDate function (same usage as the date pipe above).

LocalDate & LocalDateTime

Contrary to plain JavaScript, String representations of LocalDates are well parsed as local dates, and not UTC dates (see JavaScript tests above).
Here is an example:

app.component.ts:

import { Component } from '@angular/core';
@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
})
export class AppComponent {
  localDate = new Date('2021-12-31');
  localDateTime = new Date('2021-12-31T14:34');
}

component.html:

<pre>
LocalDate
* Angular    parses as local: {{ "2021-12-31" | date:"yyyy-MM-ddTHH:mmZ" }} <!-- 2021-12-31T00:00+0100 -->
* JavaScript parses as UTC:   {{ localDate    | date:"yyyy-MM-ddTHH:mmZ" }} <!-- 2021-12-31T01:00+0100 -->

LocalDateTime
* Angular    parses as local: {{ "2021-12-31T14:34" | date:"yyyy-MM-ddTHH:mmZ" }} <!-- 2021-12-31T14:34+0100 -->
* JavaScript parses as local: {{ localDateTime      | date:"yyyy-MM-ddTHH:mmZ" }} <!-- 2021-12-31T14:34+0100 -->
</pre>

Licence & Credits

This page and schema is available under
Attribution-ShareAlike 4.0 International (CC BY-SA 4.0)
License CC BY-SA

The header photo is:
A person holding white electronic device, by Anete Lusina from Pexels

The header background blurred photo is:
Close-up shot of an electronic device, by Anete Lusina from Pexels