1. Introduction
When we start working with date and time, an unpleasant truth suddenly becomes clear: a string is convenient as long as you are only printing it to the console. But as soon as you want to compare dates, sort events, or validate user input, a string turns into quicksand. That is why we need discipline: store date/time separately as a type, and use a string only for input and output.
Parsing is turning a string into a type (LocalDate, LocalDateTime, Instant). Formatting is the reverse operation: turning a type into a string. These operations are like entering and exiting the subway: mix them up and you are not where you meant to be—plus with the feeling that someone stole your common sense.
For this lecture, it is important to hold onto this idea: we do not “store the date as a string”; we store the date as a date, and we make the string only a “wrapper” for a human.
2. ISO-8601 and parse()
If programmers all over the world gathered and agreed on a single date format, it would probably be ISO‑8601. It does not look very “human-friendly”, but it is very “machine-friendly”: 2026-01-14 is unambiguous, sorts predictably, and does not make you guess whether the middle part is the month or the day. In kotlinx.datetime, many types can parse ISO strings via parse().
Let us agree on a practical rule right away: for exchange and storage, use ISO (what parse() understands), and for displaying to a person, you can make your own format (for example, dd.MM.yyyy). This separation is like the program’s “internal language” and the “UI language”.
A small example with LocalDate:
import kotlinx.datetime.LocalDate
fun main() {
val d = LocalDate.parse("2026-01-14")
println(d) // 2026-01-14
}
Here println(d) calls toString() (under the hood), and toString() for LocalDate usually returns the ISO form.
And here is an example with Instant:
import kotlinx.datetime.Instant
fun main() {
val t = Instant.parse("2026-01-14T12:30:00Z")
println(t) // 2026-01-14T12:30:00Z
}
Pay attention to Z: it means “UTC”. For Instant, it is important that the string defines a moment in time unambiguously (for example, via Z or an offset). Otherwise you get “local clocks with no conversion rules”, which is a different kind of entity.
Why parse() can fail—and why that is normal
The words “may throw an exception” sound scary, but in the parsing world that is just everyday life. If a user enters 2026-13-40, the date is impossible. If they enter hello, it does not look like a date at all. And at that moment the library has every right to say, “I do not understand”—via an exception.
In earlier topics we saw a similar story with numbers: toInt() can throw NumberFormatException if the string is not a number, so for user input it is better to use toIntOrNull().
With dates/time the idea is the same, only instead of toIntOrNull() we will usually have “our own …OrNull function”, built on runCatching.
3. Safe parsing: try/catch and runCatching
When you write a console program, user input is the outside world. And the outside world, as we know, is not obligated to be neat. So parsing must be safe: invalid input must not crash the program. In Kotlin there is the classic approach via try/catch, and a more compact one via runCatching.
Option using try/catch
In this approach, we explicitly catch the exception and return null if parsing failed:
import kotlinx.datetime.LocalDate
fun parseIsoDateOrNull(input: String): LocalDate? {
return try {
LocalDate.parse(input.trim())
} catch (e: Exception) {
null
}
}
This code reads fine, but it quickly grows if you end up having many such parsers.
Option using runCatching
runCatching lets you express “try it—if it does not work, do not crash” compactly:
import kotlinx.datetime.LocalDate
fun parseIsoDateOrNull(input: String): LocalDate? =
runCatching { LocalDate.parse(input.trim()) }.getOrNull()
Here getOrNull() returns the result on success or null on error. This is perfect for the pattern “read → prepare → parse → validate”.
Historically, there are even nuances around contracts and behavior of runCatching in complex cases, as you can see in Kotlin changelogs. But at our level the simple point matters: runCatching helps you avoid turning code into a forest of try/catch.
Why we return T?, not an error string
It is tempting to return an error text like "Invalid date format". But that quickly leads to confusion: the function seems to parse, but in reality it returns either a date or text. A T? contract is simpler: either you got the type, or null. And the message for the user is the responsibility of the input/output layer (CLI), which decides what to print.
4. toString() as the default format
When you print an object using println, Kotlin automatically calls toString(). For types from kotlinx.datetime, toString() usually returns an ISO representation. This is convenient for two reasons: it is unambiguous, and you can parse it back again via parse().
That means you get a natural “data cycle” in your program:
flowchart LR
A["Date/time type"] -->|"toString()"| B["String (usually ISO)"]
B -->|"parse()"| A
In this cycle, it is especially important that the type remains a type, and the string remains a string, and each tool does its job.
Example:
import kotlinx.datetime.LocalDate
fun main() {
val original = LocalDate.parse("2026-01-14")
val text = original.toString()
val parsedBack = LocalDate.parse(text)
println(text) // 2026-01-14
println(parsedBack) // 2026-01-14
}
At this point a thought usually appears: “Great, then we will always show ISO to the user.” And… sometimes that is fine. But often a user is more used to 14.01.2026, and that is where user-facing formatting begins.
5. Custom format and input
Formatting “for a person” is not about clever math; it is about carefully assembling a string. In the basic course we will not use complex formatters and localizations; we will make a clear manual formatting function using padStart, interpolation, and date fields.
Format dd.MM.yyyy for LocalDate
import kotlinx.datetime.LocalDate
fun formatRu(date: LocalDate): String {
val dd = date.dayOfMonth.toString().padStart(2, '0')
val mm = date.monthNumber.toString().padStart(2, '0')
return "$dd.$mm.${date.year}"
}
fun main() {
val d = LocalDate.parse("2026-01-04")
println(formatRu(d)) // 04.01.2026
}
What matters here is that we create a centralized rule. If tomorrow you decide you need the format dd/MM/yyyy, you change one function, not 25 println calls across the project.
Format dd.MM.yyyy HH:mm for LocalDateTime
LocalDateTime contains both date and time. We can extend formatting:
import kotlinx.datetime.LocalDateTime
fun formatRu(dt: LocalDateTime): String {
val dd = dt.dayOfMonth.toString().padStart(2, '0')
val mm = dt.monthNumber.toString().padStart(2, '0')
val hh = dt.hour.toString().padStart(2, '0')
val min = dt.minute.toString().padStart(2, '0')
return "$dd.$mm.${dt.year} $hh:$min"
}
fun main() {
val dt = LocalDateTime.parse("2026-01-14T09:05:00")
println(formatRu(dt)) // 14.01.2026 09:05
}
We are not trying to “defeat every format in the world”. We build one specific format that our application needs, and that is absolutely normal engineering practice.
User input: safely parse dd.MM.yyyy
Now for the unpleasant part: LocalDate.parse() is not required to understand the format 14.01.2026. So for a user-facing format we create a separate parser.
It is easy to fall into the trap of “I will just do it with split in five seconds”. And yes, you will. But then it turns out that 32.01.2026 also “parsed”, and that is no longer a date—it is calendar fan fiction. So the correct approach is: parse the numbers, then create a LocalDate via the constructor and catch the exception if the date is impossible.
import kotlinx.datetime.LocalDate
fun parseRuDateOrNull(input: String): LocalDate? {
val parts = input.trim().split(".")
if (parts.size != 3) return null
val day = parts[0].toIntOrNull() ?: return null
val month = parts[1].toIntOrNull() ?: return null
val year = parts[2].toIntOrNull() ?: return null
return runCatching { LocalDate(year, month, day) }.getOrNull()
}
Note: toIntOrNull() is the “safe number parsing” we already know, which prevents NumberFormatException. In exception documentation, this pattern is shown as the recommended alternative to the unsafe toInt().
A “smart” parser: accept ISO or dd.MM.yyyy
In real programs you often want to be kind to the user and accept multiple format variants. But kindness must be controlled, otherwise you will start “guessing” the input and accidentally interpret one thing as another.
A compromise: first try ISO (as the strictest and most unambiguous), then try the Russian-style format.
import kotlinx.datetime.LocalDate
fun parseDateFlexibleOrNull(input: String): LocalDate? {
val s = input.trim()
return runCatching { LocalDate.parse(s) }.getOrNull()
?: parseRuDateOrNull(s)
}
This code is good because it does not smear logic across the whole project. Ideally, functions like this should live in your “input/parsing layer”: small, single-purpose, with a clear T? contract.
6. Integrate into the app: add an operation date
Imagine our practical console project is a simple expense tracker. Previously, an expense could consist of an amount and a category. Now we will add the operation date as a LocalDate. The goal here is not to “build a super app”, but to show how parsing/formatting stops being chaos when it is extracted into functions.
Data model Expense with a date
import kotlinx.datetime.LocalDate
data class Expense(
val amount: Int,
val category: String,
val date: LocalDate,
)
Yes, it is only three fields, and yes, it already makes the program an order of magnitude clearer: the date is not a string—it is LocalDate.
Entering an expense: read the date as a string and parse it
import kotlinx.datetime.LocalDate
fun readExpenseDate(): LocalDate {
while (true) {
print("Date (YYYY-MM-DD or dd.MM.yyyy): ")
val input = readln()
val date = parseDateFlexibleOrNull(input)
if (date != null) return date
println("Could not understand the date. Example: 2026-01-14 or 14.01.2026")
}
}
Here the loop does what it should: it does not “beg for perfect input”, it calmly asks again. And most importantly, parsing is not scattered around the code—it lives in parseDateFlexibleOrNull.
Nice output for the expense list: format the date separately
fun printExpense(e: Expense) {
val dateText = formatRu(e.date)
println("${dateText} | ${e.category} | ${e.amount}")
// 14.01.2026 | Food | 1200
}
As a result, the output becomes “human-friendly”, while the data inside remains “machine-friendly”.
7. Error messages: do not lose the cause (carefully)
Sometimes you really want to tell the user why parsing failed: “month must be from 1 to 12”. For that, during debugging, you can temporarily pull the exception out of runCatching, but you should not do this everywhere all the time: you will quickly start printing scary texts like IllegalArgumentException: Invalid date..., which are understandable to the library but not to a person.
Exception documentation shows well that exceptions can be different and that their text is first of all a developer tool, not a user interface. So the usual approach is: short and clear for the user, and the detailed cause goes to a log (if there is one) or debug output.
Mini option “get the cause” (for example, for debugging):
import kotlinx.datetime.LocalDate
fun parseIsoDateDebug(input: String): LocalDate? {
val result = runCatching { LocalDate.parse(input.trim()) }
result.exceptionOrNull()?.let { e ->
println("DEBUG: parse error = ${e.message}")
}
return result.getOrNull()
}
This approach is useful, but it is important not to overuse it; otherwise you will turn a console program into a stream of “JVM suffering”.
8. Common mistakes
Mistake #1: parse a date “however it works” via split, but do not validate it against the calendar.
Beginners often split the string into parts, turn them into numbers, and think that is the end. The problem is that this way you can accept 99.99.2026 as a “valid date” at the level of Int types. The correct approach is: after parsing the numbers, still create LocalDate(...) and catch the error via runCatching, so the calendar can honestly say “no”.
Mistake #2: call parse() directly on user input without error handling.
LocalDate.parse(...) and Instant.parse(...) are designed for a correct string. External input almost never guarantees correctness, so the code starts crashing “at the worst possible moment” and ruins the UX. Instead, it is better to wrap parsing in runCatching { ... }.getOrNull() and return null, and then calmly ask the user again.
Mistake #3: mix “how to store” and “how to display”.
If you start storing the date as an already formatted string 14.01.2026, you make your own life harder: sorting, comparisons, filters—all turn back into string work. It is much more robust to store LocalDate and do formatting only right before output, with a separate function like formatRu(date).
Mistake #4: spread formatting across the entire codebase instead of using one function.
When the date format is smeared across println, in a week you will get three different date styles in one app: somewhere 2026-01-14, somewhere 14.1.2026, somewhere without leading zeros. Fixing it turns into archaeology. A centralized formatting function solves this immediately and enforces discipline in the project.
Mistake #5: try to build a “universal parser for everything” too early.
Sometimes you want to support ten formats, spaces, slashes, commas, and a lunar calendar. In practice, that turns input into a guessing game and the code into a set of fragile heuristics. At a basic level it is better to keep one exchange format (ISO via parse()) and one user format (for example, dd.MM.yyyy), and if you support both, do it strictly and predictably, like in parseDateFlexibleOrNull.
GO TO FULL VERSION