Where Kotlin/Native and the JVM actually differ

If you develop on the JVM and ship a Kotlin/Native binary, you are relying on the two behaving the same. That reliance is usually backed by a checklist someone wrote from memory — hash order, string formatting, regex, dates — and not by a measurement. This is the measurement. The probe is in sborka: one commonTest source set, 129 observations, each printing one row of a transcript, run on jvm, macosArm64 and linuxX64.

The short answer is that the standard library agrees far more than the checklists suggest, that the JDK version matters as often as the target does, and that the differences which have actually cost production time are not in the standard library at all.

What was measured

Kotlin 2.4.10, kotlinx-serialization 1.11.0, kotlinx-datetime 0.8.0, JDK 25.0.2 and 17.0.20.1. Nothing is asserted in the probe: an assertion would encode which target is right, which is the question rather than the setup. compare.py prints both values and labels neither a reference.

./run.sh jvm
./run.sh macosArm64            # linuxX64 on a Linux host
./compare.py results/<a> results/<b>

What agrees, exactly

Forty-nine rows, zero differences, on every runtime and JDK tried:

Class Rows Includes
Number formatting 17 0.1 + 0.2, 1e23, 1e-5, -0.0, Double.MIN_VALUE, 1f/3f, toString(radix)
Strings and Unicode 19 "straße".uppercase(), the ligature, dotted İ, the Dž digraph, surrogate pairs, sorted(), trim() on NBSP
Hashing 10 String.hashCode including non-BMP text, data classes, lists, maps, Double, Long, Char
Seeded randomness 3 Random(42) sequence, nextDouble, shuffled

Twenty of the 22 regex probes agree too, including the constructs usually named as risks: \w on accented text, \d on Arabic-Indic digits, \p{L}, \p{IsCyrillic}, [[:alpha:]], fixed and variable look-behind, named groups, back-references, possessive quantifiers and atomic groups.

What differs

Seventeen rows of 129, on JDK 25:

Probe JVM Kotlin/Native
HashMap iteration order epsilon,zeta,eta,alpha,delta,theta,beta,gamma alpha,beta,gamma,delta,epsilon,zeta,eta,theta
the same map through kotlinx-serialization {"a":1,"b":3,"z":0,"m":2} {"z":0,"a":1,"m":2,"b":3}
1 / 0 ArithmeticException: / by zero ArithmeticException: null
"abc"[7] StringIndexOutOfBoundsException ArrayIndexOutOfBoundsException
"2147483648".toInt() NumberFormatException: For input string: … NumberFormatException: null
TimeZone.availableZoneIds.size 604 597 on macOS, 496 on Linux

Six of them are exception messages, and one of those changes the exception's class — both are IndexOutOfBoundsException subclasses, so a catch on the base type is unaffected, but a when (e) chain in common code takes a different branch.

Hash order is the only row with a silent consequence. Everything else surfaces as text a human reads. This one surfaces as different bytes in a payload, which matters the moment a golden file, a payload hash or an idempotency key touches it. Sorting at the boundary fixes it, and sorting is safe precisely because all nineteen string comparison probes agree.

The timezone row is not a language difference. kotlinx-datetime reads the host's zone database on Kotlin/Native, and the JDK ships its own. In a container that makes the zone table a property of the base image. Every zone offset probed — Moscow in 1990 and 2011, the Kiritimati date-line skip, a Berlin DST gap — agreed on every target.

The JDK is a variable, as often as the target is

Run the same probe on JDK 17 and five rows move, two of which change what you would otherwise conclude:

  • Regex("\bé").find("é") returns null on JDK 25 and é on JDK 17, which is what Kotlin/Native returns. This is JDK-19 behaviour (JDK-8264160 aligned \b with the ASCII \w it is defined against), not a Kotlin/Native difference at all.
  • 1e23 prints 9.999999999999999E22 on JDK 17 and 1.0E23 on JDK 25 — the shortest-representation fix from the same release. Native prints 1.0E23, so on JDK 17 there are two double-formatting differences that do not exist on JDK 25.

So the count is 17 on JDK 25 and 18 on JDK 17, and they are not the same eighteen. A page that names Kotlin and its libraries but not the JDK is wrong about those rows in a way no reader can detect.

Where the differences that cost something actually were

None of the seventeen has caused an outage. The ones that did are platform APIs:

  • ktor-client-cio has no TLS on Kotlin/NativeIllegalStateException: TLS sessions are not supported. Outbound HTTPS silently never left the service. Fixed with ktor-client-curl on native and CIO on the JVM through expect/actual.

  • ktor-server-compression is published for the JVM only, so compression moves into the image build as pre-made .gz files.

  • Dispatchers.IO on Kotlin/Native is an extension property in package kotlinx.coroutines and needs import kotlinx.coroutines.IO of its own. Without that import the compiler resolves the internal member of the same name and reports "it is internal" — which is accurate, and reads exactly like a platform limitation. It is not one: with the import it compiles for linuxX64 and macosArm64 and withContext(Dispatchers.IO) runs.

  • One library can be two libraries. sqlx4k's SQLite is the Rust driver on Kotlin/Native and Xerial's sqlite-jdbc on the JVM, behind one API. They disagree about in-memory databases: the JVM half refuses a connection pool larger than one — rightly, since a second connection to :memory: is a second, empty database — and pinning the pool to one then deadlocks. A test suite that ran on the native target alone never met either half.

And one that was true and no longer is: InetSocketAddress(host, port) used not to resolve a hostname on Kotlin/Native, which left agents reporting through a Kubernetes service name silent from their first day. Re-probed at Ktor 3.5.2 through ktor-network's own InetSocketAddress, a TCP connect to a hostname succeeds on macosArm64 and linuxX64 both. Which is the argument for measuring rather than listing: a hand-kept list of platform differences only ever grows, because nothing in it reports the day an entry stops being true.

Testing for the platform layer is harder than testing for the stdlib

The stdlib rows above are a transcript: run the same source twice, diff. The platform layer resists that, and it is worth knowing why before building a check for it.

An assertion has to go through the API the program uses, not the one underneath it. The hostname failure was in ktor's InetSocketAddress, so a probe calling getaddrinfo would have been green while a fleet of agents was silent.

And some of it cannot be asserted hermetically at all. "Does this engine support TLS" looks checkable: make an HTTPS request to a socket that hangs up, and see whether the failure is a refusal. On Kotlin/Native with the curl engine it is not, because curl reports its ordinary handshake failure as IllegalStateException — the same type ktor uses to refuse TLS outright:

IllegalStateException: Connection failed … Reason: SSL connect error (CURLE_SSL_CONNECT_ERROR)

No exception type separates reached the TLS layer and failed from has no TLS at all. Only the message text does, and a check resting on a sentence passes the day the sentence is reworded. What settles it is a service with a certificate to talk to — which is a stand, not a unit test.

Limits

macosArm64 and linuxX64 differ in one row of the 129 — the zone table — so the numbers above describe the runtimes rather than a host, with that one exception. The probe covers the standard library, the regex engine, kotlinx-serialization and kotlinx-datetime; it does not cover coroutines scheduling, file-system behaviour, or anything that needs a socket. The re-probed InetSocketAddress result is a TCP connect, and UDP takes a different path to the same address type. Every number is a property of the versions named at the top and of nothing else, which is why the transcripts are committed beside the probe rather than quoted only here.