Kotlin

kotlinx-datetime, Instant vs LocalDateTime, and java.time interop.

Kotlin’s idiomatic, multiplatform answer to date and time is the kotlinx-datetime library. It deliberately offers a small set of distinct types instead of one overloaded class: Instant (an absolute point in time), LocalDateTime (a civil date-and-time with no zone), LocalDate (a calendar date), LocalTime (a time of day), and TimeZone. Durations come from the Kotlin standard library’s kotlin.time.Duration, while calendar-aware spans use kotlinx-datetime’s DateTimePeriod / DatePeriod.

The central distinction to hold onto is Instant versus LocalDateTime. An Instant names a unique moment on the global timeline — it is the equivalent of an “aware UTC” value and is what you should store when recording when something happened. A LocalDateTime is the naive value: 2026-03-29T01:30 carries no zone, so by itself it does not identify a moment. You cannot move between the two without supplying a TimeZone. See Instant vs civil time and Time zones vs offsets.

A second framing worth keeping: LocalDate names a period — a whole calendar day — while an Instant names a point. They are different kinds of value, not two precisions of one idea. See Points vs periods.

The safe default for “now” is Clock.System.now(), which returns an Instant. To get a human-readable civil reading you convert through a zone: now.toLocalDateTime(TimeZone.of("Europe/Paris")). The reverse, localDateTime.toInstant(timeZone), is the only correct way to pin a naive wall-clock value to the timeline.

Instant vs LocalDateTime

Because Instant and LocalDateTime are separate types, the naive/aware confusion that plagues many languages is caught by the compiler rather than at runtime: there is no implicit conversion, so you are forced to name a TimeZone at the boundary. The remaining hazard is choosing the wrong zone — converting an Instant through TimeZone.currentSystemDefault() when you meant a fixed business zone gives a value that silently changes with the host machine.

Pitfall: A LocalDateTime does not identify a moment in time. Treating one as if it were absolute — comparing two LocalDateTimes that were meant for different zones, or assuming it is UTC — produces results that are off by an offset and shift across DST transitions. Convert to Instant with an explicit TimeZone before comparing or subtracting.

Pitfall: Adding a kotlin.time.Duration (e.g. instant + 24.hours) advances exact elapsed time, not civil time. Across a spring-forward night, “24 hours later” is not “the same wall-clock time tomorrow”. For calendar arithmetic that respects DST, use instant.plus(1, DateTimeUnit.DAY, timeZone) instead.

See also: Instant vs civil time and Parsing user input.

Go deeper: conversions, DST-safe arithmetic, and java.time interop

The core types and conversions:

import kotlinx.datetime.*

val now: Instant = Clock.System.now()                       // an absolute instant
val tz = TimeZone.of("Europe/Paris")

val civil: LocalDateTime = now.toLocalDateTime(tz)          // instant -> wall clock
val back: Instant = civil.toInstant(tz)                     // wall clock -> instant

val today: LocalDate = now.toLocalDateTime(tz).date         // a calendar day (a period)

Parsing is type-directed, so the value you ask for is the value you get — no format guessing:

Instant.parse("2026-06-05T14:00:00Z")     // absolute; offset (Z) required
LocalDate.parse("2026-06-05")             // a date, never silently an instant
LocalDateTime.parse("2026-06-05T14:00")   // civil; stays zone-less by design

Exact vs calendar arithmetic — the distinction behind pitfall #2:

import kotlin.time.Duration.Companion.hours

val tz = TimeZone.of("Europe/London")
val start = LocalDateTime(2026, 3, 29, 1, 30).toInstant(tz)

val exact   = start + 24.hours                       // exactly 24h later
val nextDay = start.plus(1, DateTimeUnit.DAY, tz)    // "same time tomorrow", DST-aware
// Across the spring-forward night these land on different instants.

(One caveat shared by every language: the “same wall-clock time tomorrow” can fall in a DST gap or fold, where the civil reading either does not exist or is ambiguous.)

Interop with java.time on Kotlin/JVM. kotlinx-datetime is multiplatform (JVM, JS, Native, Wasm); on the JVM it is implemented on top of java.time, and you can cross over explicitly when a Java API demands the standard types:

val javaInstant: java.time.Instant = Clock.System.now().toJavaInstant()
val ktInstant: Instant = javaInstant.toKotlinInstant()

If you are JVM-only and never need multiplatform code, using java.time directly is perfectly idiomatic — its Instant / LocalDateTime / ZonedDateTime draw the same boundaries. (Note that recent Kotlin releases are moving Instant and Clock into the standard library under kotlin.time; kotlinx-datetime is aligning with that, so check the versions in your build.)

Inherited java.util.Date / Calendar code is the JVM’s equivalent of JavaScript’s legacy Date: a java.util.Date is always an instant (no date-only or civil type), the deprecated constructors count years from 1900 and months from 0, and Calendar arithmetic silently clamps. Convert at the boundary rather than propagating it: legacy.toInstant().toKotlinInstant().

See also Time zones vs offsets and DST.


← Back to all topics