Fechas y horas en Kotlin: java.time, formatos en español y zonas de México

Calcular una edad, mostrar «miércoles 16 de septiembre», saber qué hora es en Tijuana cuando en la Ciudad de México son las 10: las fechas parecen sencillas hasta que dejan de serlo. Kotlin usa java.time, la API de fechas de la JVM. Aquí verás qué tipo usar en cada caso, cómo formatear en español y las trampas que más errores causan.

Qué tipo usar

Kotlin en la JVM usa java.time, la API de fechas de Java 8 en adelante: clara, inmutable (cada operación devuelve un objeto nuevo) y con un tipo para cada caso. Elegir bien el tipo resuelve la mitad de los problemas:

  • TipoGuardaPara
  • LocalDateaño, mes y díacumpleaños, fechas de pago, días festivos
  • LocalTimehora del díahorarios de apertura
  • LocalDateTimefecha y hora, sin zonauna cita en «la hora de aquí»
  • ZonedDateTimefecha, hora y zona horariareuniones entre ciudades, cambios de horario
  • Instantun instante exacto en UTCmarcas de tiempo, registros, bases de datos
  • Duration / Periodun lapso en horas o en días, meses y añosdiferencias y sumas

Todas las fechas de esta página son fijas, para que veas exactamente las mismas salidas que nosotros; en tu programa, la fecha de hoy es LocalDate.now().

Crear fechas y moverse por el calendario

import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime

fun main() {
    val fecha = LocalDate.of(2026, 11, 9)
    val hora = LocalTime.of(18, 30)
    val cita = LocalDateTime.of(fecha, hora)

    println(fecha)
    println(hora)
    println(cita)
    println("Día de la semana: ${fecha.dayOfWeek}, día ${fecha.dayOfYear} del año")
    println("¿2028 es bisiesto? ${LocalDate.of(2028, 1, 1).isLeapYear}")
    println("Noviembre tiene ${fecha.lengthOfMonth()} días")
    println("En 30 días: ${fecha.plusDays(30)}")
    println("Un mes antes del 31 de marzo: ${LocalDate.of(2026, 3, 31).minusMonths(1)}")
    println(fecha.isBefore(LocalDate.of(2026, 12, 12)))
}
salida2026-11-09
18:30
2026-11-09T18:30
Día de la semana: MONDAY, día 313 del año
¿2028 es bisiesto? true
Noviembre tiene 30 días
En 30 días: 2026-12-09
Un mes antes del 31 de marzo: 2026-02-28
true

Los meses van del 1 al 12 (no del 0 al 11, como en el viejo java.util.Date) y las sumas respetan el calendario: un mes antes del 31 de marzo es el 28 de febrero, porque no existe el 31. dayOfWeek es un enum con los nombres en inglés; para mostrarlo en español, usa un formato, como en la sección siguiente.

Formatos en español de México

import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale

val mx: Locale = Locale.of("es", "MX")

fun main() {
    val fecha = LocalDate.of(2026, 9, 16)
    val momento = LocalDateTime.of(2026, 9, 16, 9, 5)

    println(fecha.format(DateTimeFormatter.ofPattern("dd/MM/yyyy")))
    println(fecha.format(DateTimeFormatter.ofPattern("EEEE d 'de' MMMM 'de' yyyy", mx)))
    println(momento.format(DateTimeFormatter.ofPattern("d MMM yyyy, HH:mm", mx)))
    println(fecha.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(mx)))

    val leida = LocalDate.parse("25/12/2026", DateTimeFormatter.ofPattern("dd/MM/yyyy"))
    println(leida)
}
salida16/09/2026
miércoles 16 de septiembre de 2026
16 sep 2026, 09:05
16 de septiembre de 2026
2026-12-25

Con un Locale de español de México, EEEE da el nombre del día y MMMM el del mes, en minúsculas como se escriben en español. Los textos entre comillas simples ('de') se copian tal cual. Para leer una fecha escrita a la mexicana, parse con el mismo patrón.

Las letras que se confunden

En los patrones, la diferencia entre mayúscula y minúscula cambia el significado. Estas son las confusiones más comunes, con el 28 de diciembre de 2026 a las 9:05:

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

fun main() {
    val mx = Locale.of("es", "MX")
    val momento = LocalDateTime.of(2026, 12, 28, 9, 5)

    println("yyyy: " + momento.format(DateTimeFormatter.ofPattern("dd/MM/yyyy", mx)))
    println("YYYY: " + momento.format(DateTimeFormatter.ofPattern("dd/MM/YYYY", mx)))
    println("mm:   " + momento.format(DateTimeFormatter.ofPattern("dd/mm/yyyy", mx)))
    println("hh:   " + momento.withHour(21).format(DateTimeFormatter.ofPattern("hh:mm", mx)))
    println("HH:   " + momento.withHour(21).format(DateTimeFormatter.ofPattern("HH:mm", mx)))
}
salidayyyy: 28/12/2026
YYYY: 28/12/2027
mm:   28/05/2026
hh:   09:05
HH:   21:05
  • YYYY no es el año: es el año de la semana. La semana del 28 de diciembre de 2026 ya es la primera de 2027 (con las semanas que empiezan en domingo, como en México), y la fecha sale con el año equivocado. Solo pasa los últimos días de diciembre, así que el error se esconde en las pruebas. Usa siempre yyyy.
  • mm son minutos; el mes es MM.
  • hh es la hora de 1 a 12 (sin a. m. o p. m. no sabes cuál es); para 24 horas, HH.

El aviso de Locale(«es», «MX»)

En código antiguo verás Locale("es", "MX"). Funciona, pero ese constructor está marcado como obsoleto desde Java 19 y kotlinc lo avisa:

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

fun main() {
    val mx = Locale("es", "MX")
    println(LocalDate.of(2026, 9, 16).format(DateTimeFormatter.ofPattern("MMMM", mx)))
}
kotlincdeprecado.kt:6:14: warning: 'constructor(p0: String!, p1: String!): Locale' is deprecated. Deprecated in Java.
    val mx = Locale("es", "MX")
             ^^^^^^

La forma actual es Locale.of("es", "MX"), que es la que usa esta página. En Android, que va con versiones de Java más antiguas, sigue siendo habitual el constructor (o Locale.forLanguageTag("es-MX")).

Diferencias: Period, Duration y ChronoUnit

import java.time.Duration
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.Period
import java.time.temporal.ChronoUnit

fun main() {
    val nacimiento = LocalDate.of(1995, 4, 21)
    val hoy = LocalDate.of(2026, 11, 9)

    val edad = Period.between(nacimiento, hoy)
    println("Edad: ${edad.years} años, ${edad.months} meses y ${edad.days} días")
    println("Días vividos: ${ChronoUnit.DAYS.between(nacimiento, hoy)}")
    println("Días para Navidad: ${ChronoUnit.DAYS.between(hoy, LocalDate.of(2026, 12, 25))}")

    val entrada = LocalDateTime.of(2026, 11, 9, 8, 50)
    val salida = LocalDateTime.of(2026, 11, 9, 17, 35)
    val jornada = Duration.between(entrada, salida)
    println("Jornada: ${jornada.toHours()} h ${jornada.toMinutesPart()} min ($jornada)")
    println("Más 90 minutos: ${salida.plus(Duration.ofMinutes(90))}")
}
salidaEdad: 31 años, 6 meses y 19 días
Días vividos: 11525
Días para Navidad: 46
Jornada: 8 h 45 min (PT8H45M)
Más 90 minutos: 2026-11-09T19:05

Period cuenta en años, meses y días del calendario (lo que quieres para una edad) y Duration en horas, minutos y segundos exactos (lo que quieres para una jornada o un cronómetro). Para un solo número total, como «días que faltan», ChronoUnit.DAYS.between. PT8H45M es como se escribe una duración en ISO 8601.

Zonas horarias en México

México eliminó el horario de verano en 2022, salvo en la franja fronteriza del norte, que lo sigue cambiando a la par de Estados Unidos. Quintana Roo tiene su propia zona todo el año. Por eso la diferencia entre la Ciudad de México y Tijuana no es fija:

import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter

val formato: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm xxx")

fun mostrar(en: ZonedDateTime) {
    for (zona in listOf("America/Mexico_City", "America/Tijuana", "America/Cancun", "Europe/Madrid", "UTC")) {
        val ahi = en.withZoneSameInstant(ZoneId.of(zona))
        println("  ${zona.padEnd(20)} ${ahi.format(formato)}")
    }
}

fun main() {
    val cdmx = ZoneId.of("America/Mexico_City")

    println("Reunión el 9 de noviembre a las 10:00 en CDMX:")
    mostrar(ZonedDateTime.of(LocalDateTime.of(2026, 11, 9, 10, 0), cdmx))

    println("La misma hora el 1 de julio:")
    mostrar(ZonedDateTime.of(LocalDateTime.of(2026, 7, 1, 10, 0), cdmx))

    println("Como instante: " + ZonedDateTime.of(LocalDateTime.of(2026, 11, 9, 10, 0), cdmx).toInstant())
}
salidaReunión el 9 de noviembre a las 10:00 en CDMX:
  America/Mexico_City  2026-11-09 10:00 -06:00
  America/Tijuana      2026-11-09 08:00 -08:00
  America/Cancun       2026-11-09 11:00 -05:00
  Europe/Madrid        2026-11-09 17:00 +01:00
  UTC                  2026-11-09 16:00 +00:00
La misma hora el 1 de julio:
  America/Mexico_City  2026-07-01 10:00 -06:00
  America/Tijuana      2026-07-01 09:00 -07:00
  America/Cancun       2026-07-01 11:00 -05:00
  Europe/Madrid        2026-07-01 18:00 +02:00
  UTC                  2026-07-01 16:00 +00:00
Como instante: 2026-11-09T16:00:00Z

No calcules estas diferencias a mano: usa siempre el identificador de la zona (America/Mexico_City, America/Tijuana, America/Cancun…) y deja que java.time aplique las reglas. Esas reglas vienen en la base de datos de zonas horarias que trae el JDK y que se actualiza con él: si un país cambia de horario, mantén tu JDK al día. Para guardar un momento en una base de datos o un registro, usa Instant (en UTC) y convierte a la zona del usuario solo al mostrarlo.

El reloj, como parámetro

El código que llama a LocalDate.now() por dentro es difícil de probar: el resultado cambia cada día. java.time resuelve esto con Clock: todas las funciones now() aceptan uno, y Clock.fixed crea un reloj parado en el instante que elijas. En producción pasas Clock.systemDefaultZone() y en las pruebas, uno fijo:

import java.time.Clock
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId

fun saludo(reloj: Clock): String {
    val hora = java.time.LocalTime.now(reloj).hour
    return when (hora) {
        in 6..11 -> "Buenos días"
        in 12..18 -> "Buenas tardes"
        else -> "Buenas noches"
    }
}

fun diasParaVencer(vence: LocalDate, reloj: Clock) =
    java.time.temporal.ChronoUnit.DAYS.between(LocalDate.now(reloj), vence)

fun main() {
    val cdmx = ZoneId.of("America/Mexico_City")
    val manana = Clock.fixed(Instant.parse("2026-11-09T15:00:00Z"), cdmx)
    val noche = Clock.fixed(Instant.parse("2026-11-10T04:30:00Z"), cdmx)

    println("${saludo(manana)} (son las 9:00 en CDMX)")
    println("${saludo(noche)} (son las 22:30 en CDMX)")
    println("Faltan ${diasParaVencer(LocalDate.of(2026, 11, 30), manana)} días para el pago")
}
salidaBuenos días (son las 9:00 en CDMX)
Buenas noches (son las 22:30 en CDMX)
Faltan 21 días para el pago

Fechas que no existen y textos que no se entienden

import java.time.LocalDate

fun main() {
    println(LocalDate.of(2026, 2, 30))
}
salidaException in thread "main" java.time.DateTimeException: Invalid date 'FEBRUARY 30'
    at InvalidaKt.main(invalida.kt:4)

java.time no «ajusta» una fecha imposible: lanza DateTimeException. Al leer texto, el error es DateTimeParseException, y su mensaje dice qué parte falló:

import java.time.LocalDate
import java.time.format.DateTimeParseException

fun main() {
    for (texto in listOf("2026-11-09", "2026-13-01", "9/11/2026")) {
        try {
            println("$texto -> ${LocalDate.parse(texto)}")
        } catch (e: DateTimeParseException) {
            println("$texto -> ${e.message}")
        }
    }
}
salida2026-11-09 -> 2026-11-09
2026-13-01 -> Text '2026-13-01' could not be parsed: Invalid value for MonthOfYear (valid values 1 - 12): 13
9/11/2026 -> Text '9/11/2026' could not be parsed at index 0

Sin patrón, LocalDate.parse solo entiende el formato ISO (2026-11-09). Para «9/11/2026» hay que pasarle un DateTimeFormatter con d/M/yyyy.

Si vienes de Java: idéntico

Es la misma API que en Java, sin cambios; mira fechas en Java. Si haces Kotlin Multiplatform, donde no hay java.time, existe kotlinx-datetime, de JetBrains, con tipos parecidos (LocalDate, Instant, TimeZone).

Todo el código de esta página se compiló con Kotlin 2.4.21 y se ejecutó en la JVM de Temurin 25.0.5 en un contenedor limpio y sin red antes de publicarla; las salidas y los mensajes del compilador están copiados de esa ejecución.