Datetime #
Dates and times are one of the hardest domains in programming — confusing timezones, daylight saving time suddenly shifting offsets, the difference between local and universal time, and the diverse formats across regions. Java went through a long evolution here: the notoriously bad java.util.Date and Calendar, then the far better java.time (JSR-310) in Java 8. Kotlin comes with kotlinx-datetime — the official JetBrains library that brings a modern, multiplatform (JVM, JS, Native), and idiomatic Kotlin API. This article covers the entire datetime ecosystem in Kotlin: from the basic types, arithmetic operations, formatting, parsing, to correct timezone handling — including the common traps that make datetime bugs hard to track down.
Setting Up kotlinx-datetime #
kotlinx-datetime is a separate library that needs to be added as a dependency.
// build.gradle.kts
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.0")
}
// For Kotlin Multiplatform
kotlin {
sourceSets {
commonMain.dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.0")
}
}
}
All the main types live in the kotlinx.datetime package:
import kotlinx.datetime.*
The Datetime Type Hierarchy #
Before writing code, it’s important to understand the differences between the types — choosing the wrong type is the most common source of datetime bugs.
flowchart TD
A["Datetime Types"] --> B["Without a Timezone\n(Local)"]
A --> C["With a Timezone\n(Absolute)"]
B --> D["LocalDate\nDate only\n2024-03-15"]
B --> E["LocalTime\nTime only\n14:30:00"]
B --> F["LocalDateTime\nDate + time\n2024-03-15T14:30:00\nNo timezone info!"]
C --> G["Instant\nAn absolute point in time\nMilliseconds since the Unix epoch\nThe same everywhere in the world"]
C --> H["ZonedDateTime\n(via java.time on the JVM)\nInstant + TimeZone"]| Type | Representation | When to Use |
|---|---|---|
LocalDate | Date only: 2024-03-15 | Birthdays, deadlines, schedules |
LocalTime | Time only: 14:30:00 | Store opening hours, daily alarms |
LocalDateTime | Date + time without TZ | Local events, form input |
Instant | An absolute point in time | Logs, database timestamps, APIs |
TimeZone | A timezone representation | Converting between zones |
LocalDate — Date Only #
import kotlinx.datetime.*
// Creating a LocalDate
val hari = LocalDate(2024, 3, 15) // year, month, day
val hariBulan = LocalDate(2024, Month.MARCH, 15) // with the Month enum
// Today's date — needs a timezone!
val hariIni: LocalDate = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
// Parsing from Strings
val parsed = LocalDate.parse("2024-03-15") // ISO 8601
val parsed2 = LocalDate.parse("15/03/2024", LocalDate.Format { dayOfMonth(); char('/'); monthNumber(); char('/'); year() })
// Properties
println(hari.year) // 2024
println(hari.month) // MARCH
println(hari.monthNumber) // 3
println(hari.dayOfMonth) // 15
println(hari.dayOfWeek) // FRIDAY
println(hari.dayOfYear) // 75
// Operations — adding/subtracting periods
val besok = hari.plus(1, DateTimeUnit.DAY)
val mingguDepan = hari.plus(1, DateTimeUnit.WEEK)
val bulanDepan = hari.plus(1, DateTimeUnit.MONTH)
val tahunDepan = hari.plus(1, DateTimeUnit.YEAR)
val kemarin = hari.minus(1, DateTimeUnit.DAY)
// The difference between two dates
val mulai = LocalDate(2024, 1, 1)
val akhir = LocalDate(2024, 12, 31)
val selisihHari = mulai.until(akhir, DateTimeUnit.DAY) // 365
val selisihBulan = mulai.until(akhir, DateTimeUnit.MONTH) // 11
// Comparison
println(hari > LocalDate(2024, 1, 1)) // true
println(hari < LocalDate(2025, 1, 1)) // true
println(hari == LocalDate(2024, 3, 15)) // true
// Date ranges
val rentang = LocalDate(2024, 1, 1)..LocalDate(2024, 12, 31)
println(hari in rentang) // true
LocalDate Use Cases #
// Calculate age from a birth date
fun hitungUmur(tanggalLahir: LocalDate): Int {
val hariIni = Clock.System.todayIn(TimeZone.currentSystemDefault())
var umur = hariIni.year - tanggalLahir.year
if (hariIni.monthNumber < tanggalLahir.monthNumber ||
(hariIni.monthNumber == tanggalLahir.monthNumber &&
hariIni.dayOfMonth < tanggalLahir.dayOfMonth)) {
umur--
}
return umur
}
val lahir = LocalDate(1995, 8, 17)
println("Age: ${hitungUmur(lahir)} years")
// Check if a date is a weekend
fun LocalDate.isWeekend(): Boolean =
dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY
fun LocalDate.isWeekday(): Boolean = !isWeekend()
// Count working days between two dates
fun hitungHariKerja(mulai: LocalDate, akhir: LocalDate): Int {
var hariKerja = 0
var tanggal = mulai
while (tanggal <= akhir) {
if (tanggal.isWeekday()) hariKerja++
tanggal = tanggal.plus(1, DateTimeUnit.DAY)
}
return hariKerja
}
// The first and last day of the month
fun LocalDate.hariPertamaBulan() = LocalDate(year, monthNumber, 1)
fun LocalDate.hariTerakhirBulan() = plus(1, DateTimeUnit.MONTH)
.hariPertamaBulan()
.minus(1, DateTimeUnit.DAY)
val maret = LocalDate(2024, 3, 15)
println(maret.hariPertamaBulan()) // 2024-03-01
println(maret.hariTerakhirBulan()) // 2024-03-31
LocalTime — Time Only #
// Creating a LocalTime
val waktu = LocalTime(14, 30, 0) // hour, minute, second
val waktuMs = LocalTime(14, 30, 0, 500_000_000) // with nanoseconds
// Parsing
val parsed = LocalTime.parse("14:30:00")
val parsed2 = LocalTime.parse("14:30:00.500") // with fractional seconds
// Properties
println(waktu.hour) // 14
println(waktu.minute) // 30
println(waktu.second) // 0
println(waktu.nanosecond) // 0
// Comparison
println(LocalTime(9, 0) < LocalTime(17, 0)) // true (working hours)
println(LocalTime(12, 0) == LocalTime(12, 0)) // true
// Conversion to nanoseconds since midnight
val nsFromMidnight = waktu.toNanosecondOfDay() // 52_200_000_000_000 ns
// Time formatting
fun LocalTime.formatWIB(): String = "%02d:%02d".format(hour, minute)
println(waktu.formatWIB()) // "14:30"
// Store opening/closing hours
data class JamOperasional(val buka: LocalTime, val tutup: LocalTime) {
fun sedangBuka(waktuSekarang: LocalTime): Boolean =
waktuSekarang in buka..tutup
}
val jamToko = JamOperasional(LocalTime(9, 0), LocalTime(21, 0))
println(jamToko.sedangBuka(LocalTime(14, 30))) // true
println(jamToko.sedangBuka(LocalTime(22, 0))) // false
LocalDateTime — Local Date and Time #
LocalDateTime combines a date and a time but doesn’t store timezone information. It isn’t an absolute point in time — two people in different zones holding the same LocalDateTime are not at the same moment.
// Creating a LocalDateTime
val dt = LocalDateTime(2024, 3, 15, 14, 30, 0)
val dt2 = LocalDateTime(
date = LocalDate(2024, 3, 15),
time = LocalTime(14, 30, 0)
)
// Parsing
val parsed = LocalDateTime.parse("2024-03-15T14:30:00")
val parsed2 = LocalDateTime.parse("2024-03-15T14:30:00.500")
// Properties
println(dt.date) // 2024-03-15
println(dt.time) // 14:30
println(dt.year) // 2024
println(dt.month) // MARCH
println(dt.hour) // 14
// Operations
val dtBesok = dt.plus(1, DateTimeUnit.DAY)
val dtPlusJam = dt.plus(2, DateTimeUnit.HOUR)
// Converting to an Instant — needs a timezone!
val tz = TimeZone.of("Asia/Jakarta")
val instant: Instant = dt.toInstant(tz)
// From an Instant to a LocalDateTime
val dtKembali: LocalDateTime = instant.toLocalDateTime(tz)
// ANTI-PATTERN: storing a LocalDateTime in a database as an absolute time representation
// LocalDateTime "2024-03-15T14:30:00" in Jakarta ≠ in London!
// CORRECT: store an Instant in the database
// LocalDateTime is useful for:
// - Date-time form input from users
// - Events that are genuinely local (a concert in a specific city)
// - Schedule templates (every Monday at 09:00, regardless of timezone)
Instant — Absolute Points in Time #
Instant represents a specific point on the universal timeline — the same everywhere in the world. This is the type that should be used for storing timestamps.
// The current instant
val sekarang: Instant = Clock.System.now()
println(sekarang) // 2024-03-15T07:30:00Z (UTC)
// From Unix epoch milliseconds (from a database or API)
val dariEpochMs = Instant.fromEpochMilliseconds(1_710_487_800_000L)
val dariEpochSec = Instant.fromEpochSeconds(1_710_487_800L)
// To the Unix epoch
val epochMs: Long = sekarang.toEpochMilliseconds()
val epochSec: Long = sekarang.epochSeconds
// Parsing from ISO 8601
val parsed = Instant.parse("2024-03-15T07:30:00Z")
val parsedOffset = Instant.parse("2024-03-15T14:30:00+07:00")
// Operations — add/subtract Durations
val satuJamLalu: Instant = sekarang.minus(1.hours)
val besok: Instant = sekarang.plus(24.hours)
val semingguLagi: Instant = sekarang.plus(7.days)
// The difference between two Instants produces a Duration
val mulai = Instant.parse("2024-03-15T08:00:00Z")
val akhir = Instant.parse("2024-03-15T17:30:00Z")
val durasi: Duration = akhir - mulai
println(durasi) // 9h 30m
// Comparison
println(mulai < akhir) // true
println(sekarang > mulai) // true (assuming now is after mulai)
// Converting to a local timezone
val tzJakarta = TimeZone.of("Asia/Jakarta")
val waktuJakarta: LocalDateTime = sekarang.toLocalDateTime(tzJakarta)
val tzLondon = TimeZone.of("Europe/London")
val waktuLondon: LocalDateTime = sekarang.toLocalDateTime(tzLondon)
// The same Instant, different local times
println(waktuJakarta) // 14:30 (WIB = UTC+7)
println(waktuLondon) // 07:30 (when not in daylight saving)
Clock — an Abstraction for Testability #
// ANTI-PATTERN: Clock.System.now() directly in business logic
class PesananService {
fun buatPesanan(items: List<Item>): Pesanan {
return Pesanan(
items = items,
dibuat = Clock.System.now() // can't be tested!
)
}
}
// CORRECT: inject Clock as a dependency
class PesananService(private val clock: Clock = Clock.System) {
fun buatPesanan(items: List<Item>): Pesanan {
return Pesanan(
items = items,
dibuat = clock.now()
)
}
}
// Testing with a controlled time
@Test
fun `orders must have the correct timestamp`() {
val waktuTetap = Instant.parse("2024-03-15T10:00:00Z")
val clockPalsu = object : Clock {
override fun now() = waktuTetap
}
val service = PesananService(clock = clockPalsu)
val pesanan = service.buatPesanan(listOf(itemDummy))
assertEquals(waktuTetap, pesanan.dibuat)
}
TimeZone — Time Zones #
// Timezones by IANA ID
val tzJakarta = TimeZone.of("Asia/Jakarta") // WIB: UTC+7
val tzMakassar = TimeZone.of("Asia/Makassar") // WITA: UTC+8
val tzJayapura = TimeZone.of("Asia/Jayapura") // WIT: UTC+9
val tzUTC = TimeZone.UTC
val tzLokal = TimeZone.currentSystemDefault() // the system timezone
// List all available timezones
val semuaTZ = TimeZone.availableZoneIds
println("${semuaTZ.size} timezones available")
// Converting Instant ↔ LocalDateTime
val sekarang = Clock.System.now()
val localJakarta = sekarang.toLocalDateTime(tzJakarta)
val localMakassar = sekarang.toLocalDateTime(tzMakassar)
val localJayapura = sekarang.toLocalDateTime(tzJayapura)
println("WIB: $localJakarta")
println("WITA: $localMakassar")
println("WIT: $localJayapura")
// The current UTC offset
val offsetSekarang = tzJakarta.offsetAt(sekarang)
println("Jakarta offset: $offsetSekarang") // +07:00
// Converting between two timezones
fun konversiTimezone(
waktu: LocalDateTime,
dari: TimeZone,
ke: TimeZone
): LocalDateTime {
return waktu.toInstant(dari).toLocalDateTime(ke)
}
val meetingJakarta = LocalDateTime(2024, 3, 15, 14, 0)
val meetingLondon = konversiTimezone(
meetingJakarta,
TimeZone.of("Asia/Jakarta"),
TimeZone.of("Europe/London")
)
println("Meeting Jakarta: $meetingJakarta → London: $meetingLondon")
// Meeting Jakarta: 2024-03-15T14:00 → London: 2024-03-15T07:00 (UTC+0)
Formatting and Parsing #
import kotlinx.datetime.format.*
// Built-in formats — toString() produces ISO 8601
val tgl = LocalDate(2024, 3, 15)
println(tgl) // 2024-03-15
println(tgl.toString()) // 2024-03-15
val dt = LocalDateTime(2024, 3, 15, 14, 30, 0)
println(dt) // 2024-03-15T14:30
val instant = Instant.parse("2024-03-15T07:30:00Z")
println(instant) // 2024-03-15T07:30:00Z
// Custom formats with the LocalDate.Format builder
val formatIndonesia = LocalDate.Format {
dayOfMonth()
char(' ')
monthName(MonthNames.ENGLISH_FULL) // or create custom MonthNames
char(' ')
year()
}
println(tgl.format(formatIndonesia)) // "15 March 2024"
// A number-only format
val formatAngka = LocalDate.Format {
dayOfMonth()
char('/')
monthNumber()
char('/')
year()
}
println(tgl.format(formatAngka)) // "15/03/2024"
// Month names in Indonesian
val namaBulanID = MonthNames(
"Januari", "Februari", "Maret", "April", "Mei", "Juni",
"Juli", "Agustus", "September", "Oktober", "November", "Desember"
)
val namaHariID = DayOfWeekNames(
"Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu", "Minggu"
)
val formatLengkapID = LocalDate.Format {
dayOfWeek(namaHariID)
chars(", ")
dayOfMonth()
char(' ')
monthName(namaBulanID)
char(' ')
year()
}
println(tgl.format(formatLengkapID)) // "Jumat, 15 Maret 2024"
// Formatting a LocalDateTime
val formatDTID = LocalDateTime.Format {
dayOfMonth()
char('/')
monthNumber()
char('/')
year()
chars(" at ")
hour()
char(':')
minute()
}
println(dt.format(formatDTID)) // "15/03/2024 at 14:30"
// Parsing with a custom format
val tglParsed = LocalDate.parse("15/03/2024", formatAngka)
println(tglParsed) // 2024-03-15
Interoperability with java.time (JVM) #
If a project already uses java.time, kotlinx-datetime provides easy conversions.
// Converting kotlinx-datetime → java.time
val instant: Instant = Clock.System.now()
val javaInstant: java.time.Instant = instant.toJavaInstant()
val localDate: LocalDate = LocalDate(2024, 3, 15)
val javaLocalDate: java.time.LocalDate = localDate.toJavaLocalDate()
val localDT: LocalDateTime = LocalDateTime(2024, 3, 15, 14, 30)
val javaLocalDT: java.time.LocalDateTime = localDT.toJavaLocalDateTime()
// Converting java.time → kotlinx-datetime
val backToKotlin: Instant = javaInstant.toKotlinInstant()
val backToDate: LocalDate = javaLocalDate.toKotlinLocalDate()
// Useful when working with Java libraries that use java.time
// e.g., JDBC, Hibernate, or other libraries
fun simpanKeDatabase(pesanan: Pesanan, koneksi: java.sql.Connection) {
val stmt = koneksi.prepareStatement("INSERT INTO pesanan (dibuat) VALUES (?)")
stmt.setObject(1, pesanan.dibuat.toJavaInstant()
.atOffset(java.time.ZoneOffset.UTC))
stmt.execute()
}
Idiomatic Patterns in Production Code #
Timestamps in Databases #
// ANTI-PATTERN: storing a LocalDateTime in a database
data class Transaksi(
val id: Long,
val jumlah: Double,
val waktu: LocalDateTime // which timezone? Unclear!
)
// CORRECT: store an Instant for absolute timestamps
data class Transaksi(
val id: Long,
val jumlah: Double,
val dibuat: Instant, // absolute, unambiguous
val diupdate: Instant
)
// Repository layer — converting from/to epoch milliseconds
fun bacaTransaksi(row: ResultSet): Transaksi = Transaksi(
id = row.getLong("id"),
jumlah = row.getDouble("jumlah"),
dibuat = Instant.fromEpochMilliseconds(row.getLong("dibuat_ms")),
diupdate = Instant.fromEpochMilliseconds(row.getLong("diupdate_ms"))
)
fun simpanTransaksi(t: Transaksi, stmt: PreparedStatement) {
stmt.setLong(3, t.dibuat.toEpochMilliseconds())
stmt.setLong(4, t.diupdate.toEpochMilliseconds())
}
Periods — Calendar Ranges Aware of Months #
// DatePeriod for representing calendar periods (years, months, days)
// Unlike Duration which is linear — 1 month can be 28, 29, 30, or 31 days
val periode = DatePeriod(years = 1, months = 6, days = 15)
val mulai = LocalDate(2024, 1, 1)
val akhir = mulai.plus(periode)
println(akhir) // 2024-07-16
// DateTimePeriod — a period that also covers time
val periodeWaktu = DateTimePeriod(months = 3, hours = 2)
// Calculate the period between two dates
val ulangTahun = LocalDate(1995, 8, 17)
val hariIni = LocalDate(2024, 3, 15)
val umur = ulangTahun.periodUntil(hariIni)
println("${umur.years} years ${umur.months} months ${umur.days} days")
Date Validation #
// Parsing with error handling
fun parseTanggal(input: String): LocalDate? = try {
LocalDate.parse(input)
} catch (e: IllegalArgumentException) {
null
}
// Validating the Indonesian DD/MM/YYYY format
fun parseTanggalIndonesia(input: String): LocalDate? {
val format = LocalDate.Format {
dayOfMonth(); char('/'); monthNumber(); char('/'); year()
}
return try {
LocalDate.parse(input, format)
} catch (e: IllegalArgumentException) {
null
}
}
parseTanggalIndonesia("15/03/2024") // 2024-03-15
parseTanggalIndonesia("31/02/2024") // null (February 31 doesn't exist)
parseTanggalIndonesia("abc") // null
// Validating date ranges
fun validasiRentang(mulai: LocalDate, akhir: LocalDate): Boolean {
return mulai <= akhir
}
fun validasiTidakMasaLalu(tgl: LocalDate): Boolean {
val hariIni = Clock.System.todayIn(TimeZone.currentSystemDefault())
return tgl >= hariIni
}
Common Traps to Avoid #
// TRAP 1: Storing a LocalDateTime as a timestamp
// LocalDateTime has no timezone information — can be wrong when DST changes
// USE Instant for all stored timestamps
// TRAP 2: Creating a LocalDate from "now" without a timezone
// ANTI-PATTERN:
// val hariIni = LocalDate.now() // this API doesn't exist in kotlinx-datetime! (intentionally)
// CORRECT:
val hariIni = Clock.System.todayIn(TimeZone.currentSystemDefault())
// TRAP 3: Inconsistent month arithmetic
val akhirJanuari = LocalDate(2024, 1, 31)
val bulanDepan = akhirJanuari.plus(1, DateTimeUnit.MONTH)
println(bulanDepan) // 2024-02-29 (leap year) or 2024-02-28
// Kotlinx-datetime handles this correctly — the result is the last day of the target month
// TRAP 4: Ignoring DST during conversions
// Europe/London has DST — the offset changes between GMT+0 and BST+1
val london = TimeZone.of("Europe/London")
val winter = LocalDateTime(2024, 1, 15, 12, 0).toInstant(london)
val summer = LocalDateTime(2024, 7, 15, 12, 0).toInstant(london)
println(winter.epochSeconds - summer.epochSeconds)
// Differs by 3600 seconds (1 hour) even though the LocalDateTime is the same, because of DST!
// TRAP 5: Parsing without an explicit format for non-ISO formats
// ANTI-PATTERN:
// LocalDate.parse("15-03-2024") // IllegalArgumentException! Not ISO 8601
// CORRECT:
val fmt = LocalDate.Format { dayOfMonth(); char('-'); monthNumber(); char('-'); year() }
LocalDate.parse("15-03-2024", fmt) // OK
Summary #
- Choose the right type:
LocalDatefor dates only,LocalTimefor times only,LocalDateTimefor local form input, andInstantfor every timestamp stored in a database or sent through an API.Instantis the safe choice for timestamps — it represents an absolute point in time that’s the same everywhere in the world, unambiguous about timezones.- You always need a timezone to convert between
InstantandLocalDateTime. Use IANA IDs like"Asia/Jakarta", not static offsets like"+07:00"— static offsets don’t account for DST.- Inject
Clockas a dependency so code depending on the current time is easy to test. UseClock.Systemin production and a custom implementation in tests.LocalDateTimeisn’t an absolute timestamp — two people in different zones with the sameLocalDateTimearen’t at the same moment. Don’t storeLocalDateTimein a database as a timestamp.DatePerioddiffers fromDuration—DatePeriod(months = 1)can mean 28, 29, 30, or 31 days depending on the starting month. UseDatePeriodfor calendar arithmetic,Durationfor linear time spans.- Parsing and formatting use a type-safe DSL builder:
LocalDate.Format { dayOfMonth(); char('/'); monthNumber(); char('/'); year() }. Create customMonthNamesfor month names in Indonesian.Clock.System.todayIn(timezone)is the correct way to get “today” — always include the timezone explicitly.- Interoperability with
java.timeis available via extension functions.toJavaInstant(),.toJavaLocalDate(),.toKotlinInstant()— useful when working with Java libraries.- Avoid manual date arithmetic (calculating days per month and leap years yourself) — let kotlinx-datetime handle it correctly, including edge cases like January 31 + 1 month.