An introduction to java.time

Time is a complex topic. Many programmers are unfamiliar with the difficulties and common bugs associated with time. This has in the past lead to time APIs that allowed “sloppy” code which can lead to bugs in edge-cases that will never be caught during testing but will hit you two years down the line. The old time APIs – java.util.Date and java.util.Calendar – did exactly that.

With Java 8, the standard library now contains one of the best date/time APIs in any language. java.time does not allow “sloppy” code anymore – it is very explicit. This can help reduce bugs if you know how to use it. Unfortunately, many online resources still use the old APIs and many programmers are confused by the different types in java.time (Instant, LocalDateTime and so on). The goal of this article is providing a basic overview of the most important classes, how to use them and most importantly when to use which.

It should be said that all types in the java.time package are immutable. If you are not familiar with the concept of immutability, I recommend reading my previous article Immutability in Java.

This article is about time in java. For a general resource on time in programming, I recommend “Falsehoods programmers believe about time”.

Concepts

Time

Time-of-day, or just time, is the 24-hour (or 12-hour am/pm) time we are all familiar with. 0:00 is midnight, and 12:00 is noon. Because the earth is a globe and it isn't noon for everyone at the same time (unfortunate choice of words there), time varies by time zone.

Honolulu
({{now.tz("Pacific/Honolulu").format("Z")}})
{{now.tz("Pacific/Honolulu").format("HH:mm")}}
{{userZone.substring(userZone.indexOf("/") + 1)}}
({{now.tz(userZone).format("Z")}})
{{now.tz(userZone).format("HH:mm")}}
Kathmandu
({{now.tz("Asia/Kathmandu").format("Z")}})
{{now.tz("Asia/Kathmandu").format("HH:mm")}}
Sydney
({{now.tz("Australia/Sydney").format("Z")}})
{{now.tz("Australia/Sydney").format("HH:mm")}}

java.time offers the LocalTime and OffsetTime classes for working with time. To get the current time, you need to know the time zone.

LocalTime.of({{now.tz(userZone).format("H")}}, {{now.tz(userZone).format("m")}});
LocalTime.of({{now.tz(userZone).format("H")}}, {{now.tz(userZone).format("m")}}, {{now.tz(userZone).format("s")}});
LocalTime.of({{now.tz(userZone).format("H")}}, {{now.tz(userZone).format("m")}}, {{now.tz(userZone).format("s")}}, {{now.tz(userZone).format("SSS")}}{{nanoMixin}}); // with nanoseconds

LocalTime.now(ZoneId.of("Pacific/Honolulu")); // {{now.tz("Pacific/Honolulu").format("HH:mm:ss.SSS")}}
LocalTime.now(ZoneId.of("{{userZone}}")); // {{now.tz(userZone).format("HH:mm:ss.SSS")}}
LocalTime.now(ZoneId.of("Asia/Kathmandu")); // {{now.tz("Asia/Kathmandu").format("HH:mm:ss.SSS")}}
LocalTime.now(ZoneId.of("Australia/Sydney")); // {{now.tz("Australia/Sydney").format("HH:mm:ss.SSS")}}

OffsetTime.now(ZoneId.of("Pacific/Honolulu")); // {{now.tz("Pacific/Honolulu").format("HH:mm:ss.SSSZ")}}
OffsetTime.now(ZoneId.of("{{userZone}}")); // {{now.tz(userZone).format("HH:mm:ss.SSSZ")}}
OffsetTime.now(ZoneId.of("Asia/Kathmandu")); // {{now.tz("Asia/Kathmandu").format("HH:mm:ss.SSSZ")}}
OffsetTime.now(ZoneId.of("Australia/Sydney")); // {{now.tz("Australia/Sydney").format("HH:mm:ss.SSSZ")}}

As you can see, the only difference between the two classes is that OffsetTime includes offset information (more on offsets and zones below). In the majority of cases LocalTime is sufficient.

Date

A date is just what you'd expect — the combination of year, month and day. Just like time, the date is timezone-specific — it might be monday in Australia but still sunday in Europe. It might even be three different dates across the world!

Samoa
{{now.tz("Pacific/Samoa").format("dddd, YYYY-MM-DD")}}
{{userZone.substring(userZone.indexOf("/") + 1)}}
{{now.tz(userZone).format("dddd, YYYY-MM-DD")}}
Christmas Island
{{now.tz("Pacific/Kiritimati").format("dddd, YYYY-MM-DD")}}

To represent dates, you use the LocalDate class:

LocalDate.of({{now.tz(userZone).format("YYYY")}}, {{now.tz(userZone).format("M")}}, {{now.tz(userZone).format("D")}});

LocalDate.now(ZoneId.of("Pacific/Samoa")); // {{now.tz("Pacific/Samoa").format("YYYY-MM-DD")}}
LocalDate.now(ZoneId.of("{{userZone}}")); // {{now.tz(userZone).format("YYYY-MM-DD")}}
LocalDate.now(ZoneId.of("Pacific/Kiritimati")); // {{now.tz("Pacific/Kiritimati").format("YYYY-MM-DD")}}

Datetime

Datetime is just the combination of date and time. There are three classes that represent this concept: LocalDateTime, OffsetDateTime and ZonedDateTime. Just like with time, OffsetDateTime and ZonedDateTime are just a LocalDateTime with additional time zone info attached (a ZoneOffset and ZoneId respectively, more on that below).

LocalDateTime.of({{now.tz(userZone).format("YYYY")}}, {{now.tz(userZone).format("M")}}, {{now.tz(userZone).format("D")}}, {{now.tz(userZone).format("H")}}, {{now.tz(userZone).format("m")}}); // {{now.tz(userZone).format("YYYY-MM-DDTHH:mm")}}
LocalDateTime.of({{now.tz(userZone).format("YYYY")}}, {{now.tz(userZone).format("M")}}, {{now.tz(userZone).format("D")}}, {{now.tz(userZone).format("H")}}, {{now.tz(userZone).format("m")}}, {{now.tz(userZone).format("s")}}); // {{now.tz(userZone).format("YYYY-MM-DDTHH:mm:ss")}}
LocalDateTime.of({{now.tz(userZone).format("YYYY")}}, {{now.tz(userZone).format("M")}}, {{now.tz(userZone).format("D")}}, {{now.tz(userZone).format("H")}}, {{now.tz(userZone).format("m")}}, {{now.tz(userZone).format("s")}}, {{now.tz(userZone).format("SSS")}}{{nanoMixin}}); // {{now.tz(userZone).format("YYYY-MM-DDTHH:mm:ss.SSS")}}{{nanoMixin}}

LocalDateTime.now(ZoneId.of("Pacific/Samoa")); // {{now.tz("Pacific/Samoa").format("YYYY-MM-DDTHH:mm:ss.SSS")}}
LocalDateTime.now(ZoneId.of("{{userZone}}")); // {{now.tz(userZone).format("YYYY-MM-DDTHH:mm:ss.SSS")}}
LocalDateTime.now(ZoneId.of("Pacific/Chatham")); // {{now.tz("Pacific/Chatham").format("YYYY-MM-DDTHH:mm:ss.SSS")}}

ZonedDateTime.now(ZoneId.of("{{userZone}}")); // {{now.tz(userZone).format("YYYY-MM-DDTHH:mm:ss.SSSZ")}}[{{userZone}}]
OffsetDateTime.now( ZoneId.of("{{userZone}}")); // {{now.tz(userZone).format("YYYY-MM-DDTHH:mm:ss.SSSZ")}}

Zone

In java.time, there are two distinct concepts of time zones: Offsets (ZoneOffset) and zone IDs (ZoneId). The difference is that while a ZoneOffset is always a fixed offset from UTC, a ZoneId can have different offsets depending on time of year (usually because of daylight savings time).

// normal time, +01:00
LocalDateTime.of(2018, 1, 1, 12, 0).atZone(ZoneId.of("Europe/Berlin")).getOffset();

// daylight saving time, +02:00
LocalDateTime.of(2018, 8, 1, 12, 0).atZone(ZoneId.of("Europe/Berlin")).getOffset();

Every ZoneOffset is also ZoneId (ZoneOffset extends ZoneId ).

Instant

Now to the by far most useful class: Instant. This is how machines typically work with times but it's not very intuitive to humans. The most important feature of Instants is that they do not depend on time zone — it is always the same Instant across the whole world. Traditionally, there have been two representations of instants: Either epoch-based time (the number of seconds passed since 1970-01-01 00:00:00 UTC, excluding leap seconds) or as an ISO 8601 timestamp like {{now.tz("UTC").format("YYYY-MM-DDTHH:mm:ss")}}Z. An Instant is basically a nicer wrapper around System.currentTimeMillis().

Note that while the ISO 8601 timestamp may look like it, an Instant is not a datetime. You always need a zone to convert from Instant to a datetime — the ISO 8601 timestamp just uses UTC as the zone.

Instant.now(); // {{now.tz("UTC").format("YYYY-MM-DDTHH:mm:ss")}}Z
Instant.now().getEpochSecond(); // {{now.format("X")}}
Instant.now().toEpochMilli(); // {{now.format("x")}}, same as System.currentTimeMillis()

Principles

Instants for computers, DateTime for humans

Instant is the “natural” way of representing time for computers but it is often useless to humans. For storage (for example in a database), Instant is usually preferred but you may need to use other classes like ZonedDateTime when presenting data to a user.

In this example, Alice is entering a datetime, maybe for when a phone conference will take place. Alice lives in the {{userZone}} time zone, but the conference is at the same Instant across the world, so we will use Instant for persistence.

// Alice (in time zone {{userZone}}) is entering a datetime
String aliceInput = "{{now.tz(userZone).format("YYYY-MM-DD HH:mm:ss")}}";
ZoneId aliceZone = ZoneId.of("{{userZone}}");
ZonedDateTime parsed = LocalDateTime.parse(aliceInput, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
       .atZone(aliceZone);
Instant instant = parsed.toInstant();
// instant is stored in the DB

// Bob (in time zone Australia/Sydney) is viewing the datetime Alice has entered
ZoneId bobZone = ZoneId.of("Australia/Sydney");
ZonedDateTime atZone = ZonedDateTime.ofInstant(instant, bobZone);
output(atZone.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); // {{now.tz("Australia/Sydney").format("YYYY-MM-DD HH:mm:ss")}}

Arithmetic

When doing arithmetic on time types, you have to be very specific in what you want. When a user says “one day from now” that is a different statement than “24 hours from now” - there might be a daylight savings time switchover in between! This is where OffsetDateTime and ZonedDateTime really show their difference:

// 2018-03-24T12:00+01:00[Europe/Berlin]
ZonedDateTime zonedA = LocalDateTime.of(2018, 3, 24, 12, 0).atZone(ZoneId.of("Europe/Berlin"));
OffsetDateTime offsetA = zonedA.toOffsetDateTime(); // 2018-03-24T12:00+01:00
Instant instantA = zonedA.toInstant(); // 2018-03-24T11:00:00Z
instantA.equals(offsetA.toInstant()) // true

ZonedDateTime zonedB = zonedA.plusDays(1); // 2018-03-25T12:00+02:00[Europe/Berlin]
OffsetDateTime offsetB = offsetA.plusDays(1); // 2018-03-25T12:00+01:00
instantB.equals(offsetB.toInstant()) // false

As you can see, even though we called the same method (plusDays), the two different classes produced different Instants (plusHours(24) behaves the same way). Both are correct – you have to decide which of the two you need for your use case.

Conversion

I have...
Instant LocalDateTime OffsetDateTime ZonedDateTime
I want... Instant ldt.toInstant(ZoneOffset) odt.toInstant() zdt.toInstant()
LocalDateTime LocalDateTime.ofInstant(instant, ZoneId) odt.toLocalDateTime() zdt.toLocalDateTime()
OffsetDateTime OffsetDateTime.ofInstant(instant, ZoneId) ldt.atOffset(ZoneOffset) zdt.toOffsetDateTime()
ZonedDateTime ZonedDateTime.ofInstant(instant, ZoneId) ldt.atZone(ZoneId) odt.atZoneSameInstant(ZoneId)
odt.atZoneSimilarLocal(ZoneId)

For the conversions where a zone is listed, a zone is required. You cannot get around specifying a zone though there may be a sensible default such as ZoneOffset.UTC. (The one exception is OffsetDateTime to ZonedDateTime, which you can do without, but it's not very useful because it will not pick the right ZoneId for most purposes)

Compatibility

java.util.Date

Date is an odd class. It is named date, but it actually wraps a milliseconds timestamp just like Instant. You can easily convert between the two using Date.from(Instant) and date.toInstant().

Unfortunately, Date was the de facto date/time/instant/everything class of the java ecosystem for a long time. While the various date/time methods such as getMinutes were deprecated in java 1.1, many old libraries still use it to represent date/time. A honorable mention also goes to java.sql.Time (and friends) — a class that is made exactly to represent a time but which extends Date which is totally unsuited for this. In these cases, libraries typically assume the system time zone (ZoneId.systemDefault()). You can use this time zone to convert, for example, a LocalDateTime to a Date that might be properly recognized by the library you're interacting with, but there is no guarantee.

SQL

With java 8, JDBC also supports java.time types. Interaction with the driver is done through the Object-based methods such as ResultSet.getObject. JPA 2.2 also supports java.time by default.

If you are using an older driver, various JDBC time types have factories that can take java.time objects, for example java.sql.Time.valueOf(LocalTime). There are also inverse methods available.

FAQ (or: no, you really can't do that)

I have an Instant, and would like to get its LocalDate/date/weekday/year/...
You must specify a zone. At this point in time, the same Instant (unix time {{now.format("X")}}) has two different dates on different places on earth: it is {{now.tz("Pacific/Samoa").format("dddd, YYYY-MM-DD")}} on Samoa but {{now.tz("Pacific/Kiritimati").format("dddd, YYYY-MM-DD")}} on Kiritimati (Christmas Island). Once you have decided on a time zone(ZoneOffset.UTC might be a good pick — but it depends), you can do LocalDateTime.ofInstant(instant, zone)
I have a wallclock datetime (for example {{now.tz(userZone).format("YYYY-MM-DD HH:mm:ss")}}) and would like the unix time.
You must specify an offset. Without an offset, that datetime is unix time {{moment.tz(now.tz(userZone).format("YYYY-MM-DD HH:mm:ss"), "UTC").format("X")}} in UTC but {{moment.tz(now.tz(userZone).format("YYYY-MM-DD HH:mm:ss"), "CET").format("X")}} in Central European Time. Furthermore, a ZoneId alone is not sufficient — the same datetime can be two different instants during a daylight savings switchover (or can be no instant at all).
A user has submitted a HTML form with a datetime string {{now.tz(userZone).format("YYYY-MM-DD HH:mm:ss")}}. How do I get the Instant?
Again, you need a zone. Unfortunately, user zone info is not sent with HTTP. There are several options:
I'm on Java 7. What do I do?
There is a backport available for the java.time API.