Date & Time #

Working with dates and times is one of those topics that looks easy but is full of traps — different time zones, DST (Daylight Saving Time), local calendars, and inconsistent formats. Kotlin uses the java.time API introduced in Java 8, which is far better than the old java.util.Date and Calendar. The API is designed with immutability as a principle: all operations produce new objects, never modifying the old ones. This article covers the main java.time classes, how to create, manipulate, format, and compare dates and times, plus best patterns for real-world applications.

Why Not java.util.Date? #

Before diving into java.time, it’s important to understand why the old API should be avoided:

// ANTI-PATTERN: java.util.Date — full of problems
val oldDate = java.util.Date()
// - Mutable — can be changed after creation, bug-prone
// - Misleading name (Date also stores time, in milliseconds since epoch)
// - Non-intuitive API (months start at 0, year = year - 1900)
// - Not thread-safe

// CORRECT: java.time — immutable, expressive, thread-safe
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.Instant

val now = LocalDateTime.now()

The java.time Class Map #

Choosing the right class is the first crucial step:

flowchart TD
    A{What needs\nto be stored?} --> B{Need a\ntime zone?}
    B -- Yes --> C["ZonedDateTime\nExample: 2024-08-24T10:30+07:00[Asia/Jakarta]"]
    B -- No --> D{Need\ntime?}
    D -- No --> E["LocalDate\nExample: 2024-08-24"]
    D -- Time only --> F["LocalTime\nExample: 10:30:45"]
    D -- Both --> G["LocalDateTime\nExample: 2024-08-24T10:30:45"]
    A --> H{Universal\npoint in time?}
    H -- Yes --> I["Instant\nExample: 2024-08-24T03:30:45Z (UTC)"]
    A --> J{Difference between\ntwo times?}
    J -- Time-based --> K["Duration\nExample: 8 hours 30 minutes"]
    J -- Date-based --> L["Period\nExample: 1 year 2 months 3 days"]

LocalDate — Date Without Time #

LocalDate represents a calendar date (year, month, day) without time or time zone information. Suitable for birth dates, deadlines, calendar schedules.

import java.time.LocalDate
import java.time.Month
import java.time.DayOfWeek

// Creating a LocalDate
val today = LocalDate.now()
val specificDate = LocalDate.of(2024, 8, 17)
val fromMonth = LocalDate.of(2024, Month.AUGUST, 17)
val fromString = LocalDate.parse("2024-08-17")  // default ISO format: yyyy-MM-dd

println(today)           // 2024-08-17 (for example)
println(specificDate)    // 2024-08-17

// Accessing components
println(specificDate.year)        // 2024
println(specificDate.monthValue)  // 8
println(specificDate.month)       // AUGUST
println(specificDate.dayOfMonth)  // 17
println(specificDate.dayOfWeek)   // SATURDAY
println(specificDate.dayOfYear)   // 230

// Manipulation — always produces a new object (immutable)
val tomorrow      = today.plusDays(1)
val lastWeek      = today.minusWeeks(1)
val nextMonth     = today.plusMonths(1)
val lastYear      = today.minusYears(1)

// Useful info
println(specificDate.isLeapYear)       // false
println(specificDate.lengthOfMonth())  // 31 (August)
println(specificDate.lengthOfYear())   // 366 if leap, 365 if not

// Start and end of month/year
val startOfMonth = specificDate.withDayOfMonth(1)
val endOfMonth = specificDate.withDayOfMonth(specificDate.lengthOfMonth())
val startOfYear  = specificDate.withDayOfYear(1)

LocalTime — Time Without Date #

LocalTime represents a time of day (hours, minutes, seconds, nanoseconds) without date or time zone information.

import java.time.LocalTime

val now = LocalTime.now()
val specificTime = LocalTime.of(14, 30, 0)        // 14:30:00
val withNanos   = LocalTime.of(9, 0, 45, 500_000_000) // 09:00:45.500
val fromString  = LocalTime.parse("14:30:00")

println(specificTime.hour)        // 14
println(specificTime.minute)      // 30
println(specificTime.second)      // 0
println(specificTime.nano)        // 0

// Manipulation
val oneHourLater = specificTime.plusHours(1)    // 15:30
val fiveMinEarlier = specificTime.minusMinutes(5) // 14:25

// Useful constants
println(LocalTime.MIN)      // 00:00
println(LocalTime.MAX)      // 23:59:59.999999999
println(LocalTime.NOON)     // 12:00
println(LocalTime.MIDNIGHT) // 00:00

// Check business hours
fun isBusinessHours(time: LocalTime): Boolean {
    val start = LocalTime.of(9, 0)
    val end = LocalTime.of(17, 0)
    return !time.isBefore(start) && time.isBefore(end)
}

println(isBusinessHours(LocalTime.of(10, 30)))  // true
println(isBusinessHours(LocalTime.of(18, 0)))   // false

LocalDateTime — Date and Time #

LocalDateTime combines LocalDate and LocalTime without time zone information. Suitable for local schedules like “meeting on August 24, 2024 at 10:30”.

import java.time.LocalDateTime

val now = LocalDateTime.now()
val specific  = LocalDateTime.of(2024, 8, 24, 10, 30, 0)
val fromDate  = LocalDate.of(2024, 8, 24).atTime(10, 30)
val fromTime  = LocalDate.of(2024, 8, 24).atTime(LocalTime.of(10, 30))

// Extract components
println(specific.toLocalDate())   // 2024-08-24
println(specific.toLocalTime())   // 10:30

// Manipulation
val twoHoursLater  = specific.plusHours(2)       // 2024-08-24T12:30
val threeDaysLater = specific.plusDays(3)        // 2024-08-27T10:30
val startOfDay     = specific.toLocalDate().atStartOfDay()  // 2024-08-24T00:00

// Change specific components
val changeHour = specific.withHour(15)               // 2024-08-24T15:30
val changeDate = specific.withDayOfMonth(1)          // 2024-08-01T10:30

ZonedDateTime — Date Time with a Zone #

ZonedDateTime is a LocalDateTime complete with a time zone. Use this when dealing with users in different time zones, cross-zone scheduling, or distributed systems.

import java.time.ZonedDateTime
import java.time.ZoneId

// Indonesian time zone list
val wib  = ZoneId.of("Asia/Jakarta")       // UTC+7
val wita = ZoneId.of("Asia/Makassar")      // UTC+8
val wit  = ZoneId.of("Asia/Jayapura")      // UTC+9

val nowWib = ZonedDateTime.now(wib)
println(nowWib)  // 2024-08-24T10:30:00+07:00[Asia/Jakarta]

// Conversion between time zones
val jakartaTime = ZonedDateTime.of(2024, 8, 24, 10, 30, 0, 0, wib)
val tokyoTime   = jakartaTime.withZoneSameInstant(ZoneId.of("Asia/Tokyo"))
val londonTime  = jakartaTime.withZoneSameInstant(ZoneId.of("Europe/London"))

println("Jakarta: ${jakartaTime.toLocalTime()}")  // 10:30
println("Tokyo:   ${tokyoTime.toLocalTime()}")    // 12:30 (+2 hours from Jakarta)
println("London:  ${londonTime.toLocalTime()}")   // 03:30 (-7 hours from Jakarta)

// List all available time zones
ZoneId.getAvailableZoneIds()
    .filter { it.startsWith("Asia/") }
    .sorted()
    .forEach { println(it) }
LocalDateTime doesn’t contain time zone information. This means “2024-08-24T10:30” could mean 10:30 WIB or 10:30 UTC — ambiguous. When storing times that need cross-zone interpretation (e.g., calendar events, flight schedules), always use ZonedDateTime or Instant.

Instant — The Universal Point in Time #

Instant represents a single absolute point in time on the universal timeline — the number of milliseconds (or nanoseconds) since the Unix epoch (January 1, 1970 00:00:00 UTC). This is what you should store in the database for timestamps.

import java.time.Instant
import java.time.ZoneId

val now = Instant.now()
println(now)  // 2024-08-24T03:30:00Z (always UTC, marked with 'Z')

// From epoch millis (e.g., from System.currentTimeMillis())
val fromMillis = Instant.ofEpochMilli(System.currentTimeMillis())
val fromSeconds = Instant.ofEpochSecond(1_724_464_200L)

// To epoch millis
println(now.toEpochMilli())  // 1724464200000 (for example)

// Convert Instant to ZonedDateTime for local display
val displayWib = now.atZone(ZoneId.of("Asia/Jakarta"))
println(displayWib)  // 2024-08-24T10:30:00+07:00[Asia/Jakarta]

// Comparison
val t1 = Instant.now()
Thread.sleep(100)
val t2 = Instant.now()
println(t1.isBefore(t2))   // true
println(t2.isAfter(t1))    // true

Formatting and Parsing #

DateTimeFormatter is used to convert date/time objects to String and back.

import java.time.format.DateTimeFormatter
import java.util.Locale

// Built-in formats
val isoDate = LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE)
println(isoDate)  // 2024-08-24

// Custom format
val formatter = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale("id", "ID"))
val date = LocalDate.of(2024, 8, 17)
println(date.format(formatter))  // 17 Agustus 2024

// Time format
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss")
println(LocalTime.now().format(timeFormatter))  // 10:30:45

// Full format
val dtFormatter = DateTimeFormatter.ofPattern("EEEE, dd MMMM yyyy 'at' HH:mm", Locale("id", "ID"))
println(LocalDateTime.now().format(dtFormatter))
// Sabtu, 24 Agustus 2024 pukul 10:30

// Parsing a String into a date object
val inputFormat = DateTimeFormatter.ofPattern("dd/MM/yyyy")
val parsedDate = LocalDate.parse("17/08/2024", inputFormat)
println(parsedDate)  // 2024-08-17

// Safe parsing (doesn't throw exceptions)
fun parseLocalDateSafe(input: String, pattern: String): LocalDate? {
    return runCatching {
        LocalDate.parse(input, DateTimeFormatter.ofPattern(pattern))
    }.getOrNull()
}

println(parseLocalDateSafe("17/08/2024", "dd/MM/yyyy"))   // 2024-08-17
println(parseLocalDateSafe("not a date", "dd/MM/yyyy"))   // null

Common Format Patterns #

SymbolMeaningExample
yyyy4-digit year2024
yy2-digit year24
MM2-digit month08
MMMShort month nameAug
MMMMFull month nameAugust
dd2-digit day17
EEEShort day nameSat
EEEEFull day nameSaturday
HHHour (00–23)14
hhHour (01–12)02
mmMinutes30
ssSeconds45
aAM/PMPM
zShort time zone nameWIB
ZUTC offset+0700

Duration and Period #

Duration measures time-based differences (hours, minutes, seconds), while Period measures calendar-based differences (years, months, days).

import java.time.Duration
import java.time.Period

// Duration — for time differences in hours/minutes/seconds
val workStart = LocalTime.of(9, 0)
val workEnd = LocalTime.of(17, 30)
val workDuration = Duration.between(workStart, workEnd)

println(workDuration.toHours())   // 8
println(workDuration.toMinutes()) // 510
println(workDuration.toSeconds()) // 30600

// Duration between two Instants
val t1 = Instant.now()
Thread.sleep(1500)
val t2 = Instant.now()
val elapsed = Duration.between(t1, t2)
println("Elapsed time: ${elapsed.toMillis()} ms")  // about 1500

// Create a Duration directly
val oneHour = Duration.ofHours(1)
val thirtyMinutes = Duration.ofMinutes(30)
val combined = oneHour.plus(thirtyMinutes)
println(combined.toMinutes())  // 90

// Period — for date differences in years/months/days
val birth = LocalDate.of(1995, 3, 15)
val today = LocalDate.now()
val age = Period.between(birth, today)

println("Age: ${age.years} years ${age.months} months ${age.days} days")

// Create a Period directly
val threeMonths = Period.ofMonths(3)
val deadline = LocalDate.now().plus(threeMonths)
println("Deadline: $deadline")

// ChronoUnit for total calculations in one unit
import java.time.temporal.ChronoUnit

val daysUntil = ChronoUnit.DAYS.between(today, LocalDate.of(2025, 1, 1))
println("Days until new year: $daysUntil")

val monthsUntil = ChronoUnit.MONTHS.between(today, LocalDate.of(2025, 1, 1))
println("Months until new year: $monthsUntil")

Comparing Dates and Times #

val t1 = LocalDate.of(2024, 1, 1)
val t2 = LocalDate.of(2024, 8, 17)
val t3 = LocalDate.of(2024, 1, 1)

// Comparison methods
println(t1.isBefore(t2))   // true
println(t2.isAfter(t1))    // true
println(t1.isEqual(t3))    // true

// compareTo — useful for sorting
println(t1.compareTo(t2))  // negative (t1 is earlier)
println(t2.compareTo(t1))  // positive (t2 is later)
println(t1.compareTo(t3))  // 0 (equal)

// Check whether a date is within a certain range
fun LocalDate.isWithin(start: LocalDate, end: LocalDate): Boolean {
    return !this.isBefore(start) && !this.isAfter(end)
}

val date = LocalDate.of(2024, 4, 15)
val q2Start = LocalDate.of(2024, 4, 1)
val q2End = LocalDate.of(2024, 6, 30)
println(date.isWithin(q2Start, q2End))  // true

// Sorting a list of LocalDate
val randomDates = listOf(
    LocalDate.of(2024, 8, 1),
    LocalDate.of(2024, 3, 15),
    LocalDate.of(2024, 6, 30)
)
println(randomDates.sorted())  // [2024-03-15, 2024-06-30, 2024-08-01]

Real-World Application Patterns #

Storing Time in a Database #

// Recommendation: store as Instant (UTC timestamp) in the database
// Display to users in their own time zone

data class Transaction(
    val id: Long,
    val amount: Double,
    val createdAt: Instant = Instant.now()  // store UTC
)

fun displayTransaction(t: Transaction, userZone: ZoneId) {
    val localTime = t.createdAt.atZone(userZone)
    val formatter = DateTimeFormatter.ofPattern("dd MMM yyyy HH:mm", Locale("id", "ID"))
    println("Transaction #${t.id}: Rp${t.amount} on ${localTime.format(formatter)}")
}

val trx = Transaction(1, 150_000.0)
displayTransaction(trx, ZoneId.of("Asia/Jakarta"))   // WIB
displayTransaction(trx, ZoneId.of("Asia/Makassar"))  // WITA

Calculating Age #

fun calculateAge(birthDate: LocalDate): String {
    val today = LocalDate.now()
    require(!birthDate.isAfter(today)) { "Birth date must not be in the future" }

    val age = Period.between(birthDate, today)
    return "${age.years} years ${age.months} months ${age.days} days"
}

println(calculateAge(LocalDate.of(1995, 3, 15)))

Validating Business Hours #

data class BusinessHours(val open: LocalTime, val close: LocalTime) {
    fun isOpen(time: LocalTime = LocalTime.now()): Boolean {
        return !time.isBefore(open) && time.isBefore(close)
    }

    fun timeUntilClose(time: LocalTime = LocalTime.now()): Duration? {
        if (!isOpen(time)) return null
        return Duration.between(time, close)
    }
}

val supermarket = BusinessHours(LocalTime.of(8, 0), LocalTime.of(22, 0))
println(supermarket.isOpen(LocalTime.of(14, 0)))   // true
println(supermarket.isOpen(LocalTime.of(23, 0)))   // false
supermarket.timeUntilClose(LocalTime.of(21, 30))?.let {
    println("Store closes in ${it.toMinutes()} minutes")  // 30 minutes
}

Summary #

  • Choose the right classLocalDate for dates only, LocalTime for time only, LocalDateTime for both without a zone, ZonedDateTime for time with a zone, Instant for universal timestamps.
  • Instant for the database — always store timestamps as Instant (UTC) in the database. Convert to the local time zone only when displaying to users.
  • Avoid java.util.Date and Calendar — these old APIs are mutable, not thread-safe, and have confusing APIs. Always use java.time.
  • LocalDateTime has no time zone — without a zone, “14:30” could mean WIB or UTC. Use ZonedDateTime or Instant for times that need cross-zone interpretation.
  • All java.time objects are immutable — operations like plusDays() and minusMonths() always produce new objects. No need to worry about one object being modified elsewhere.
  • Duration for time, Period for datesDuration.between(t1, t2) for hour/minute/second differences; Period.between(d1, d2) for year/month/day differences. Use ChronoUnit for a total in one unit.
  • DateTimeFormatter with Locale — include Locale("id", "ID") when formatting month or day names so they appear in Indonesian, not English.
  • Safe parsing with runCatchingLocalDate.parse() throws an exception for invalid formats. Wrap it with runCatching { }.getOrNull() for safe parsing of user input.

← Previous: Map   Next: Regex →

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