Date pickers are a timezone bug with a calendar attached

Compose's date and time pickers are straightforward components hiding a genuine trap: the selected value is UTC-midnight epoch millis, and converting it naively shifts dates by one in most of the world.

6 min read
androidcomposekotlinmaterial3

Day 42 — Date pickers are a timezone bug with a calendar attached

Day 42 of 100. The components are easy. The data they hand back has a sharp edge that produces bug reports nobody can reproduce in the office.

The symptom

A booking screen where the date is off by one, for some users:

val state = rememberDatePickerState()

DatePicker(state = state)

Button(onClick = {
    val millis = state.selectedDateMillis ?: return@Button
    val date = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDate()
    viewModel.book(date)
}) { Text("Book") }

Users in London see the right date. Users in Los Angeles pick the 15th and book the 14th. Users in Auckland pick the 15th and book the 15th, except in the evening.

QA in one timezone finds nothing.

Why the obvious fix fails

The obvious fix is to add a day when it looks wrong:

val date = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDate()
    .plusDays(if (offsetIsNegative) 1 else 0)      // no

This is patching a symptom whose sign depends on the user's offset, so it fixes half the world and breaks the other half. The underlying value hasn't been misunderstood any less.

The picker returns UTC-midnight millis; converting through a local zone shifts the day

The actual mechanism

selectedDateMillis is midnight UTC on the selected calendar date. It is a date encoded as a timestamp, not a moment in the user's day.

Converting it through the system zone asks "what local date was it at 00:00 UTC", which for anyone west of Greenwich is the previous day.

The correct conversion never leaves UTC:

val date: LocalDate = Instant.ofEpochMilli(millis)
    .atZone(ZoneOffset.UTC)          // UTC, not systemDefault
    .toLocalDate()

Going the other way, for an initial selection:

val state = rememberDatePickerState(
    initialSelectedDateMillis = LocalDate.of(2026, 10, 9)
        .atStartOfDay(ZoneOffset.UTC)
        .toInstant()
        .toEpochMilli(),
)

ZoneOffset.UTC on both sides. The moment systemDefault() appears near a date picker, there's a latent off-by-one.

Constraining the selectable range

Two independent mechanisms, and they do different jobs:

val state = rememberDatePickerState(
    yearRange = 2026..2030,
    selectableDates = object : SelectableDates {
        override fun isSelectableDate(utcTimeMillis: Long): Boolean {
            val d = Instant.ofEpochMilli(utcTimeMillis).atZone(ZoneOffset.UTC).toLocalDate()
            return d.dayOfWeek != DayOfWeek.SATURDAY && d.dayOfWeek != DayOfWeek.SUNDAY
        }
        override fun isSelectableYear(year: Int) = year in 2026..2030
    },
)

yearRange limits what the year scroller offers; SelectableDates greys out individual days. Note the callback parameter is named utcTimeMillis — the API is telling you the convention, and it's worth reading as documentation.

Disabling dates in the picker is necessary and not sufficient. Validate on submit too: the state can be set programmatically, and a restored rememberSaveable value bypasses the picker entirely.

Time pickers, and the 12/24 question

val timeState = rememberTimePickerState(
    initialHour = 9,
    initialMinute = 30,
    is24Hour = DateFormat.is24HourFormat(LocalContext.current),
)

TimePicker(state = timeState)     // or TimeInput(state = timeState) for keyboard entry

is24Hour should follow the system setting, not the locale and never a hardcoded value. A user who has set 24-hour time expects it everywhere, and DateFormat.is24HourFormat is the one source of truth for that.

timeState.hour is always 0–23 regardless of the display format, which is the sensible choice and occasionally surprising — the AM/PM toggle is presentation only.

Time carries its own version of the date trap. A LocalTime of 09:30 is not a moment either — combining it with a date to get an Instant needs the user's zone, and needs to survive a daylight-saving transition where 02:30 may not exist or may happen twice:

val instant = date.atTime(time)
    .atZone(ZoneId.systemDefault())      // here systemDefault IS correct
    .toInstant()

Note the asymmetry: ZoneOffset.UTC for reading the picker's date, systemDefault() for turning a date-and-time into a real moment. The two look similar and mean opposite things, which is exactly why the bug is common.

TimePicker is the clock dial; TimeInput is the two text fields. Offer TimeInput when users are entering a known time and the dial when they're choosing one — and Material's guidance is to give people a way to switch, since the dial is slow for precise entry.

Wrapping in a dialog

Neither picker is a dialog. DatePickerDialog exists; the time picker needs your own:

if (showTimePicker) {
    AlertDialog(
        onDismissRequest = { showTimePicker = false },
        confirmButton = {
            TextButton(onClick = {
                onTimeSelected(LocalTime.of(timeState.hour, timeState.minute))
                showTimePicker = false
            }) { Text("OK") }
        },
        dismissButton = { TextButton(onClick = { showTimePicker = false }) { Text("Cancel") } },
        text = { TimePicker(state = timeState) },
    )
}

Day 39's rule applies: showTimePicker should be rememberSaveable, and the picker state should be too — rememberTimePickerState and rememberDatePickerState are both saveable, so a rotation mid-selection keeps the user's partial choice.

The date-range picker

DateRangePicker returns two nullable millis values with the same UTC convention, and adds one validation everyone forgets:

val start = rangeState.selectedStartDateMillis
val end = rangeState.selectedEndDateMillis
val valid = start != null && end != null && end >= start

A range with only a start is a legitimate intermediate state — the user has tapped once — so the confirm button should be disabled rather than the state treated as an error.

Ranges also need a maximum span in most products — "up to 90 nights" — and that check belongs next to the validity one, since a user who picks a two-year range gets a confusing failure much later otherwise.

How to prove it

The timezone bug is testable without a device, and this test is worth having permanently:

@Test fun selectedDateIsStableAcrossZones() {
    val millis = LocalDate.of(2026, 10, 9).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
    listOf("America/Los_Angeles", "Europe/London", "Pacific/Auckland").forEach { zone ->
        TimeZone.setDefault(TimeZone.getTimeZone(zone))
        assertEquals(LocalDate.of(2026, 10, 9), millisToLocalDate(millis))
    }
}

Written against the naive systemDefault() conversion, Los Angeles fails. That failing assertion is the entire post.

On device, set the phone to a UTC−8 timezone and book something. It takes a minute and finds the class of bug that otherwise reaches production.

Don't always show a picker

Worth saying because the component's existence invites overuse: for a date the user knows, typing is faster than tapping through a calendar.

DatePickerDialog supports both modes, and the toggle is one parameter:

val state = rememberDatePickerState(
    initialDisplayMode = DisplayMode.Input,     // text entry, with a switch to the calendar
)

DisplayMode.Input is the better default for a birth date — nobody browses to 1987 — while DisplayMode.Picker suits choosing an appointment in the next fortnight, where the day of the week matters and the calendar shows it.

The same reasoning applies to the whole component. A "today / tomorrow / next week" row of chips answers most scheduling questions with one tap, with the full picker behind a "Choose date" option for the rest. The picker is the fallback, not the interface.

What this generalizes to

The general lesson is that a date is not a timestamp. "October 9th" is a calendar concept with no instant attached; an epoch millisecond is a specific moment. Encoding the first as the second requires a convention, and every bug here comes from two pieces of code assuming different conventions.

The durable fix is to convert at the boundary and use a real date type inside — LocalDate in your domain, millis only where an API demands it. Anywhere a Long travels through the app as "a date", the next timezone bug is already written.

Tomorrow, Day 43: search bars and chips, closing the components pillar.


Day 42 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Pickers.