Here is how to properly handle dates and times since Java 8, and while sending/receiving to/from JavaScript and databases.
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.
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").
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.
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.
It is helpful to write guidelines for your own projects on which classes to pick for each use-cases.
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
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;
}
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.
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.
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.
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.
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
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.
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)
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 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:
java.lang.IllegalAccessException: class org.bson.codecs.pojo.PropertyAccessorImpl cannot access a member of class java.time.ZoneRegion (in module java.base) with modifiers "public"
org.bson.codecs.configuration.CodecConfigurationException: Cannot find a public constructor for 'OffsetDateTime'
.{
"OffsetDateTime": {
"dayOfMonth": 28,
"dayOfWeek": "MONDAY",
"dayOfYear": 59,
"hour": 11,
"minute": 4,
"month": "FEBRUARY",
"monthValue": 2,
"nano": 777148300,
"offset": {
"rules": {
"fixedOffset": true,
"transitionRules": [],
"transitions": []
},
"totalSeconds": 3600
},
"second": 26,
"year": 2022
}
}
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.
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".
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' }}
Use the formatDate function (same usage as the date pipe above).
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>
This page and schema is available under
Attribution-ShareAlike 4.0 International (CC BY-SA 4.0)
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