1. Why there are multiple time types
When you start programming, you want to believe that time is a single thing: “well, it’s just date and time, what’s so hard?”. But reality is like Jira: one entity, and thirty statuses. In kotlinx.datetime you get several types not to torture you, but for precision of thought: “what exactly am I storing, and what am I allowed to do with it?”.
The most important idea of this lecture: the type is chosen by meaning, not by the string format you are used to.
Let’s summarize “who is who” in a table—it’s useful to keep in mind:
| Type | Meaning (conceptually) | Real-life example | What it doesn’t have |
|---|---|---|---|
|
“A moment on the time line” (a point in time) | when a payment actually happened, a log was recorded, a request arrived | local clock time, a “calendar date” without a zone |
|
“A calendar date” | date of birth, a deadline date “by the calendar” | time of day, time zone |
|
“Local clock + calendar” | a meeting “at 15:00 on March 10” in a specific city | time zone (so it can be ambiguous) |
|
“Translation rules” between Instant and local representations | “Oslo”, “UTC”, “America/New_York” | this is not a date and not a time; it is specifically rules |
The authors of kotlinx-datetime explicitly recommend choosing the type for the job: for events/logs, most often Instant; for dates without time, LocalDate; and for future “local” events, LocalDateTime plus a separately stored zone.
Kotlin 2.3 and importing Instant and Clock
If you use Kotlin 2.3, the IDE may suggest import kotlin.time.Instant and import kotlin.time.Clock, and that’s fine: the ecosystem evolved, and some functionality (in particular Instant/Clock) was moved from kotlinx-datetime into the Kotlin standard library.
At the same time, kotlinx.datetime remains important, because that’s where the “calendar” types live, such as LocalDate, LocalDateTime, TimeZone, and the convenient conversions between a “moment” and “local components”. In this lecture we’ll use the combination: Instant/Clock (as the moment) + kotlinx.datetime (as the calendar and time zones).
One more grown-up remark: according to Kotlin’s component stability table, the kotlinx-datetime library had Alpha status for a long time. That doesn’t mean “dangerous”; it means “the API could change”, so it’s especially important to trust your IDE/version documentation if signatures differ slightly.
2. Instant: a moment that knows nothing about your calendar
When you see the word Instant, it helps to say to yourself: “a moment”. Not “local time”, not “today at 12:30”, but exactly a point on the time line.
The authors of kotlinx-datetime describe Instant as a universal timestamp that is convenient for events that already happened (logs, transactions) or for the near future, where time-zone rules are unlikely to change in time.
Instant itself is very honest: it doesn’t try to guess what city you’re in. It doesn’t say “it’s 19:05 now”. It says roughly “here is the moment”. That’s it. Want “local clock time”? Bring a time zone.
Mini example: get “now” as a moment in time.
import kotlin.time.Clock
import kotlin.time.Instant
fun main() {
val now: Instant = Clock.System.now()
println(now) // for example: 2026-01-14T21:03:10.123Z
}
Note: the output often looks like ISO-8601 with a Z at the end. Z denotes UTC in the string representation of the moment. This is not “your clock time”; it’s just a standard way to show a moment in time.
3. LocalDate: a calendar date without time-of-day and time zone
LocalDate is the type that answers the question “what date is it?”. Exactly the date: year, month, day. It knows nothing about hours, minutes, seconds—and that is not a drawback, it’s protection against confusion.
A classic example is a date of birth. If someone was born on 2001-05-20, that is a date, not a “moment in time”, and tying it to an Instant is usually pointless (and sometimes even harmful).
Example of creating a LocalDate from components:
import kotlinx.datetime.LocalDate
fun main() {
val d = LocalDate(2026, 1, 6)
println(d) // 2026-01-06
}
Why is this more convenient than a string "06.01.2026"? Because now we have a date as a type, and the compiler won’t let us “accidentally” add it to a string or compare it as text. (We’ll cover validation and string parsing in detail in another lecture.)
4. LocalDateTime: local date and time without a time zone
LocalDateTime sounds like “there it is—date and time, victory!”. And here it’s important not to fall into a trap: LocalDateTime is local components (year/month/day + hour/minute/second), but without a time zone.
The kotlinx-datetime documentation puts it very directly: LocalDateTime is good as a human-readable representation of an Instant, for data transfer, and for planning future events “in local time” (especially if zone rules may change). At the same time, the library intentionally does not provide arithmetic on LocalDateTime, because without a time zone it’s easy to produce “a time that doesn’t exist” on the day the clock changes.
Example of creating a LocalDateTime from components:
import kotlinx.datetime.LocalDateTime
fun main() {
val dt = LocalDateTime(2026, 1, 6, 10, 0, 0)
println(dt) // 2026-01-06T10:00:00
}
There is no information here about which zone this “10:00” is in. New York? Berlin? UTC? The program has no right to guess—otherwise bugs would be not “sometimes”, but “always, on schedule”.
5. TimeZone: translation rules without which “10:00” does not become a moment
TimeZone is not time and not date. It’s a set of rules: how to translate a “moment” (Instant) into “local components” (LocalDateTime) and back. The same point on the time line (Instant) will show different “local clock times” in different zones. That’s why a time zone is a required part of the translation.
In kotlinx.datetime there are several practical ways to get a time zone:
import kotlinx.datetime.TimeZone
fun main() {
val systemTz = TimeZone.currentSystemDefault()
val utc = TimeZone.UTC
val berlin = TimeZone.of("Europe/Berlin")
println(systemTz) // for example: America/Los_Angeles
println(utc) // UTC
println(berlin) // Europe/Berlin
}
Zone identifiers like "Europe/Berlin" come from the IANA time zone database. These are the “official” names you often see in server and OS settings.
6. Conversions Instant ↔ LocalDateTime via TimeZone
Now the most practical part: how to turn a “moment” into “local clock time” and back. Here it’s important to remember a rule that saves you from half of time bugs:
An Instant can be unambiguously converted to a LocalDateTime if the zone is known.
But a LocalDateTime cannot always be unambiguously converted to an Instant, even if the zone is known, because there are time changes (DST), “gaps”, and repeated hours.
Instant -> LocalDateTime: show local clock time
This conversion is logically safe: the moment is single, the zone is known, so the local components are determined unambiguously (by the zone rules).
Example with two zones—UTC and the system one:
import kotlin.time.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
fun main() {
val now = Clock.System.now()
println(now.toLocalDateTime(TimeZone.UTC)) // in UTC
println(now.toLocalDateTime(TimeZone.currentSystemDefault())) // in the local zone
}
This exact approach is shown in the project documentation/README: convert Instant to LocalDateTime via toLocalDateTime(timeZone), and TimeZone.currentSystemDefault() is a fine option for displaying “as on this computer”.
LocalDateTime -> Instant: interpret these local clock components as a moment
This is where adult life starts. LocalDateTime by itself is “time components without a zone”. To make a “moment” out of it, you must say: in which zone these components should be interpreted.
A simple example:
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
fun main() {
val meeting = LocalDateTime(2026, 1, 6, 10, 0, 0)
val tz = TimeZone.of("UTC")
val instant = meeting.toInstant(tz)
println(instant) // 2026-01-06T10:00:00Z
}
But it’s important to understand that such a conversion is not always unambiguous. The documentation describes three situations: there may be exactly one moment (easy), it may not exist at all (a “forward” transition when time jumps), or it may exist twice (a “backward” transition when an hour repeats). In ambiguous cases, the library chooses the “earlier” option by its rules so it can still return some Instant.
This does not mean “you don’t have to think”. It means “the library will prevent a crash, but the meaning of the decision is on you”.
7. Mini map: what converts to what
To avoid confusion, it’s handy to keep a mini map:
flowchart LR
I["Instant
moment"] -- "toLocalDateTime(timeZone)" --> LDT["LocalDateTime
local components"]
LDT -- "toInstant(timeZone)" --> I
TZ["TimeZone
rules"] --- I
TZ --- LDT
LD["LocalDate
calendar date"] --- LDT
The most important takeaway from this diagram: TimeZone is not an “option”, but a required part of converting “moment ↔ local clock time”.
8. Example: start storing event time correctly
Imagine our practical console app (which we’ve been evolving for a long time) is a small expense tracker: we add expenses, print a list, build reports. Previously, we could store the date as a string (“that’s what the user typed”). Now we’ll do it more carefully: inside the program we store a type, and treat the string only as packaging for input/output.
Let’s start with the model. Let each operation have the moment the record was created: createdAt: Instant. A moment is convenient for logs, synchronization, and sorting, but we’ll cover sorting in detail in the next lecture.
import kotlin.time.Instant
data class Expense(
val id: Int,
val title: String,
val amountCents: Int,
val createdAt: Instant,
)
Now let’s write a small helper function: convert the moment createdAt into local “clock time” for displaying to the user. Here we intentionally take a TimeZone as a parameter so we don’t hide the “magic system zone” inside the function.
import kotlin.time.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
fun asLocalDateTime(instant: Instant, tz: TimeZone): LocalDateTime =
instant.toLocalDateTime(tz)
And use it when printing a line (for now, using a simple toString() without a custom user format; formatting will be in a separate lecture):
import kotlinx.datetime.TimeZone
fun printExpenseLine(e: Expense) {
val tz = TimeZone.currentSystemDefault()
val local = asLocalDateTime(e.createdAt, tz)
println("#${e.id} ${e.title}: ${e.amountCents} cents @ $local")
// example: #1 Coffee: 350 cents @ 2026-01-14T13:05:00.123
}
From an architecture point of view, this is a small but important step: inside the system we store a moment (Instant), and when showing it to the user we derive local components (LocalDateTime) via explicitly chosen rules (TimeZone). This exact scenario—“Instant as the source of truth → LocalDateTime for UI”—is considered the baseline and recommended.
9. Common mistakes
Mistake #1: store the moment of an event as LocalDateTime and “assume” a time zone.
LocalDateTime looks convenient (“date and time!”), so it is often taken as a universal type. The problem is that without a zone it does not say when exactly it happened. Today you assumed “server time”, tomorrow the server moved to another region, and suddenly “all events became 3 hours earlier”. Instant is much more reliable for the fact of an event, while LocalDateTime is better kept as a presentation type for display.
Mistake #2: do LocalDateTime -> Instant without understanding ambiguity.
Even with a zone, the conversion can land in a “time gap” or a “repeated time” during clock transitions. The library will return a value according to its rules, but the meaning of that decision must be conscious: for schedules, deadlines, and legally significant events you sometimes need to think through the policy separately. The documentation explicitly warns that the reverse conversion is not always unambiguous.
Mistake #3: use TimeZone.currentSystemDefault() everywhere as the only correct zone.
For UI—“show the user as on their computer”—the system zone is an excellent choice. But if you write logs, synchronize data, send events between machines, or save to a file for another environment, the “system zone” becomes a hidden dependency. In such places, it’s better to pass TimeZone explicitly as a parameter or agree on a standard zone (often UTC).
Mistake #4: think that Instant is “a UTC date and time”.
Instant is a moment, and UTC is a way to represent a moment as local components. Yes, toString() often looks like an ISO string with a Z, and it can feel like “UTC time”. But conceptually, an Instant does not have to be “in UTC”; it simply lies on the time line. To get “date/time on a clock”, you always convert through a TimeZone.
Mistake #5: try to “choose one type for all cases”.
Sometimes you want to say: “let the project use only Instant, and that’s it”. Or the opposite: “let everything be LocalDateTime, it’s more convenient for me”. But different tasks really require different concepts: a date of birth is a LocalDate, while the moment a log record was written is an Instant. When you choose the type by meaning, half of future bugs simply never happen, because the compiler won’t let you mix things that should not be mixed.