← Back to blog

Presenting kotlinx-locale

Building the data layer, one of the challenges was sharing values already formatted and correctly localized across the different platforms. The Android app, the iOS app and the web portal read from the same source, and for the same value they should render the same string.

Delegating that formatting to each platform is where the inconsistency came from. Hand the same date in the same locale to three platforms and you can get three different strings back. That inconsistency is one of the problems the data layer was built to remove, so leaving locale formatting to the platforms worked against its own purpose.

What ICU and CLDR are

Two names run through the rest of this post. CLDR is the data and ICU is the code.

CLDR, the Unicode Common Locale Data Repository, records for every language how to format a date, group digits, name a currency and order a person’s name. Unicode maintains it and cuts a numbered release a couple of times a year.

ICU, International Components for Unicode, is what reads that data. It takes CLDR plus a locale and turns a value into a string. Most platform locale APIs are ICU, or something shaped like it, running on a bundled copy of CLDR. When Android formats a date, when Apple’s frameworks do, when a browser’s Intl runs, they are almost always reading CLDR through ICU underneath.

You can see the split in one date. Take the long format. CLDR stores the pattern each locale uses and the words that fill it:

pt-BR    d 'de' MMMM 'de' y      month name "julho"
en-US    MMMM d, y               month name "July"

MMMM is the wide month, d the day, y the year, and the text in quotes is literal. ICU takes a date and a locale, looks up that locale’s pattern and names, and fills it in. Give it 27 July 2026:

pt-BR    27 de julho de 2026
en-US    July 27, 2026

The instruction to ICU is identical both times. Only the data it read changed.

That is why the two versions matter. A formatted result is a function of both the code and the data behind it, and every platform carries its own copy of each.

Why the platforms disagree

Kotlin has no multiplatform locale API. There is no common Locale type, and kotlinx-datetime ships no locale data on purpose, so common code cannot turn a date into “27 de julho de 2026” by itself.

The usual answer is expect/actual. Declare the formatting in common code and let each platform implement it with its own API. We tried that first. It does not hold, because the platforms do not model formatting the same way.

Android and the JVM format through DateTimeFormatter. iOS through NSDateFormatter. The web through Intl.DateTimeFormat, which follows a different model than either. The capabilities do not line up. Take skeleton formatting. You name the fields you want, the year, an abbreviated month, the day, and the locale decides their order and punctuation. Android and iOS do it. The web has no built-in equivalent. So expect/actual does not paper over the gap. It hands it to you, three capability sets to reconcile by hand.

One date, three platform formatters, three data versions A single LocalDate is handed to three different platform formatting APIs: DateTimeFormatter on Android and the JVM, NSDateFormatter on iOS, and Intl.DateTimeFormat on the web. Each formats through the CLDR data its host ships, which is tied to the operating system or browser version, so the same date can render differently across platforms and even across users on the same app build. LocalDate(2026, 7, 27) the same value everywhere Android / JVM DateTimeFormatter CLDR from the OS iOS NSDateFormatter CLDR from the OS Web Intl.DateTimeFormat CLDR from the browser three APIs, three data versions, results that can disagree
The same date, formatted by three platform APIs over three bundled CLDR versions. The results can disagree across platforms, and across users on different OS versions.

The version problem

Say you did all of that. Matched the APIs by hand and wrote the missing skeleton support yourself. One problem survives it.

Every platform ships its own copy of the ICU/CLDR data, and the version it ships is tied to the operating system. Two users on the exact same build of your app can see different strings, because their phones carry different CLDR versions. Each one is correct. They still disagree, and none of it is visible from the call site. So expect/actual cannot deliver consistency even in principle. The data under it is not the same data.

None of this is new. Twitter hit it on the web years ago and shipped twitter-cldr-rb, which carries its own CLDR data instead of trusting the host. The takeaway is the same for us. If you want the same result everywhere, own the data.

Ship the data, not a bridge to the host

So that is what we did. Instead of fighting each platform over what it can and cannot do, kotlinx-locale brings the data along. It compiles Unicode’s CLDR into Kotlin source, so the same call returns the same string on the JVM, Android, JS, Wasm and every Native target. The host’s locale APIs never run unless you ask for them. The data is pinned to one CLDR release, so a result changes when we bump the library, not when a user updates their phone.

val date = LocalDate(2026, 7, 27)

date.format(FormatStyle.FULL, Locale.forLanguageTag("pt-BR"))
// segunda-feira, 27 de julho de 2026

date.format(FormatStyle.MEDIUM, Locale.forLanguageTag("ja"))
// 2026/07/27

Those two calls return those two strings in the app and on the web, because neither one asks the platform anything.

One engine, one data version, the same result everywhere The same LocalDate is formatted by kotlinx-locale, which runs in common Kotlin and carries one pinned version of the CLDR data. Android, iOS and the web all receive the same string, because none of them consults its host's locale data. LocalDate(2026, 7, 27) the same value everywhere kotlinx-locale common Kotlin, one bundled CLDR version Android iOS Web
The same value, formatted once in common Kotlin over one pinned CLDR version. Every platform gets the same string, because none of them reads its host's locale data.

From a datetime gap to full standardization

It started narrow. All we needed was one date formatted the same on three surfaces. Once the data lived in Kotlin, every other part of localization was the same shape of problem, so we kept going. kotlinx-locale now does countries, currencies, numbers, time zones, relative and duration wording, person names and collation, all from the same CLDR data on every target.

Two pieces reach past CLDR on purpose. Currency identity, the numeric codes and the minor units a currency rounds to, is not in CLDR, so we vendor the official ISO 4217 list and generate from it, cross-checked against CLDR and the JDK where they overlap. Phone numbers are not a Unicode standard at all. They are Google’s libphonenumber, which might be standardized one day and is not today. We took it anyway. A localization library that cannot tell you whether a phone number is valid in a country is not finished.

At that point the goal had moved. It stopped being “format a date the same everywhere” and became one answer for anything locale-sensitive, identical on every Kotlin platform.

It is open source

kotlinx-locale is written entirely in common Kotlin and released under Apache 2.0. The code, the modules and the full API are on GitHub. If you build on Kotlin Multiplatform and your surfaces keep disagreeing about dates, currencies or numbers, it is for you.