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"]
TypeRepresentationWhen to Use
LocalDateDate only: 2024-03-15Birthdays, deadlines, schedules
LocalTimeTime only: 14:30:00Store opening hours, daily alarms
LocalDateTimeDate + time without TZLocal events, form input
InstantAn absolute point in timeLogs, database timestamps, APIs
TimeZoneA timezone representationConverting 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: LocalDate for dates only, LocalTime for times only, LocalDateTime for local form input, and Instant for every timestamp stored in a database or sent through an API.
  • Instant is 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 Instant and LocalDateTime. Use IANA IDs like "Asia/Jakarta", not static offsets like "+07:00" — static offsets don’t account for DST.
  • Inject Clock as a dependency so code depending on the current time is easy to test. Use Clock.System in production and a custom implementation in tests.
  • LocalDateTime isn’t an absolute timestamp — two people in different zones with the same LocalDateTime aren’t at the same moment. Don’t store LocalDateTime in a database as a timestamp.
  • DatePeriod differs from DurationDatePeriod(months = 1) can mean 28, 29, 30, or 31 days depending on the starting month. Use DatePeriod for calendar arithmetic, Duration for linear time spans.
  • Parsing and formatting use a type-safe DSL builder: LocalDate.Format { dayOfMonth(); char('/'); monthNumber(); char('/'); year() }. Create custom MonthNames for month names in Indonesian.
  • Clock.System.todayIn(timezone) is the correct way to get “today” — always include the timezone explicitly.
  • Interoperability with java.time is 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.

← Previous: Duration   Next: Reflection →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact